Skip to content

Commit 2352d3f

Browse files
Add AI analytics page with token usage chart and summary stats
Co-authored-by: joaquim.verges <[email protected]>
1 parent 44fdfee commit 2352d3f

File tree

7 files changed

+439
-0
lines changed

7 files changed

+439
-0
lines changed

apps/dashboard/src/@/api/analytics.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { unstable_cache } from "next/cache";
44
import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs";
55
import { normalizeTime } from "@/lib/time";
66
import type {
7+
AIUsageStats,
78
AnalyticsQueryParams,
89
EcosystemWalletStats,
910
EngineCloudStats,
@@ -204,6 +205,44 @@ export function getInAppWalletUsage(
204205
return cached_getInAppWalletUsage(normalizedParams(params), authToken);
205206
}
206207

208+
const cached_getAiUsage = unstable_cache(
209+
async (
210+
params: AnalyticsQueryParams,
211+
authToken: string,
212+
): Promise<AIUsageStats[]> => {
213+
const searchParams = buildSearchParams(params);
214+
const res = await fetchAnalytics({
215+
authToken,
216+
url: `v2/nebula/usage?${searchParams.toString()}`,
217+
init: {
218+
method: "GET",
219+
},
220+
});
221+
222+
if (res?.status !== 200) {
223+
const reason = await res?.text();
224+
console.error(
225+
`Failed to fetch AI usage, ${res?.status} - ${res.statusText} - ${reason}`,
226+
);
227+
return [];
228+
}
229+
230+
const json = await res.json();
231+
return json.data as AIUsageStats[];
232+
},
233+
["getAiUsage"],
234+
{
235+
revalidate: 60 * 60, // 1 hour
236+
},
237+
);
238+
239+
export function getAiUsage(
240+
params: AnalyticsQueryParams,
241+
authToken: string,
242+
) {
243+
return cached_getAiUsage(normalizedParams(params), authToken);
244+
}
245+
207246
const cached_getUserOpUsage = unstable_cache(
208247
async (
209248
params: AnalyticsQueryParams,

apps/dashboard/src/@/types/analytics.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ export interface WebhookSummaryStats {
7878
errorBreakdown: Record<string, number>;
7979
}
8080

81+
export interface AIUsageStats {
82+
date: string;
83+
totalPromptTokens: number;
84+
totalCompletionTokens: number;
85+
totalSessions: number;
86+
totalRequests: number;
87+
}
88+
8189
export interface AnalyticsQueryParams {
8290
teamId: string;
8391
projectId?: string;
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"use client";
2+
import { format } from "date-fns";
3+
import { useMemo } from "react";
4+
import { ThirdwebBarChart } from "@/components/blocks/charts/bar-chart";
5+
import { DocLink } from "@/components/blocks/DocLink";
6+
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
7+
import type { ChartConfig } from "@/components/ui/chart";
8+
import { ReactIcon } from "@/icons/brand-icons/ReactIcon";
9+
import { TypeScriptIcon } from "@/icons/brand-icons/TypeScriptIcon";
10+
import type { AIUsageStats } from "@/types/analytics";
11+
12+
type ChartData = {
13+
time: string; // human readable date
14+
tokens: number;
15+
};
16+
17+
export function AiTokenUsageChartCardUI(props: {
18+
aiUsageStats: AIUsageStats[];
19+
isPending: boolean;
20+
title: string;
21+
description: string;
22+
}) {
23+
const { aiUsageStats } = props;
24+
25+
const { chartConfig, chartData } = useMemo(() => {
26+
const _chartConfig: ChartConfig = {
27+
tokens: {
28+
color: "hsl(var(--chart-1))",
29+
label: "Tokens",
30+
},
31+
};
32+
33+
const _chartData: ChartData[] = aiUsageStats.map((stat) => ({
34+
time: stat.date,
35+
tokens: stat.totalPromptTokens + stat.totalCompletionTokens,
36+
}));
37+
38+
return {
39+
chartConfig: _chartConfig,
40+
chartData: _chartData.sort(
41+
(a, b) => new Date(a.time).getTime() - new Date(b.time).getTime(),
42+
),
43+
};
44+
}, [aiUsageStats]);
45+
46+
const disableActions =
47+
props.isPending ||
48+
chartData.length === 0 ||
49+
chartData.every((data) => data.tokens === 0);
50+
51+
return (
52+
<ThirdwebBarChart
53+
chartClassName="aspect-[1.5] lg:aspect-[3.5]"
54+
config={chartConfig}
55+
customHeader={
56+
<div className="relative px-6 pt-6">
57+
<h3 className="mb-0.5 font-semibold text-xl tracking-tight">
58+
{props.title}
59+
</h3>
60+
<p className="mb-3 text-muted-foreground text-sm">
61+
{props.description}
62+
</p>
63+
64+
<ExportToCSVButton
65+
className="top-6 right-6 mb-4 w-full bg-background md:absolute md:mb-0 md:flex md:w-auto"
66+
disabled={disableActions}
67+
fileName="AI Token Usage"
68+
getData={async () => {
69+
const header = ["Date", "Tokens"];
70+
const rows = chartData.map((data) => [
71+
data.time,
72+
data.tokens.toString(),
73+
]);
74+
return { header, rows };
75+
}}
76+
/>
77+
</div>
78+
}
79+
data={chartData}
80+
emptyChartState={<AiTokenUsageEmptyChartState />}
81+
hideLabel={false}
82+
isPending={props.isPending}
83+
showLegend={false}
84+
toolTipLabelFormatter={(_v, item) => {
85+
if (Array.isArray(item)) {
86+
const time = item[0].payload.time as string;
87+
return format(new Date(time), "MMM d, yyyy");
88+
}
89+
return undefined;
90+
}}
91+
variant="default"
92+
/>
93+
);
94+
}
95+
96+
function AiTokenUsageEmptyChartState() {
97+
return (
98+
<div className="flex flex-col items-center justify-center px-4">
99+
<span className="mb-6 text-center text-lg">
100+
Start using AI to interact with any EVM chain
101+
</span>
102+
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
103+
<DocLink
104+
icon={TypeScriptIcon}
105+
label="TypeScript"
106+
link="https://portal.thirdweb.com/ai/chat"
107+
/>
108+
<DocLink
109+
icon={ReactIcon}
110+
label="React"
111+
link="https://portal.thirdweb.com/ai/chat"
112+
/>
113+
</div>
114+
</div>
115+
);
116+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { ActivityIcon, MessageSquareIcon, CoinsIcon } from "lucide-react";
2+
import { Suspense } from "react";
3+
import { getAiUsage } from "@/api/analytics";
4+
import type { Range } from "@/components/analytics/date-range-selector";
5+
import { StatCard } from "@/components/analytics/stat";
6+
import type { AIUsageStats } from "@/types/analytics";
7+
8+
function AiSummaryInner(props: {
9+
stats: AIUsageStats[] | undefined;
10+
isPending: boolean;
11+
}) {
12+
const totalRequests = props.stats?.reduce((acc, curr) => {
13+
return acc + curr.totalRequests;
14+
}, 0);
15+
16+
const totalSessions = props.stats?.reduce((acc, curr) => {
17+
return acc + curr.totalSessions;
18+
}, 0);
19+
20+
const totalTokens = props.stats?.reduce((acc, curr) => {
21+
return acc + curr.totalPromptTokens + curr.totalCompletionTokens;
22+
}, 0);
23+
24+
return (
25+
<div className="grid grid-cols-3 gap-4">
26+
<StatCard
27+
icon={ActivityIcon}
28+
isPending={props.isPending}
29+
label="Requests"
30+
value={totalRequests || 0}
31+
/>
32+
<StatCard
33+
icon={MessageSquareIcon}
34+
isPending={props.isPending}
35+
label="Sessions"
36+
value={totalSessions || 0}
37+
/>
38+
<StatCard
39+
icon={CoinsIcon}
40+
isPending={props.isPending}
41+
label="Tokens"
42+
value={totalTokens || 0}
43+
/>
44+
</div>
45+
);
46+
}
47+
48+
async function AsyncAiSummary(props: {
49+
teamId: string;
50+
projectId: string;
51+
authToken: string;
52+
range: Range;
53+
}) {
54+
const { teamId, projectId, authToken, range } = props;
55+
const aggregatedStatsPromise = getAiUsage(
56+
{
57+
from: range.from,
58+
period: "all",
59+
projectId,
60+
teamId,
61+
to: range.to,
62+
},
63+
authToken,
64+
);
65+
66+
const aggregatedStats = await aggregatedStatsPromise.catch(() => null);
67+
68+
return (
69+
<AiSummaryInner
70+
stats={aggregatedStats || undefined}
71+
isPending={false}
72+
/>
73+
);
74+
}
75+
76+
export function AiSummary(props: {
77+
teamId: string;
78+
projectId: string;
79+
authToken: string;
80+
range: Range;
81+
}) {
82+
return (
83+
<Suspense
84+
fallback={<AiSummaryInner stats={undefined} isPending={true} />}
85+
>
86+
<AsyncAiSummary
87+
projectId={props.projectId}
88+
teamId={props.teamId}
89+
authToken={props.authToken}
90+
range={props.range}
91+
/>
92+
</Suspense>
93+
);
94+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { ResponsiveSuspense } from "responsive-rsc";
2+
import { getAiUsage } from "@/api/analytics";
3+
import {
4+
getLastNDaysRange,
5+
type Range,
6+
} from "@/components/analytics/date-range-selector";
7+
import type { AIUsageStats } from "@/types/analytics";
8+
import { AiTokenUsageChartCardUI } from "./AiTokenUsageChartCard";
9+
10+
type AiAnalyticsProps = {
11+
interval: "day" | "week";
12+
range: Range;
13+
stats: AIUsageStats[];
14+
isPending: boolean;
15+
};
16+
17+
function AiAnalyticsUI({
18+
stats,
19+
isPending,
20+
}: AiAnalyticsProps) {
21+
return (
22+
<AiTokenUsageChartCardUI
23+
title="Token Usage"
24+
description="The total number of tokens used for AI interactions on your project."
25+
aiUsageStats={stats || []}
26+
isPending={isPending}
27+
/>
28+
);
29+
}
30+
31+
type AsyncAiAnalyticsProps = Omit<
32+
AiAnalyticsProps,
33+
"stats" | "isPending"
34+
> & {
35+
teamId: string;
36+
projectId: string;
37+
authToken: string;
38+
};
39+
40+
async function AsyncAiAnalytics(
41+
props: AsyncAiAnalyticsProps,
42+
) {
43+
const range = props.range ?? getLastNDaysRange("last-30");
44+
45+
const stats = await getAiUsage(
46+
{
47+
from: range.from,
48+
period: props.interval,
49+
projectId: props.projectId,
50+
teamId: props.teamId,
51+
to: range.to,
52+
},
53+
props.authToken,
54+
).catch((error) => {
55+
console.error(error);
56+
return [];
57+
});
58+
59+
return (
60+
<AiAnalyticsUI
61+
{...props}
62+
isPending={false}
63+
range={range}
64+
stats={stats}
65+
/>
66+
);
67+
}
68+
69+
export function AiAnalytics(props: AsyncAiAnalyticsProps) {
70+
return (
71+
<ResponsiveSuspense
72+
searchParamsUsed={["from", "to", "interval"]}
73+
fallback={
74+
<AiAnalyticsUI {...props} isPending={true} stats={[]} />
75+
}
76+
>
77+
<AsyncAiAnalytics {...props} />
78+
</ResponsiveSuspense>
79+
);
80+
}

0 commit comments

Comments
 (0)