Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/DelegatingWidget/Delegate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ const Delegate = ({
const bondWithHintArgs = {
amount: amount?.toString() ? parseEther(amount) : BigInt(0),
to,
isTransferStake,
oldDelegateNewPosPrev,
oldDelegateNewPosNext,
currDelegateNewPosPrev,
Expand Down
29 changes: 27 additions & 2 deletions components/DelegatingWidget/index.tsx
Comment thread
Jipperism marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { trackVercelAnalyticsEvent } from "@lib/analytics";
import { EnsIdentity } from "@lib/api/types/get-ens";
import { Box, Card, Flex, Text } from "@livepeer/design-system";
import { formatLPT } from "@utils/numberFormatters";
import { fromWei } from "@utils/web3";
import { AccountQueryResult, OrchestratorsSortedQueryResult } from "apollo";
import {
useAccountAddress,
useEnsData,
useExplorerStore,
useIsWrongRouteChain,
usePendingFeesAndStakeData,
} from "hooks";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";

import ArrowDown from "../../public/img/arrow-down.svg";
import Footer from "./Footer";
Expand Down Expand Up @@ -50,6 +52,7 @@ const Index = ({
const { selectedStakingAction, setSelectedStakingAction } =
useExplorerStore();
const isWrongRouteChain = useIsWrongRouteChain();
const accountAddress = useAccountAddress();

const pendingFeesAndStake = usePendingFeesAndStakeData(delegator?.id);

Expand All @@ -72,6 +75,28 @@ const Index = ({
fromWei(pendingFeesAndStake?.pendingStake ?? "0")
);

// Fire once per delegation attempt by a connected wallet, the first time
// the form goes from clean to dirty - not again on every clear/refill of
// the amount. Covers both typed amounts and the "max" shortcut.
const hasTrackedFormStart = useRef(false);
useEffect(() => {
if (
!hasTrackedFormStart.current &&
accountAddress &&
selectedStakingAction === "delegate" &&
parseFloat(amount) > 0
) {
hasTrackedFormStart.current = true;
trackVercelAnalyticsEvent("delegation_form_started");
}
}, [accountAddress, amount, selectedStakingAction]);

// Submitting ends the attempt, so the next one is tracked again.
const resetForm = () => {
setAmount("");
hasTrackedFormStart.current = false;
};

return (
<Box
css={{
Expand Down Expand Up @@ -196,7 +221,7 @@ const Index = ({
</>
)}
<Footer
reset={() => setAmount("")}
reset={resetForm}
data={{
isTransferStake: isTransferStake || false,
isMyTranscoder,
Expand Down
1 change: 1 addition & 0 deletions components/Drawer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const Index = ({
href={item.href}
passHref
variant="subtle"
onClick={item.onClick}
css={{
color:
asPath.split("?")[0] === item.as
Expand Down
1 change: 1 addition & 0 deletions hooks/useExplorerStore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type InputData = {
delegate?: Address;
fees?: bigint;
isOrchestrator?: boolean;
isTransferStake?: boolean;
newDelegate?: Address;
newPosNext?: string;
newPosPrev?: string;
Expand Down
9 changes: 9 additions & 0 deletions hooks/useHandleTransaction.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,20 @@
import { useAddRecentTransaction } from "@rainbow-me/rainbowkit";
import { renderHook } from "@testing-library/react";

import { trackTransaction } from "../lib/analytics";
import { useExplorerStore } from "./useExplorerStore";
import { useHandleTransaction } from "./useHandleTransaction";
import { useIsSafe } from "./wallet";

jest.mock("@rainbow-me/rainbowkit", () => ({
useAddRecentTransaction: jest.fn(),
}));
jest.mock("../lib/analytics", () => ({
trackTransaction: jest.fn(),
}));
jest.mock("wagmi", () => ({
useConfig: jest.fn(),
}));
jest.mock("./useExplorerStore", () => ({
useExplorerStore: jest.fn(),
}));
Expand Down Expand Up @@ -45,6 +52,7 @@ it("never tracks a Safe proposal that arrives before Safe detection finishes", (
rerender();

expect(addRecentTransaction).not.toHaveBeenCalled();
expect(trackTransaction).not.toHaveBeenCalled();
});

it("tracks an EOA transaction once after detection finishes", () => {
Expand All @@ -64,4 +72,5 @@ it("tracks an EOA transaction once after detection finishes", () => {
hash,
description: "Vote",
});
expect(trackTransaction).toHaveBeenCalledTimes(1);
});
9 changes: 8 additions & 1 deletion hooks/useHandleTransaction.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { trackTransaction } from "@lib/analytics";
import { useAddRecentTransaction } from "@rainbow-me/rainbowkit";
import { capitalCase } from "change-case";
import { useEffect, useRef } from "react";
import { isHash } from "viem";
import { useConfig } from "wagmi";

import {
InputData,
Expand All @@ -26,6 +28,7 @@ export const useHandleTransaction = (
setLatestTransactionDetails,
} = useExplorerStore();
const addRecentTransaction = useAddRecentTransaction();
const config = useConfig();
Comment thread
Jipperism marked this conversation as resolved.
const isSafe = useIsSafe();
const trackedHash = useRef<string | null>(null);

Expand All @@ -37,7 +40,9 @@ export const useHandleTransaction = (
}, [isLoading]);

// Safes return a Safe tx hash (or a non-hash value) instead of an on-chain
// tx hash, which RainbowKit would track forever or throw on.
// tx hash, which RainbowKit would track forever or throw on, and whose
// receipt would never arrive. Funnel events wait for a settled on-chain
// hash for the same reason.
useEffect(() => {
if (
data &&
Expand All @@ -49,8 +54,10 @@ export const useHandleTransaction = (
hash: data,
description: capitalCase(id),
});
trackTransaction(config, id, args, data);
trackedHash.current = data;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, isSafe, addRecentTransaction, id]);

useEffect(() => {
Expand Down
21 changes: 21 additions & 0 deletions layouts/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { SnackbarProvider } from "@components/Snackbar";
import TxStartedDialog from "@components/TxStartedDialog";
import TxSummaryDialog from "@components/TxSummaryDialog";
import URLVerificationBanner from "@components/URLVerificationBanner";
import {
trackVercelAnalyticsEvent,
trackWalletConnected,
} from "@lib/analytics";
import { IS_L2 } from "@lib/chains";
import { globalStyles } from "@lib/globalStyles";
import {
Expand Down Expand Up @@ -63,6 +67,7 @@ import { FiInfo } from "react-icons/fi";
import { LuRadioTower } from "react-icons/lu";
import { useWindowSize } from "react-use";
import { Chain } from "viem";
import { useAccountEffect } from "wagmi";

import {
useAccountAddress,
Expand Down Expand Up @@ -107,6 +112,7 @@ export type DrawerItem = {
as: string;
icon: React.ElementType;
className?: string;
onClick?: () => void;
};

const DesignSystemProviderTyped = DesignSystemProvider as React.FC<{
Expand Down Expand Up @@ -265,6 +271,15 @@ const Layout = ({ children, title = "Livepeer Explorer" }) => {
ReactGA.pageview(window.location.pathname + window.location.search);
}, []);

useAccountEffect({
onConnect: ({ isReconnected }) => {
// Reconnects restore a previous session, so they don't count.
if (!isReconnected) {
trackWalletConnected(asPath);
}
},
});

const items: DrawerItem[] = [
{
name: "Overview",
Expand All @@ -279,6 +294,7 @@ const Layout = ({ children, title = "Livepeer Explorer" }) => {
as: "/orchestrators",
icon: DNS,
className: "orchestrators",
onClick: () => trackVercelAnalyticsEvent("orchestrators_nav_clicked"),
},
{
name: "Gateways",
Expand Down Expand Up @@ -548,6 +564,11 @@ const Layout = ({ children, title = "Livepeer Explorer" }) => {
<Link passHref href="/orchestrators">
<Button
size="3"
onClick={() =>
trackVercelAnalyticsEvent(
"orchestrators_nav_clicked"
)
}
css={{
marginLeft: "$1",
backgroundColor: isOrchestratorsNavActive
Expand Down
171 changes: 171 additions & 0 deletions lib/analytics.ts
Comment thread
Jipperism marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { type BeforeSend, track } from "@vercel/analytics";
import type { InputData, TransactionIdentifier } from "hooks/useExplorerStore";
import type { Config } from "wagmi";
import { getPublicClient } from "wagmi/actions";

/**
* Replaces wallet addresses in tracked URLs (e.g. `/accounts/0x…`), so
* pageviews and events can't be tied to a visitor's wallet.
*/
export const redactAddresses: BeforeSend = (event) => ({
...event,
url: event.url.replace(/0x[0-9a-fA-F]{40}/g, "[address]"),
});

export type DelegationFunnelEvent =
| "orchestrators_nav_clicked"
| "orchestrators_page_viewed"
| "orchestrator_detail_viewed"
| "wallet_connected"
| "delegation_form_started"
| "delegation_transaction_submitted"
| "delegation_transaction_confirmed"
| "delegation_transaction_failed"
| "account_delegating_tab_viewed"
| "redelegation_started"
| "unbonding_or_exit_started";

export const trackVercelAnalyticsEvent = (
event: DelegationFunnelEvent,
properties?: Parameters<typeof track>[1]
) => {
if (typeof window === "undefined") {
return;
}

track(event, properties);
};

const NON_DELEGATION_PATHS = ["/migrate", "/voting", "/treasury"];

/** Coarse entry point a wallet was connected from; never identifying. */
const getWalletConnectSurface = (path: string) => {
if (path === "/") {
return "home";
}
if (path.startsWith("/orchestrators")) {
return "orchestrators_list";
}
if (path.startsWith("/accounts/")) {
return path.includes("/orchestrating") ? "orchestrator_detail" : "account";
}
return "other";
};

/**
* Tracks a wallet connect as delegation intent, tagged with the page it was
* made from. Migration and governance need a wallet for their own reasons,
* so connects there aren't tracked at all.
*/
export const trackWalletConnected = (asPath: string) => {
const path = asPath.split("?")[0];

if (
NON_DELEGATION_PATHS.some((nonDelegation) => path.startsWith(nonDelegation))
) {
return;
}

trackVercelAnalyticsEvent("wallet_connected", {
surface: getWalletConnectSurface(path),
});
Comment thread
ECWireless marked this conversation as resolved.
};

const trackedOnce = new Set<string>();

/**
* Tracks `event` at most once per page session for `key`, e.g. once per
* orchestrator no matter how often its pages are revisited.
*/
export const trackVercelAnalyticsEventOnce = (
event: DelegationFunnelEvent,
key: string
) => {
const id = `${event}:${key.toLowerCase()}`;

if (!trackedOnce.has(id)) {
trackedOnce.add(id);
trackVercelAnalyticsEvent(event);
}
};

type TransactionEvents = {
submitted: DelegationFunnelEvent;
confirmed?: DelegationFunnelEvent;
failed?: DelegationFunnelEvent;
};

const REDELEGATION_EVENTS: TransactionEvents = {
submitted: "redelegation_started",
};

const DELEGATION_EVENTS: Partial<
Record<TransactionIdentifier, TransactionEvents>
> = {
bond: {
submitted: "delegation_transaction_submitted",
confirmed: "delegation_transaction_confirmed",
failed: "delegation_transaction_failed",
},
unbond: { submitted: "unbonding_or_exit_started" },
rebond: REDELEGATION_EVENTS,
rebondFromUnbonded: REDELEGATION_EVENTS,
};

/**
* Tracks the funnel events for a submitted contract interaction: submitted
* right away, then confirmed or failed once it is mined. The receipt is
* awaited outside React, so the result is still tracked if the sending
* component unmounts, e.g. when the mobile delegate sheet is closed.
* "Move Delegated Stake" is a `bond` but counts as a redelegation.
*/
export const trackTransaction = (
config: Config,
id: TransactionIdentifier,
args: InputData,
hash: `0x${string}`
Comment thread
ECWireless marked this conversation as resolved.
) => {
const isRedelegation = id === "bond" && args.isTransferStake;
const events = isRedelegation ? REDELEGATION_EVENTS : DELEGATION_EVENTS[id];

if (!events) {
return;
}

trackVercelAnalyticsEvent(events.submitted);

const { confirmed, failed } = events;
const client = getPublicClient(config);

if ((!confirmed && !failed) || !client) {
return;
}

// Replacing the transaction in the wallet with a cancel, or with an
// unrelated transaction, means this interaction never happened, so neither
// outcome is tracked. Speeding it up is the same interaction and counts.
let isAbandoned = false;

client
.waitForTransactionReceipt({
hash,
timeout: 0,
onReplaced: ({ reason }) => {
isAbandoned = reason !== "repriced";
},
})
.then((receipt) => {
if (isAbandoned) {
return;
}

const event = receipt.status === "success" ? confirmed : failed;

if (event) {
trackVercelAnalyticsEvent(event);
}
})
// A receipt we can't fetch, e.g. because of an RPC error, says nothing
// about whether the interaction succeeded.
.catch(() => undefined);
};
Loading
Loading