From 3d7670534de42ef624777fe1978b6ff874dc1965 Mon Sep 17 00:00:00 2001 From: Joaquim Verges Date: Wed, 22 Jan 2025 14:49:51 +1300 Subject: [PATCH] feat: Add transaction analytics and tracking --- apps/dashboard/src/@/api/analytics.ts | 22 +++ .../(team)/_components/TotalSponsoredCard.tsx | 2 +- .../(team)/_components/TransactionsCard.tsx | 128 +++++++++++++++ .../[team_slug]/(team)/~/analytics/page.tsx | 27 +++- .../team/[team_slug]/[project_slug]/page.tsx | 146 +++++++++++++++++- .../components/Analytics/PieChartCard.tsx | 24 ++- .../src/data/analytics/fetch-analytics.ts | 7 + apps/dashboard/src/types/analytics.ts | 9 ++ packages/thirdweb/src/wallets/smart/index.ts | 44 +++++- 9 files changed, 394 insertions(+), 15 deletions(-) create mode 100644 apps/dashboard/src/app/team/[team_slug]/(team)/_components/TransactionsCard.tsx diff --git a/apps/dashboard/src/@/api/analytics.ts b/apps/dashboard/src/@/api/analytics.ts index d74af46a22f..c653fd1a5ce 100644 --- a/apps/dashboard/src/@/api/analytics.ts +++ b/apps/dashboard/src/@/api/analytics.ts @@ -3,6 +3,7 @@ import type { AnalyticsQueryParams, InAppWalletStats, RpcMethodStats, + TransactionStats, UserOpStats, WalletStats, WalletUserStats, @@ -85,6 +86,27 @@ export async function getUserOpUsage( return json.data as UserOpStats[]; } +export async function getClientTransactions( + params: AnalyticsQueryParams, +): Promise { + const searchParams = buildSearchParams(params); + const res = await fetchAnalytics( + `v1/transactions/client?${searchParams.toString()}`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + }, + ); + + if (res?.status !== 200) { + console.error("Failed to fetch client transactions stats"); + return []; + } + + const json = await res.json(); + return json.data as TransactionStats[]; +} + export async function getRpcMethodUsage( params: AnalyticsQueryParams, ): Promise { diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/_components/TotalSponsoredCard.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/_components/TotalSponsoredCard.tsx index b37dddd86bc..a1d4ad82730 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/_components/TotalSponsoredCard.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/_components/TotalSponsoredCard.tsx @@ -106,7 +106,7 @@ export async function TotalSponsoredChartCardUI({ return ( + // eslint-disable-next-line no-restricted-syntax + item.chainId && getChainMetadata(defineChain(Number(item.chainId))), + ), + ).then((chains) => chains.filter((c) => c) as ChainMetadata[]); + + // Process data to combine by date and chain type + const dateMap = new Map(); + for (const item of data) { + const chain = chains.find((c) => c.chainId === Number(item.chainId)); + + const existing = dateMap.get(item.date) || { mainnet: 0, testnet: 0 }; + if (chain?.testnet) { + existing.testnet += item.count; + } else { + existing.mainnet += item.count; + } + dateMap.set(item.date, existing); + } + + // Convert to array and sort by date + const timeSeriesData = Array.from(dateMap.entries()) + .map(([date, values]) => ({ + date, + mainnet: values.mainnet, + testnet: values.testnet, + total: values.mainnet + values.testnet, + })) + .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + const processedAggregatedData = { + mainnet: aggregatedData + .filter( + (d) => !chains.find((c) => c.chainId === Number(d.chainId))?.testnet, + ) + .reduce((acc, curr) => acc + curr.count, 0), + testnet: aggregatedData + .filter( + (d) => chains.find((c) => c.chainId === Number(d.chainId))?.testnet, + ) + .reduce((acc, curr) => acc + curr.count, 0), + total: aggregatedData.reduce((acc, curr) => acc + curr.count, 0), + }; + + const chartConfig = { + mainnet: { + label: "Mainnet Chains", + color: "hsl(var(--chart-1))", + }, + testnet: { + label: "Testnet Chains", + color: "hsl(var(--chart-2))", + }, + total: { + label: "All Chains", + color: "hsl(var(--chart-3))", + }, + }; + + if (onlyMainnet) { + const filteredData = timeSeriesData.filter((d) => d.mainnet > 0); + return ( +
+

+ {title || "Transactions"} +

+

{description}

+ } + /> +
+ ); + } + + return ( + processedAggregatedData[key]} + className={className} + // Get the trend from the last two COMPLETE periods + trendFn={(data, key) => + data.filter((d) => (d[key] as number) > 0).length >= 3 + ? ((data[data.length - 2]?.[key] as number) ?? 0) / + ((data[data.length - 3]?.[key] as number) ?? 0) - + 1 + : undefined + } + /> + ); +} diff --git a/apps/dashboard/src/app/team/[team_slug]/(team)/~/analytics/page.tsx b/apps/dashboard/src/app/team/[team_slug]/(team)/~/analytics/page.tsx index 9269b30d3f2..8b89ded9bbb 100644 --- a/apps/dashboard/src/app/team/[team_slug]/(team)/~/analytics/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/(team)/~/analytics/page.tsx @@ -1,4 +1,5 @@ import { + getClientTransactions, getInAppWalletUsage, getUserOpUsage, getWalletConnections, @@ -31,6 +32,7 @@ import { getValidAccount } from "app/account/settings/getAccount"; import { EmptyStateCard } from "app/team/components/Analytics/EmptyStateCard"; import { Suspense } from "react"; import { TotalSponsoredChartCardUI } from "../../_components/TotalSponsoredCard"; +import { TransactionsChartCardUI } from "../../_components/TransactionsCard"; // revalidate every 5 minutes export const revalidate = 300; @@ -100,6 +102,8 @@ async function OverviewPageContent(props: { inAppWalletUsage, userOpUsageTimeSeries, userOpUsage, + clientTransactionsTimeSeries, + clientTransactions, ] = await Promise.all([ // Aggregated wallet connections getWalletConnections({ @@ -135,6 +139,19 @@ async function OverviewPageContent(props: { to: range.to, period: "all", }), + // Client transactions + getClientTransactions({ + accountId: account.id, + from: range.from, + to: range.to, + period: interval, + }), + getClientTransactions({ + accountId: account.id, + from: range.from, + to: range.to, + period: "all", + }), ]); const isEmpty = @@ -180,6 +197,14 @@ async function OverviewPageContent(props: { /> )} + {clientTransactions.length > 0 && ( + + )} {userOpUsage.length > 0 ? ( ) : ( )} diff --git a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx index ddb734706ed..432c1f15587 100644 --- a/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx +++ b/apps/dashboard/src/app/team/[team_slug]/[project_slug]/page.tsx @@ -9,12 +9,14 @@ import { } from "components/analytics/date-range-selector"; import type { InAppWalletStats, + TransactionStats, UserOpStats, WalletStats, WalletUserStats, } from "types/analytics"; import { + getClientTransactions, getInAppWalletUsage, getUserOpUsage, getWalletConnections, @@ -23,12 +25,17 @@ import { } from "@/api/analytics"; import { EmptyStateCard } from "app/team/components/Analytics/EmptyStateCard"; import { Suspense } from "react"; +import { getContract } from "thirdweb"; import { type ChainMetadata, defineChain, getChainMetadata, } from "thirdweb/chains"; +import { shortenAddress } from "thirdweb/utils"; import { type WalletId, getWalletInfo } from "thirdweb/wallets"; +import { TransactionsChartCardUI } from "../(team)/_components/TransactionsCard"; +import { getThirdwebClient } from "../../../../@/constants/thirdweb.server"; +import { fetchDashboardContractMetadata } from "../../../../@3rdweb-sdk/react/hooks/useDashboardContractMetadata"; import { AnalyticsHeader } from "../../components/Analytics/AnalyticsHeader"; import { CombinedBarChartCard } from "../../components/Analytics/CombinedBarChartCard"; import { EmptyState } from "../../components/Analytics/EmptyState"; @@ -114,6 +121,8 @@ async function ProjectAnalytics(props: { inAppWalletUsage, userOpUsageTimeSeries, userOpUsage, + clientTransactionsTimeSeries, + clientTransactions, ] = await Promise.allSettled([ // Aggregated wallet connections getWalletConnections({ @@ -149,6 +158,19 @@ async function ProjectAnalytics(props: { to: range.to, period: "all", }), + // Client transactions + getClientTransactions({ + clientId: project.publishableKey, + from: range.from, + to: range.to, + period: interval, + }), + getClientTransactions({ + clientId: project.publishableKey, + from: range.from, + to: range.to, + period: "all", + }), ]); return ( @@ -174,12 +196,6 @@ async function ProjectAnalytics(props: { link="https://portal.thirdweb.com/connect/quickstart" /> )} -
{walletConnections.status === "fulfilled" && walletConnections.value.length > 0 ? ( @@ -200,6 +216,22 @@ async function ProjectAnalytics(props: { /> )}
+ {clientTransactionsTimeSeries.status === "fulfilled" && + clientTransactions.status === "fulfilled" && + clientTransactions.value.length > 0 && ( + <> + +
+ + +
+ + )} {userOpUsageTimeSeries.status === "fulfilled" && userOpUsage.status === "fulfilled" && userOpUsage.value.length > 0 ? ( @@ -216,6 +248,12 @@ async function ProjectAnalytics(props: { link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started" /> )} + ); } @@ -344,6 +382,100 @@ function AuthMethodDistributionCard({ data }: { data: InAppWalletStats[] }) { ); } +async function ChainDistributionCard({ data }: { data: TransactionStats[] }) { + const formattedData = await Promise.all( + data.map(async (w) => { + // eslint-disable-next-line no-restricted-syntax + const chain = await getChainMetadata(defineChain(Number(w.chainId))); + return { + chainId: w.chainId, + count: w.count, + chainName: chain?.slug, + }; + }), + ); + const reducedData = Object.entries( + formattedData.reduce( + (acc, curr) => { + acc[curr.chainName] = (acc[curr.chainName] || 0) + curr.count; + return acc; + }, + {} as Record, + ), + ).map(([key, value]) => ({ + label: key, + value, + })); + + const aggregateFn = () => reducedData.length; + + return ( + + ); +} + +async function ContractDistributionCard({ + data, +}: { data: TransactionStats[] }) { + const formattedData = ( + await Promise.all( + data.map(async (w) => { + // eslint-disable-next-line no-restricted-syntax + const chain = defineChain(Number(w.chainId)); + const chainMeta = await getChainMetadata(chain); + if (!w.contractAddress) { + return null; + } + const contractData = await fetchDashboardContractMetadata( + getContract({ + chain, + address: w.contractAddress, + client: getThirdwebClient(), + }), + ).catch(() => undefined); + return { + chainId: w.chainId, + count: w.count, + chainName: chainMeta?.slug || w.chainId.toString(), + contractAddress: w.contractAddress, + contractLabel: + contractData?.name || shortenAddress(w.contractAddress), + }; + }), + ) + ).filter((d) => d !== null); + + const reducedData = Object.entries( + formattedData.reduce( + (acc, curr) => { + acc[`${curr.chainName}:${curr.contractAddress}:${curr.contractLabel}`] = + (acc[ + `${curr.chainName}:${curr.contractAddress}:${curr.contractLabel}` + ] || 0) + curr.count; + return acc; + }, + {} as Record, + ), + ).map(([key, value]) => { + const [chainName, contractAddress, contractLabel] = key.split(":"); + return { + label: `${contractLabel} (${chainName})`, + link: `/${chainName}/${contractAddress}`, + value, + }; + }); + + const aggregateFn = () => reducedData.length; + + return ( + + ); +} + async function TotalSponsoredCard({ data, aggregatedData, @@ -417,7 +549,7 @@ async function TotalSponsoredCard({ return ( number; }) { const processedData = (() => { - if (data.length <= 9) return data; - // Sort by value descending const sorted = [...data].sort((a, b) => b.value - a.value); + if (sorted.length <= 9) return sorted; + // Take top 9 const top10 = sorted.slice(0, 9).map((item) => ({ ...item, @@ -62,13 +64,27 @@ export function PieChartCard({
{processedData.map( - ({ label, fill }: { label: string; fill?: string }) => ( + ({ + label, + fill, + link, + }: { label: string; fill?: string; link?: string }) => (
- {label} + {link ? ( + + {label} + + ) : ( + {label} + )}
), )} diff --git a/apps/dashboard/src/data/analytics/fetch-analytics.ts b/apps/dashboard/src/data/analytics/fetch-analytics.ts index 533c5ec789e..09b0988a6fd 100644 --- a/apps/dashboard/src/data/analytics/fetch-analytics.ts +++ b/apps/dashboard/src/data/analytics/fetch-analytics.ts @@ -24,6 +24,13 @@ export async function fetchAnalytics( decodeURIComponent(value), ); } + // client id DEBUG OVERRIDE + // ANALYTICS_SERVICE_URL.searchParams.delete("clientId"); + // ANALYTICS_SERVICE_URL.searchParams.delete("accountId"); + // ANALYTICS_SERVICE_URL.searchParams.append( + // "clientId", + // "...", + // ); return fetch(ANALYTICS_SERVICE_URL, { ...init, diff --git a/apps/dashboard/src/types/analytics.ts b/apps/dashboard/src/types/analytics.ts index e84c72767d1..772da2515bf 100644 --- a/apps/dashboard/src/types/analytics.ts +++ b/apps/dashboard/src/types/analytics.ts @@ -28,6 +28,15 @@ export interface UserOpStats { chainId?: string; } +export interface TransactionStats { + date: string; + chainId: number; + contractAddress?: string; + walletType?: string; + walletAddress?: string; + count: number; +} + export interface RpcMethodStats { date: string; evmMethod: string; diff --git a/packages/thirdweb/src/wallets/smart/index.ts b/packages/thirdweb/src/wallets/smart/index.ts index d7019ec11b3..8ae4f68dc73 100644 --- a/packages/thirdweb/src/wallets/smart/index.ts +++ b/packages/thirdweb/src/wallets/smart/index.ts @@ -1,4 +1,5 @@ import type * as ox__TypedData from "ox/TypedData"; +import { trackTransaction } from "../../analytics/track/transaction.js"; import type { Chain } from "../../chains/types.js"; import { getCachedChain } from "../../chains/utils.js"; import type { ThirdwebClient } from "../../client/client.js"; @@ -16,6 +17,7 @@ import { readContract } from "../../transaction/read-contract.js"; import { getAddress } from "../../utils/address.js"; import { isZkSyncChain } from "../../utils/any-evm/zksync/isZkSyncChain.js"; import type { Hex } from "../../utils/encoding/hex.js"; +import { resolvePromisedValue } from "../../utils/promise/resolve-promised-value.js"; import { parseTypedData } from "../../utils/signatures/helpers/parse-typed-data.js"; import { type SignableMessage, maxUint96 } from "../../utils/types.js"; import type { @@ -251,7 +253,8 @@ async function createSmartAccount( transaction, executeOverride: options.overrides?.execute, }); - return _sendUserOp({ + + const result = await _sendUserOp({ executeTx, options: { ...options, @@ -263,6 +266,15 @@ async function createSmartAccount( }, }, }); + trackTransaction({ + client: options.client, + chainId: options.chain.id, + transactionHash: result.transactionHash, + walletAddress: options.accountContract.address, + walletType: "smart", + contractAddress: transaction.to ?? undefined, + }); + return result; }, async sendBatchTransaction(transactions: SendTransactionOption[]) { const executeTx = prepareBatchExecute({ @@ -270,7 +282,7 @@ async function createSmartAccount( transactions, executeBatchOverride: options.overrides?.executeBatch, }); - return _sendUserOp({ + const result = await _sendUserOp({ executeTx, options: { ...options, @@ -278,6 +290,15 @@ async function createSmartAccount( accountContract, }, }); + trackTransaction({ + client: options.client, + chainId: options.chain.id, + transactionHash: result.transactionHash, + walletAddress: options.accountContract.address, + walletType: "smart", + contractAddress: transactions[0]?.to ?? undefined, + }); + return result; }, async signMessage({ message }: { message: SignableMessage }) { if (options.overrides?.signMessage) { @@ -433,6 +454,16 @@ function createZkSyncAccount(args: { transaction: serializableTransaction, signedTransaction, }); + + trackTransaction({ + client: connectionOptions.client, + chainId: chain.id, + transactionHash: txHash.transactionHash, + walletAddress: account.address, + walletType: "smart", + contractAddress: transaction.to ?? undefined, + }); + return { transactionHash: txHash.transactionHash, client: connectionOptions.client, @@ -495,6 +526,15 @@ async function _sendUserOp(args: { userOpHash, }); + trackTransaction({ + client: options.client, + chainId: options.chain.id, + transactionHash: receipt.transactionHash, + walletAddress: options.accountContract.address, + walletType: "smart", + contractAddress: await resolvePromisedValue(executeTx.to ?? undefined), + }); + return { client: options.client, chain: options.chain,