Skip to content

feat: dashboard analytics for webhooks #7430

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
69 changes: 69 additions & 0 deletions apps/dashboard/src/@/api/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import type {
UserOpStats,
WalletStats,
WalletUserStats,
WebhookLatencyStats,
WebhookRequestStats,
WebhookSummaryStats,
} from "@/types/analytics";
import { getAuthToken } from "./auth-token";
import { getChains } from "./chain";
Expand Down Expand Up @@ -424,3 +427,69 @@ export async function getEngineCloudMethodUsage(
const json = await res.json();
return json.data as EngineCloudStats[];
}

export async function getWebhookRequests(
params: AnalyticsQueryParams & { webhookId?: string },
): Promise<WebhookRequestStats[]> {
const searchParams = buildSearchParams(params);
if (params.webhookId) {
searchParams.append("webhookId", params.webhookId);
}
const res = await fetchAnalytics(
`v2/webhook/requests?${searchParams.toString()}`,
);
if (res.status !== 200) {
const reason = await res.text();
console.error(
`Failed to fetch webhook request stats: ${res.status} - ${reason}`,
);
return [];
}

const json = await res.json();
return json.data as WebhookRequestStats[];
}

export async function getWebhookLatency(
params: AnalyticsQueryParams & { webhookId?: string },
): Promise<WebhookLatencyStats[]> {
const searchParams = buildSearchParams(params);
if (params.webhookId) {
searchParams.append("webhookId", params.webhookId);
}
const res = await fetchAnalytics(
`v2/webhook/latency?${searchParams.toString()}`,
);
if (res.status !== 200) {
const reason = await res.text();
console.error(
`Failed to fetch webhook latency stats: ${res.status} - ${reason}`,
);
return [];
}

const json = await res.json();
return json.data as WebhookLatencyStats[];
}

export async function getWebhookSummary(
params: AnalyticsQueryParams & { webhookId?: string },
): Promise<WebhookSummaryStats[]> {
const searchParams = buildSearchParams(params);
if (params.webhookId) {
searchParams.append("webhookId", params.webhookId);
}
const res = await fetchAnalytics(
`v2/webhook/summary?${searchParams.toString()}`,
);
if (res.status !== 200) {
const reason = await res.text();
console.error(
`Failed to fetch webhook summary stats: ${res.status} - ${reason}`,
);
return [];
}

const json = await res.json();
return json.data as WebhookSummaryStats[];
}
49 changes: 49 additions & 0 deletions apps/dashboard/src/@/api/webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use server";
import "server-only";

import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
import { getAuthToken } from "./auth-token";

export type WebhookConfig = {
id: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
deletedAt: Date | null;
teamId: string;
projectId: string;
destinationUrl: string;
pausedAt: Date | null;
webhookSecret: string;
};

export async function getWebhookConfigs(
teamIdOrSlug: string,
projectIdOrSlug: string,
): Promise<{ data: WebhookConfig[] } | { error: string }> {
const token = await getAuthToken();
if (!token) {
return { error: "Unauthorized." };
}

const res = await fetch(
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/projects/${projectIdOrSlug}/webhook-configs`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
try {
const json = (await res.json()) as {
data: WebhookConfig[];
error: { message: string };
};
if (json.error) {
return { error: json.error.message };
}
return { data: json.data };
} catch {
return { error: "Failed to fetch webhooks." };
}
}
25 changes: 25 additions & 0 deletions apps/dashboard/src/@/types/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,28 @@ export interface AnalyticsQueryParams {
to?: Date;
period?: "day" | "week" | "month" | "year" | "all";
}

export interface WebhookRequestStats {
date: string;
webhookId: string;
httpStatusCode: number;
totalRequests: number;
}

export interface WebhookLatencyStats {
date: string;
webhookId: string;
p50LatencyMs: number;
p90LatencyMs: number;
p99LatencyMs: number;
}

export interface WebhookSummaryStats {
webhookId: string;
totalRequests: number;
successRequests: number;
errorRequests: number;
successRate: number;
avgLatencyMs: number;
errorBreakdown: Record<string, number>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { subHours } from "date-fns";
import { AlertTriangleIcon, ClockIcon } from "lucide-react";
import { toast } from "sonner";
import {
getWebhookLatency,
getWebhookRequests,
getWebhookSummary,
} from "@/api/analytics";
import type { Project } from "@/api/projects";
import { getWebhookConfigs, type WebhookConfig } from "@/api/webhooks";
import {
getLastNDaysRange,
type Range,
} from "@/components/analytics/date-range-selector";
import { RangeSelector } from "@/components/analytics/range-selector";
import { StatCard } from "@/components/analytics/stat";
import type {
WebhookLatencyStats,
WebhookRequestStats,
WebhookSummaryStats,
} from "@/types/analytics";
import { LatencyChart } from "./latency-chart";
import { StatusCodesChart } from "./status-codes-chart";
import { WebhookSelector } from "./webhook-selector";

type WebhookAnalyticsProps = {
interval: "day" | "week";
range: Range;
selectedWebhookId: string | null;
webhooksConfigs: WebhookConfig[];
requestStats: WebhookRequestStats[];
latencyStats: WebhookLatencyStats[];
summaryStats: WebhookSummaryStats[];
};

function WebhookAnalytics({
interval,
range,
selectedWebhookId,
webhooksConfigs,
requestStats,
latencyStats,
summaryStats,
}: WebhookAnalyticsProps) {
// Calculate overview metrics for the last 24 hours
const errorRate = 100 - (summaryStats[0]?.successRate || 0);
const avgLatency = summaryStats[0]?.avgLatencyMs || 0;

// Transform request data for combined chart.
const allRequestsData = requestStats
.reduce((acc, stat) => {
const statusCode = stat.httpStatusCode.toString();
const existingEntry = acc.find((entry) => entry.time === stat.date);
if (existingEntry) {
existingEntry[statusCode] =
(existingEntry[statusCode] || 0) + stat.totalRequests;
} else {
acc.push({
time: stat.date,
[statusCode]: stat.totalRequests,
});
}
return acc;
}, [] as any[])
.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());

// Transform latency data for line chart.
const latencyData = latencyStats
.map((stat) => ({ ...stat, time: stat.date }))
.sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime());

return (
<div className="flex flex-col gap-6">
<WebhookSelector
selectedWebhookId={selectedWebhookId}
webhooks={webhooksConfigs}
/>

<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<StatCard
formatter={(value) => `${value.toFixed(2)}%`}
icon={AlertTriangleIcon}
isPending={false}
label="Error Rate (24h)"
value={errorRate}
/>
<StatCard
formatter={(value) => `${value.toFixed(0)}ms`}
icon={ClockIcon}
isPending={false}
label="P50 Latency (24h)"
value={avgLatency}
/>
</div>

<RangeSelector interval={interval} range={range} />

<div className="flex flex-col gap-4 lg:gap-6">
<StatusCodesChart data={allRequestsData} isPending={false} />
<LatencyChart data={latencyData} isPending={false} />
</div>
</div>
);
}

const DEFAULT_RANGE = getLastNDaysRange("last-120");
const DEFAULT_INTERVAL = "week" as const;

export async function AnalyticsPageContent({
teamSlug,
project,
searchParams,
}: {
teamSlug: string;
project: Project;
searchParams?: { [key: string]: string | string[] | undefined };
}) {
// Parse search params for filters
const selectedWebhookId = searchParams?.webhookId as string | undefined;
const interval =
(searchParams?.interval as "day" | "week") || DEFAULT_INTERVAL;
const range = DEFAULT_RANGE; // Could be enhanced to parse from search params

// Get webhook configs.
const webhooksConfigsResponse = await getWebhookConfigs(teamSlug, project.id);
if ("error" in webhooksConfigsResponse) {
toast.error(webhooksConfigsResponse.error);
return null;
}

// Get webhook analytics.
const [requestStats, latencyStats, summaryStats] = await Promise.all([
getWebhookRequests({
teamId: project.teamId,
projectId: project.id,
from: range.from,
period: interval,
to: range.to,
webhookId: selectedWebhookId || undefined,
}).catch(() => []),
getWebhookLatency({
teamId: project.teamId,
projectId: project.id,
from: range.from,
period: interval,
to: range.to,
webhookId: selectedWebhookId || undefined,
}).catch(() => []),
getWebhookSummary({
teamId: project.teamId,
projectId: project.id,
from: subHours(new Date(), 24),
to: new Date(),
webhookId: selectedWebhookId || undefined,
}).catch(() => []),
]);

return (
<WebhookAnalytics
interval={interval}
latencyStats={latencyStats}
range={range}
requestStats={requestStats}
selectedWebhookId={selectedWebhookId || null}
summaryStats={summaryStats}
webhooksConfigs={webhooksConfigsResponse.data}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"use client";

import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
import type { ChartConfig } from "@/components/ui/chart";

interface LatencyChartProps {
data: Array<{
time: string;
p50LatencyMs: number;
p90LatencyMs: number;
p99LatencyMs: number;
}>;
isPending: boolean;
}

const latencyChartConfig = {
p50LatencyMs: {
color: "hsl(142, 76%, 36%)", // Green for best performance
label: "P50",
},
p90LatencyMs: {
color: "hsl(45, 93%, 47%)", // Yellow for warning level
label: "P90",
},
p99LatencyMs: {
color: "hsl(0, 84%, 60%)", // Red for critical level
label: "P99",
},
} satisfies ChartConfig;

export function LatencyChart({ data, isPending }: LatencyChartProps) {
return (
<ThirdwebAreaChart
chartClassName="h-[500px] w-full"
config={latencyChartConfig}
data={data}
header={{
description: "Latency metrics for the selected webhook",
title: "Latency",
}}
hideLabel={false}
isPending={isPending}
showLegend={true}
toolTipValueFormatter={(value) => `${value}ms`}
xAxis={{ sameDay: false }}
/>
);
}
Loading
Loading