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
31 changes: 29 additions & 2 deletions apps/dashboard/src/@/api/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,11 @@ const cached_getAggregateUserOpUsage = unstable_cache(
]);

// Aggregate stats across wallet types
return userOpStats.reduce(
(acc, curr) => {
const aggregated = userOpStats.reduce(
(
acc: UserOpStats & { gasPriceSum: number; gasPriceCount: number },
curr,
) => {
// Skip testnets from the aggregated stats
if (curr.chainId) {
const chain = chains.data.find(
Expand All @@ -306,15 +309,39 @@ const cached_getAggregateUserOpUsage = unstable_cache(
acc.successful += curr.successful;
acc.failed += curr.failed;
acc.sponsoredUsd += curr.sponsoredUsd;
acc.gasUnits += curr.gasUnits;
// For avgGasPrice, we'll track sum and count for proper averaging
acc.gasPriceSum += curr.avgGasPrice * curr.successful;
acc.gasPriceCount += curr.successful;
return acc;
},
{
date: (params.from || new Date()).toISOString(),
failed: 0,
sponsoredUsd: 0,
successful: 0,
gasUnits: 0,
avgGasPrice: 0,
gasPriceSum: 0,
gasPriceCount: 0,
},
);

// Calculate the proper average gas price
aggregated.avgGasPrice =
aggregated.gasPriceCount > 0
? aggregated.gasPriceSum / aggregated.gasPriceCount
: 0;

// Return only the UserOpStats fields
return {
date: aggregated.date,
failed: aggregated.failed,
sponsoredUsd: aggregated.sponsoredUsd,
successful: aggregated.successful,
gasUnits: aggregated.gasUnits,
avgGasPrice: aggregated.avgGasPrice,
};
},
["getAggregateUserOpUsage"],
{
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/@/types/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export interface UserOpStats {
successful: number;
failed: number;
sponsoredUsd: number;
gasUnits: number;
avgGasPrice: number;
chainId?: string;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
"use client";

import { format } from "date-fns";
import { useMemo, useState } from "react";
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
import { DocLink } from "@/components/blocks/DocLink";
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
import { Button } from "@/components/ui/button";
import type { ChartConfig } from "@/components/ui/chart";
import { useAllChainsData } from "@/hooks/chains/allChains";
import { DotNetIcon } from "@/icons/brand-icons/DotNetIcon";
import { ReactIcon } from "@/icons/brand-icons/ReactIcon";
import { TypeScriptIcon } from "@/icons/brand-icons/TypeScriptIcon";
import { UnityIcon } from "@/icons/brand-icons/UnityIcon";
import { UnrealIcon } from "@/icons/brand-icons/UnrealIcon";
import type { UserOpStats } from "@/types/analytics";
import { formatTickerNumber } from "@/utils/format-utils";

type ChartData = Record<string, number> & {
time: string;
};

type MetricType =
| "sponsoredTransactions"
| "gasUnits"
| "avgGasPrice"
| "costPerGasUnit";

export function GasMetricsChartCard(props: {
userOpStats: UserOpStats[];
isPending: boolean;
}) {
const { userOpStats } = props;
const topChainsToShow = 10;
const chainsStore = useAllChainsData();
const [metricType, setMetricType] = useState<MetricType>(
"sponsoredTransactions",
);

const { chartConfig, chartData } = useMemo(() => {
const _chartConfig: ChartConfig = {};
const _chartDataMap: Map<string, ChartData> = new Map();
const chainIdToVolumeMap: Map<string, number> = new Map();

// for each stat, add it in _chartDataMap
for (const stat of userOpStats) {
const chartData = _chartDataMap.get(stat.date);
const { chainId } = stat;
const chain = chainsStore.idToChain.get(Number(chainId));
const chainName = chain?.name || chainId || "Unknown";

let value: number;
if (metricType === "sponsoredTransactions") {
value = stat.successful;
} else if (metricType === "gasUnits") {
value = stat.gasUnits;
} else if (metricType === "avgGasPrice") {
value = stat.avgGasPrice;
} else {
// costPerGasUnit: USD spent per gas unit (helps identify price spike impact)
value = stat.gasUnits > 0 ? stat.sponsoredUsd / stat.gasUnits : 0;
}

// if no data for current day - create new entry
if (!chartData) {
_chartDataMap.set(stat.date, {
time: stat.date,
[chainName]: value,
} as ChartData);
} else {
chartData[chainName] = (chartData[chainName] || 0) + value;
}
Comment on lines +53 to +72
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fix aggregation logic for average-based metrics.

The accumulation logic at line 71 uses simple addition for all metric types. This is mathematically correct for cumulative metrics (sponsoredTransactions, gasUnits) but incorrect for average-based metrics (avgGasPrice, costPerGasUnit).

When multiple UserOpStats entries share the same date and chain, summing avgGasPrice values produces a meaningless result (e.g., 50 Gwei + 60 Gwei = 110 Gwei is not a valid average). Similarly, summing cost-per-unit ratios distorts the true cost efficiency.

For average metrics, you need weighted aggregation:

  • avgGasPrice: Track total gas price sum and transaction count, then divide
  • costPerGasUnit: Track total sponsoredUsd and total gasUnits, then divide

Consider this approach:

-      let value: number;
-      if (metricType === "sponsoredTransactions") {
-        value = stat.successful;
-      } else if (metricType === "gasUnits") {
-        value = stat.gasUnits;
-      } else if (metricType === "avgGasPrice") {
-        value = stat.avgGasPrice;
-      } else {
-        // costPerGasUnit: USD spent per gas unit (helps identify price spike impact)
-        value = stat.gasUnits > 0 ? stat.sponsoredUsd / stat.gasUnits : 0;
-      }
+      // Store raw components for proper aggregation
+      const isSummableMetric = metricType === "sponsoredTransactions" || metricType === "gasUnits";

Then handle accumulation differently:

       if (!chartData) {
         _chartDataMap.set(stat.date, {
           time: stat.date,
-          [chainName]: value,
+          [chainName]: isSummableMetric ? value : { sum: stat.sponsoredUsd, units: stat.gasUnits, count: stat.successful, gasPriceSum: stat.avgGasPrice * stat.successful },
         } as ChartData);
       } else {
-        chartData[chainName] = (chartData[chainName] || 0) + value;
+        if (isSummableMetric) {
+          chartData[chainName] = (chartData[chainName] || 0) + value;
+        } else {
+          // Accumulate components for weighted average
+          const existing = chartData[chainName] || { sum: 0, units: 0, count: 0, gasPriceSum: 0 };
+          chartData[chainName] = {
+            sum: existing.sum + stat.sponsoredUsd,
+            units: existing.units + stat.gasUnits,
+            count: existing.count + stat.successful,
+            gasPriceSum: existing.gasPriceSum + (stat.avgGasPrice * stat.successful)
+          };
+        }
       }

Then compute final values after aggregation based on metricType.

Alternatively, if the backend guarantees unique date+chain combinations (one UserOpStats per date per chain), document this assumption clearly and add an assertion to catch violations early.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/account-abstraction/AccountAbstractionAnalytics/GasMetricsChartCard.tsx
around lines 53 to 72, the code currently adds metric values naively which is
correct for cumulative metrics but wrong for average-based metrics; change the
aggregation to maintain weighted accumulators: for avgGasPrice store and sum
{totalGasPriceSum, txCount} (e.g., totalGasPriceSum += avgGasPrice * successful;
txCount += successful) and for costPerGasUnit store and sum {totalSponsoredUsd,
totalGasUnits} (totalSponsoredUsd += sponsoredUsd; totalGasUnits += gasUnits);
when creating a new chart entry initialize these accumulator fields, when
merging add to them instead of adding a single computed ratio, and after all
data is aggregated compute the final display value per date+chain by dividing
the appropriate totals based on metricType (avgGasPrice =
totalGasPriceSum/txCount, costPerGasUnit = totalSponsoredUsd/totalGasUnits),
with guards for zero counts; if the backend guarantees unique date+chain entries
either document that assumption or add an assertion to catch violations.


chainIdToVolumeMap.set(
chainName,
value + (chainIdToVolumeMap.get(chainName) || 0),
);
}

const chainsSorted = Array.from(chainIdToVolumeMap.entries())
.sort((a, b) => b[1] - a[1])
.map((w) => w[0]);

const chainsToShow = chainsSorted.slice(0, topChainsToShow);
const chainsToTagAsOthers = chainsSorted.slice(topChainsToShow);

// replace chainIdsToTagAsOther chainId with "other"
for (const data of _chartDataMap.values()) {
for (const chainId in data) {
if (chainsToTagAsOthers.includes(chainId)) {
data.others = (data.others || 0) + (data[chainId] || 0);
delete data[chainId];
}
}
}

chainsToShow.forEach((chainName, i) => {
_chartConfig[chainName] = {
color: `hsl(var(--chart-${(i % 10) + 1}))`,
label: chainName,
};
});

// Add Other
if (chainsToTagAsOthers.length > 0) {
chainsToShow.push("others");
_chartConfig.others = {
color: "hsl(var(--muted-foreground))",
label: "Others",
};
}

return {
chartConfig: _chartConfig,
chartData: Array.from(_chartDataMap.values()).sort((a, b) => {
return new Date(a.time).getTime() - new Date(b.time).getTime();
}),
};
}, [userOpStats, chainsStore, metricType]);

const uniqueChainIds = Object.keys(chartConfig);
const disableActions =
props.isPending ||
chartData.length === 0 ||
chartData.every((data) => {
return Object.entries(data).every(([key, value]) => {
return key === "time" || value === 0;
});
});

const metricConfig = {
sponsoredTransactions: {
title: "Sponsored Transactions",
description: "Total number of sponsored transactions",
fileName: "Sponsored Transactions",
formatter: (value: number) => formatTickerNumber(value),
},
gasUnits: {
title: "Gas Units Consumed",
description: "Total gas units consumed by sponsored transactions",
fileName: "Gas Units Consumed",
formatter: (value: number) => formatTickerNumber(value),
},
avgGasPrice: {
title: "Average Gas Price",
description: "Average gas price in Gwei for sponsored transactions",
fileName: "Average Gas Price",
formatter: (value: number) => {
// Convert from wei to Gwei
const gwei = value / 1_000_000_000;
return `${formatTickerNumber(gwei)} Gwei`;
},
},
costPerGasUnit: {
title: "Cost per Gas Unit",
description:
"USD spent per gas unit - spikes indicate gas price increases",
fileName: "Cost per Gas Unit",
formatter: (value: number) => {
// Show in micro-USD for readability
return `$${(value * 1_000_000).toFixed(4)}µ`;
},
},
};

const config =
metricConfig[metricType as keyof typeof metricConfig] ||
metricConfig.sponsoredTransactions;

return (
<ThirdwebBarChart
chartClassName="aspect-[1] lg:aspect-[3.5]"
config={chartConfig}
customHeader={
<div className="relative px-6 pt-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
{config.title}
</h3>
<p className="text-muted-foreground text-sm">
{config.description}
</p>
</div>
<div className="flex gap-2 flex-wrap">
<Button
onClick={() => setMetricType("sponsoredTransactions")}
size="sm"
variant={
metricType === "sponsoredTransactions" ? "default" : "outline"
}
>
Transactions
</Button>
<Button
onClick={() => setMetricType("gasUnits")}
size="sm"
variant={metricType === "gasUnits" ? "default" : "outline"}
>
Gas Units
</Button>
<Button
onClick={() => setMetricType("avgGasPrice")}
size="sm"
variant={metricType === "avgGasPrice" ? "default" : "outline"}
>
Gas Price
</Button>
<Button
onClick={() => setMetricType("costPerGasUnit")}
size="sm"
variant={
metricType === "costPerGasUnit" ? "default" : "outline"
}
>
Cost Efficiency
</Button>
</div>
</div>

<div className="top-6 right-6 mt-4 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:my-0 md:flex md:mt-10">
<ExportToCSVButton
className="bg-background"
disabled={disableActions}
fileName={config.fileName}
getData={async () => {
const header = ["Date", ...uniqueChainIds];
const rows = chartData.map((data) => {
const { time, ...rest } = data;
return [
time,
...uniqueChainIds.map((w) => (rest[w] || 0).toString()),
];
});
return { header, rows };
}}
/>
</div>
</div>
}
data={chartData}
emptyChartState={<GasMetricsChartCardEmptyChartState />}
hideLabel={false}
isPending={props.isPending}
showLegend
toolTipLabelFormatter={(_v, item) => {
if (Array.isArray(item)) {
const time = item[0].payload.time as number;
return format(new Date(time), "MMM d, yyyy");
}
return undefined;
}}
toolTipValueFormatter={(value) => {
if (typeof value !== "number") {
return "";
}
return config.formatter(value);
}}
variant="stacked"
/>
);
}

function GasMetricsChartCardEmptyChartState() {
return (
<div className="flex flex-col items-center justify-center">
<span className="mb-6 text-lg">Sponsor gas for your users</span>
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
<DocLink
icon={TypeScriptIcon}
label="TypeScript"
link="https://portal.thirdweb.com/typescript/v5/account-abstraction/get-started"
/>
<DocLink
icon={ReactIcon}
label="React"
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
/>
<DocLink
icon={ReactIcon}
label="React Native"
link="https://portal.thirdweb.com/react/v5/account-abstraction/get-started"
/>
<DocLink
icon={UnityIcon}
label="Unity"
link="https://portal.thirdweb.com/unity/v5/wallets/account-abstraction"
/>
<DocLink
icon={UnrealIcon}
label="Unreal Engine"
link="https://portal.thirdweb.com/unreal-engine/blueprints/smart-wallet"
/>
<DocLink
icon={DotNetIcon}
label=".NET"
link="https://portal.thirdweb.com/dotnet/wallets/providers/account-abstraction"
/>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,17 @@ export function createUserOpStatsStub(days: number): UserOpStats[] {
const successful = Math.random() * 100;
const failed = Math.random() * 100;
const sponsoredUsd = Math.random() * 100;
const gasUnits = Math.random() * 1000000; // Random gas units between 0-1M
const avgGasPrice = Math.random() * 100000000000; // Random gas price in wei (0-100 Gwei)

stubbedData.push({
chainId: Math.floor(Math.random() * 100).toString(),
date: new Date(2024, 11, d).toLocaleString(),
failed,
sponsoredUsd,
successful,
gasUnits,
avgGasPrice,
});

if (Math.random() > 0.7) {
Expand Down
Loading
Loading