feat: add Engine Cloud analytics to project overview (#7195)

# [Dashboard] Feature: Add Engine Cloud Analytics Charts

Example screenshot:

![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/vKjRiOG6EUjD7XH4cppU/73d7e2b9-23bd-46b5-8cae-5564a3630770.png)

## Notes for the reviewer
This PR adds Engine Cloud analytics to the dashboard, displaying request data in a bar chart visualization. The implementation includes:

1. A new API endpoint to fetch Engine Cloud method usage data
2. New UI components for displaying Engine Cloud analytics:
   - `EngineCloudBarChartCardUI` - Shows Engine Cloud requests by pathname
   - `EngineCloudChartCardUI` - Shows Engine Cloud methods usage with percentage breakdown
3. Integration of the new chart into the project analytics page

## How to test
- View the Storybook component at "Analytics/EngineCloudBarChartCard"
- Navigate to a project dashboard to see the Engine Cloud analytics chart
- Verify data is properly fetched and displayed in the chart

<!-- start pr-codex -->

---

## PR-Codex overview
This PR introduces a new feature to track and visualize `EngineCloud` statistics in the dashboard, enhancing analytics capabilities with new components and API integrations.

### Detailed summary
- Added `EngineCloudStats` interface in `analytics.ts`.
- Implemented `getEngineCloudMethodUsage` API function.
- Created `EngineCloudChartCard` component for displaying cloud stats.
- Developed `EngineCloudBarChartCardUI` for rendering bar charts.
- Added storybook entries for `EngineCloudBarChartCardUI`.
- Enhanced existing analytics with new data visualizations.

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Introduced a stacked bar chart visualization for engine cloud request statistics in the analytics dashboard.
  - Added a chart card displaying engine cloud method usage data with loading state support.
  - Enhanced analytics with time series data for multiple API pathnames.
  - Included engine cloud method usage data in the project analytics view.

- **Chores**
  - Updated `.gitignore` to exclude the `.pnpm-store/` directory from version control.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
arcoraven
2025-05-30 01:34:51 +00:00
parent 523412437b
commit f2e8b9ecab
7 changed files with 258 additions and 0 deletions
+1
View File
@@ -16,6 +16,7 @@ yalc.lock
./build/
playwright-report/
.env/
.pnpm-store/
# codecov binary
codecov
+22
View File
@@ -3,6 +3,7 @@ import "server-only";
import type {
AnalyticsQueryParams,
EcosystemWalletStats,
EngineCloudStats,
InAppWalletStats,
RpcMethodStats,
TransactionStats,
@@ -431,3 +432,24 @@ export async function getUniversalBridgeWalletUsage(args: {
const json = await res.json();
return json.data as UniversalBridgeWalletStats[];
}
export async function getEngineCloudMethodUsage(
params: AnalyticsQueryParams,
): Promise<EngineCloudStats[]> {
const searchParams = buildSearchParams(params);
const res = await fetchAnalytics(
`v2/engine-cloud/requests?${searchParams.toString()}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
if (res?.status !== 200) {
console.error("Failed to fetch Engine Cloud method usage");
return [];
}
const json = await res.json();
return json.data as EngineCloudStats[];
}
@@ -0,0 +1,56 @@
import type { Meta, StoryObj } from "@storybook/react";
import { BadgeContainer } from "stories/utils";
import type { EngineCloudStats } from "types/analytics";
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
const meta = {
title: "Analytics/EngineCloudBarChartCard",
component: Component,
} satisfies Meta<typeof Component>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Variants: Story = {
parameters: {
viewport: { defaultViewport: "desktop" },
},
};
const generateTimeSeriesData = (days: number, pathnames: string[]) => {
const data: EngineCloudStats[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split("T")[0];
for (const pathname of pathnames) {
data.push({
// biome-ignore lint/style/noNonNullAssertion: we know this is not null
date: dateStr!,
chainId: "84532",
pathname,
totalRequests: Math.floor(Math.random() * 1000) + 100,
});
}
}
return data;
};
function Component() {
return (
<div className="container max-w-6xl space-y-10 py-10">
<BadgeContainer label="Multiple Pathnames">
<EngineCloudBarChartCardUI
rawData={generateTimeSeriesData(30, [
"/v1/write/transaction",
"/v1/sign/transaction",
"/v1/sign/message",
])}
/>
</BadgeContainer>
</div>
);
}
@@ -0,0 +1,139 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { formatDate } from "date-fns";
import { useMemo } from "react";
import {
Bar,
CartesianGrid,
BarChart as RechartsBarChart,
XAxis,
} from "recharts";
import type { EngineCloudStats } from "types/analytics";
import { EmptyStateCard } from "../../../../../components/Analytics/EmptyStateCard";
export function EngineCloudBarChartCardUI({
rawData,
}: { rawData: EngineCloudStats[] }) {
const { data, pathnames, chartConfig, isAllEmpty } = useMemo(() => {
// Dynamically collect all unique pathnames
const pathnameSet = new Set<string>();
for (const item of rawData) {
// Ignore empty pathname ''.
if (item.pathname) {
pathnameSet.add(item.pathname);
}
}
const pathnames = Array.from(pathnameSet);
// Group by date, then by pathname
const dateMap = new Map<string, Record<string, number>>();
for (const { date, pathname, totalRequests } of rawData) {
const map = dateMap.get(date) ?? {};
map[pathname] = Number(totalRequests) || 0;
dateMap.set(date, map);
}
// Build data array for recharts
const data = Array.from(dateMap.entries()).map(([date, value]) => {
let total = 0;
for (const pathname of pathnames) {
if (!value[pathname]) value[pathname] = 0;
total += value[pathname];
}
return { date, ...value, total };
});
// Chart config
const chartConfig: ChartConfig = {};
for (const pathname of pathnames) {
chartConfig[pathname] = { label: pathname };
}
return {
data,
pathnames,
chartConfig,
isAllEmpty: data.every((d) => d.total === 0),
};
}, [rawData]);
if (data.length === 0 || isAllEmpty) {
return <EmptyStateCard metric="RPC" link="https://portal.thirdweb.com/" />;
}
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0">
<div className="flex flex-1 flex-col justify-center gap-1 p-6">
<CardTitle className="font-semibold text-lg">
Engine Cloud Requests
</CardTitle>
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6 sm:pl-0">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full pt-6"
>
<RechartsBarChart
accessibilityLayer
data={data}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value: string) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
labelFormatter={(d) => formatDate(new Date(d), "MMM d")}
valueFormatter={(_value) => {
const value = typeof _value === "number" ? _value : 0;
return <span className="inline-flex gap-1.5">{value}</span>;
}}
/>
}
/>
{pathnames.map((pathname, idx) => (
<Bar
key={pathname}
stackId="a"
dataKey={pathname}
radius={[
idx === pathnames.length - 1 ? 4 : 0,
idx === pathnames.length - 1 ? 4 : 0,
idx === 0 ? 4 : 0,
idx === 0 ? 4 : 0,
]}
fill={`hsl(var(--chart-${idx + 1}))`}
strokeWidth={1}
className="stroke-background"
/>
))}
</RechartsBarChart>
</ChartContainer>
</CardContent>
</Card>
);
}
@@ -0,0 +1,25 @@
import { getEngineCloudMethodUsage } from "@/api/analytics";
import { LoadingChartState } from "components/analytics/empty-chart-state";
import { Suspense } from "react";
import type { AnalyticsQueryParams } from "types/analytics";
import { EngineCloudBarChartCardUI } from "./EngineCloudBarChartCardUI";
export function EngineCloudChartCard(props: AnalyticsQueryParams) {
return (
<Suspense
fallback={
<div className="h-[400px]">
<LoadingChartState />
</div>
}
>
<EngineCloudChartCardAsync {...props} />
</Suspense>
);
}
async function EngineCloudChartCardAsync(props: AnalyticsQueryParams) {
const rawData = await getEngineCloudMethodUsage(props);
return <EngineCloudBarChartCardUI rawData={rawData} />;
}
@@ -41,6 +41,7 @@ import { getAuthToken } from "../../../../api/lib/getAuthToken";
import { loginRedirect } from "../../../../login/loginRedirect";
import { CombinedBarChartCard } from "../../../components/Analytics/CombinedBarChartCard";
import { PieChartCard } from "../../../components/Analytics/PieChartCard";
import { EngineCloudChartCard } from "./components/EngineCloudChartCard";
import { ProjectFTUX } from "./components/ProjectFTUX/ProjectFTUX";
import { RpcMethodBarChartCard } from "./components/RpcMethodBarChartCard";
import { TransactionsCharts } from "./components/Transactions";
@@ -279,6 +280,13 @@ async function ProjectAnalytics(props: {
teamId={project.teamId}
projectId={project.id}
/>
<EngineCloudChartCard
from={range.from}
to={range.to}
period={interval}
teamId={project.teamId}
projectId={project.id}
/>
</div>
);
}
+7
View File
@@ -45,6 +45,13 @@ export interface RpcMethodStats {
count: number;
}
export interface EngineCloudStats {
date: string;
chainId: string;
pathname: string;
totalRequests: number;
}
export interface UniversalBridgeStats {
date: string;
chainId: number;