feat(ui): add Errors tab to Usage with failure rate, status and identity breakdowns (#45396)

Moves failure analytics off the cost overview into a dedicated admin-only
Errors tab: error rate with 4xx/5xx/429/401-403 callouts, error rate over
time, failed requests per day stacked by HTTP status code with a legend,
failures by status code, and a Failures by identity table switchable between
virtual keys, teams, users and models with error rate and top status per
row, linking to Logs. Reads GET /gateway/errors/activity. The Overview keeps
its plain ok/failed counts.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yassin Kortam 2026-10-09 14:27:57 -07:00 • committed by GitHub
parent 2df5b07308
commit aa64b07281
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1107 additions and 7 deletions

View file

@ -61,6 +61,7 @@ vi.mock("@/components/networking", () => ({
dailyActivityModelTopKeysCall: vi.fn(),
dailyActivityExportCall: vi.fn(),
gatewayDailyActivityCall: vi.fn(),
requestErrorActivityCall: vi.fn(),
tagListCall: vi.fn(),
}));
@ -208,6 +209,7 @@ describe("UsagePage", () => {
const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall);
const mockTagListCall = vi.mocked(networking.tagListCall);
const mockGatewayDailyActivityCall = vi.mocked(networking.gatewayDailyActivityCall);
const mockRequestErrorActivityCall = vi.mocked(networking.requestErrorActivityCall);
const mockUseCustomers = vi.mocked(useCustomers);
const mockUseAgents = vi.mocked(useAgents);
const mockUseAuthorized = vi.mocked(useAuthorized);
@ -370,11 +372,50 @@ describe("UsagePage", () => {
// Counts deliberately unlike anything in mockSpendData: the gateway tile must be
// readable as coming from /gateway/daily/activity and from nothing else.
const mockRequestErrorActivity = {
total_successful_requests: 900,
total_failed_requests: 100,
by_date: [
{
date: "2025-01-01",
successful_requests: 900,
failed_requests: 100,
client_errors: 90,
server_errors: 10,
by_status_code: [
{ status_code: 429, failed_requests: 90 },
{ status_code: 500, failed_requests: 10 },
],
},
],
by_status_code: [
{ status_code: 429, failed_requests: 90 },
{ status_code: 500, failed_requests: 10 },
],
by_key: [
{
id: "hash-1",
label: "prod-key",
api_requests: 400,
failed_requests: 100,
top_status_code: 429,
top_status_code_requests: 90,
},
],
by_team: [],
by_user: [],
by_model: [],
};
const mockGatewayActivity = {
total_successful_requests: 424242,
total_failed_requests: 909,
by_date: [{ date: "2025-01-01", successful_requests: 424242, failed_requests: 909 }],
by_route: [{ category: "llm", route: "/chat/completions", successful_requests: 424242, failed_requests: 909 }],
by_status_code: [
{ status_code: 429, failed_requests: 3 },
{ status_code: 500, failed_requests: 2 },
],
};
const defaultProps = {
@ -440,6 +481,8 @@ describe("UsagePage", () => {
mockGatewayDailyActivityCall.mockClear();
mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
mockGatewayDailyActivityCall.mockResolvedValue(mockGatewayActivity);
mockRequestErrorActivityCall.mockClear();
mockRequestErrorActivityCall.mockResolvedValue(mockRequestErrorActivity);
mockUseInfiniteUsers.mockReturnValue({
data: {
pages: [
@ -594,6 +637,8 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(mockGatewayDailyActivityCall).not.toHaveBeenCalled();
expect(mockRequestErrorActivityCall).not.toHaveBeenCalled();
expect(screen.queryByRole("tab", { name: "Errors" })).not.toBeInTheDocument();
await waitFor(() => {
expect(totalRequestsCell()).toHaveTextContent("1,500");
});
@ -601,6 +646,24 @@ describe("UsagePage", () => {
expect(screen.queryByTestId("gateway-requests-by-endpoint")).not.toBeInTheDocument();
});
it("keeps failure analytics off the cost overview and on the Errors tab", async () => {
const user = userEvent.setup();
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockRequestErrorActivityCall).toHaveBeenCalled();
});
expect(overview().queryByText(/Client errors \(4xx\)/)).not.toBeInTheDocument();
expect(overview().queryByRole("button", { name: /Failed Requests/ })).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Errors" }));
const errorsTab = within(await screen.findByTestId("usage-errors-tab"));
expect(await errorsTab.findByText("10.0%")).toBeInTheDocument();
expect(errorsTab.getByRole("list", { name: "Failed requests by status code" })).toHaveTextContent("429");
const keys = errorsTab.getByRole("table", { name: "Virtual keys ranked by failed requests" });
expect(within(keys).getAllByRole("row")[1]).toHaveTextContent("prod-key");
});
it("should display usage metrics and charts", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);

View file

@ -30,7 +30,7 @@ import EntityUsageExportModal from "@/components/EntityUsageExport";
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
import { filterModelActivity } from "@/components/UsagePage/modelActivityFilter";
import { Team } from "@/components/key_team_helpers/key_list";
import { gatewayDailyActivityCall, Organization, tagListCall } from "@/components/networking";
import { gatewayDailyActivityCall, Organization, requestErrorActivityCall, tagListCall } from "@/components/networking";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { Tag } from "@/components/tag_management/types";
import UserAgentActivity from "@/components/user_agent_activity";
@ -51,6 +51,8 @@ import {
type FetchedGatewayActivity,
type GatewayActivity,
} from "./gatewayActivity";
import ErrorsTab from "./errors/ErrorsTab";
import type { RequestErrorActivity } from "./errors/errorsData";
import EndpointUsage from "./EndpointUsage/EndpointUsage";
import EntityUsage, { EntityList } from "./EntityUsage/EntityUsage";
import ModelViewToggle, { ModelViewType } from "./ModelViewToggle";
@ -230,6 +232,27 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const gatewayActivity = selectGatewayActivity(isAdmin, gatewayActivityData, currentGatewayRangeKey);
const [requestErrorData, setRequestErrorData] = useState<FetchedForRange<RequestErrorActivity | null> | null>(null);
const requestErrorFetchIdRef = useRef(0);
useEffect(() => {
if (!isAdmin || !gatewayRequest) return;
const fetchId = ++requestErrorFetchIdRef.current;
requestErrorActivityCall(gatewayRequest.accessToken, gatewayRequest.startTime, gatewayRequest.endTime)
.then((data) => {
if (requestErrorFetchIdRef.current !== fetchId) return;
setRequestErrorData({ rangeKey: currentGatewayRangeKey, value: data as RequestErrorActivity });
})
.catch(() => {
if (requestErrorFetchIdRef.current !== fetchId) return;
setRequestErrorData({ rangeKey: currentGatewayRangeKey, value: null });
});
}, [isAdmin, gatewayRequest, currentGatewayRangeKey]);
const requestErrorsForRange = isAdmin ? requestErrorData : null;
const requestErrorsFailed =
requestErrorsForRange?.rangeKey === currentGatewayRangeKey && requestErrorsForRange.value === null;
const requestErrorActivity =
requestErrorsForRange?.rangeKey === currentGatewayRangeKey ? requestErrorsForRange.value : null;
const userSpendData = useMemo(
() => ({
results: toDailyData(aggregatedRaw),
@ -364,6 +387,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<TabsTrigger value="endpoints" className="flex-none px-3">
Endpoint Activity
</TabsTrigger>
{isAdmin && (
<TabsTrigger value="errors" className="flex-none px-3">
Errors
</TabsTrigger>
)}
</TabsList>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setIsAiChatOpen(true)}>
@ -487,6 +515,16 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<TabsContent value="endpoints" keepMounted>
<EndpointUsage userSpendData={userSpendData} />
</TabsContent>
{isAdmin && (
<TabsContent value="errors">
<ErrorsTab
activity={requestErrorActivity}
loading={requestErrorActivity === null && !requestErrorsFailed}
failed={requestErrorsFailed}
userScoped={effectiveUserId !== null}
/>
</TabsContent>
)}
</Tabs>
</>
)}

View file

@ -0,0 +1,222 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import { uiHref } from "@/utils/uiHref";
import ErrorsTab from "./ErrorsTab";
import type { RequestErrorActivity } from "./errorsData";
vi.mock("@/components/shared/charts", () => ({
STACKED_USAGE_PALETTE: ["#111", "#222", "#333"],
LineChart: ({
data,
categories,
showDots,
}: {
data: readonly unknown[];
categories: readonly string[];
showDots?: boolean;
}) => (
<div data-testid="rate-chart" data-dots={showDots ? "on" : "off"}>
{data.length} points · {categories.join(",")}
</div>
),
StackedUsageChart: ({ data, series }: { data: readonly unknown[]; series: readonly string[] }) => (
<div data-testid="status-code-chart">
{data.length} days · {series.join(",")}
</div>
),
}));
const activity: RequestErrorActivity = {
total_successful_requests: 950,
total_failed_requests: 50,
by_date: [
{
date: "2026-10-07",
successful_requests: 500,
failed_requests: 20,
client_errors: 20,
server_errors: 0,
by_status_code: [{ status_code: 429, failed_requests: 20 }],
},
{
date: "2026-10-08",
successful_requests: 450,
failed_requests: 30,
client_errors: 25,
server_errors: 5,
by_status_code: [
{ status_code: 429, failed_requests: 20 },
{ status_code: 401, failed_requests: 5 },
{ status_code: 503, failed_requests: 5 },
],
},
],
by_status_code: [
{ status_code: 429, failed_requests: 40 },
{ status_code: 401, failed_requests: 5 },
{ status_code: 503, failed_requests: 5 },
],
by_key: [
{
id: "88aa",
label: "batch-runner",
api_requests: 200,
failed_requests: 40,
top_status_code: 429,
top_status_code_requests: 38,
},
],
by_team: [],
by_user: [
{
id: "u-1",
label: "dev@example.com",
api_requests: 50,
failed_requests: 5,
top_status_code: 401,
top_status_code_requests: 5,
},
],
by_model: [],
};
describe("ErrorsTab", () => {
it("shows the error rate, the status callouts, both charts and the status list", () => {
renderWithProviders(<ErrorsTab activity={activity} loading={false} failed={false} />);
expect(screen.getByText("5.0%")).toBeInTheDocument();
expect(screen.getByText("50 of 1,000 requests")).toBeInTheDocument();
expect(screen.getByRole("group", { name: "Rate limited (429)" })).toHaveTextContent("40");
expect(screen.getByRole("group", { name: "Auth failures (401/403)" })).toHaveTextContent("5");
expect(screen.getByTestId("rate-chart")).toHaveTextContent("2 points · rate");
expect(screen.getByTestId("rate-chart")).toHaveAttribute("data-dots", "off");
expect(screen.getByTestId("status-code-chart")).toHaveTextContent("2 days · 429,401,503");
expect(
within(screen.getByRole("list", { name: "Status codes in the chart" })).getAllByRole("listitem"),
).toHaveLength(3);
const statusList = screen.getByRole("list", { name: "Failed requests by status code" });
const items = within(statusList).getAllByRole("listitem");
expect(items[0]).toHaveTextContent("429");
expect(items[0]).toHaveTextContent("Rate Limited");
expect(items[0]).toHaveTextContent("40 · 80.0%");
expect(items).toHaveLength(3);
});
it("switches the error rate chart to one line per status code", async () => {
const user = userEvent.setup();
renderWithProviders(<ErrorsTab activity={activity} loading={false} failed={false} />);
await user.click(
within(screen.getByRole("radiogroup", { name: "Show" })).getByRole("radio", { name: "By status code" }),
);
expect(screen.getByTestId("rate-chart")).toHaveTextContent("2 points · 429,401,503");
});
it("draws markers when the range holds a single day, in both views", async () => {
const user = userEvent.setup();
const oneDay: RequestErrorActivity = { ...activity, by_date: activity.by_date.slice(0, 1) };
renderWithProviders(<ErrorsTab activity={oneDay} loading={false} failed={false} />);
expect(screen.getByTestId("rate-chart")).toHaveTextContent("1 points · rate");
expect(screen.getByTestId("rate-chart")).toHaveAttribute("data-dots", "on");
await user.click(
within(screen.getByRole("radiogroup", { name: "Show" })).getByRole("radio", { name: "By status code" }),
);
expect(screen.getByTestId("rate-chart")).toHaveTextContent("1 points · 429,401,503");
expect(screen.getByTestId("rate-chart")).toHaveAttribute("data-dots", "on");
});
it("tells a user-scoped view that errors are deployment-wide instead of showing other users' failures", () => {
renderWithProviders(<ErrorsTab activity={activity} loading={false} failed={false} userScoped />);
expect(screen.getByRole("status")).toHaveTextContent("Errors are deployment-wide");
expect(screen.queryByTestId("rate-chart")).not.toBeInTheDocument();
expect(screen.queryByRole("radiogroup", { name: "Show" })).not.toBeInTheDocument();
});
it("says so instead of drawing an empty status view when nothing failed", async () => {
const user = userEvent.setup();
const healthy: RequestErrorActivity = {
...activity,
total_failed_requests: 0,
by_date: activity.by_date.map((day) => ({
...day,
failed_requests: 0,
client_errors: 0,
server_errors: 0,
by_status_code: [],
})),
by_status_code: [],
};
renderWithProviders(<ErrorsTab activity={healthy} loading={false} failed={false} />);
expect(screen.getByTestId("rate-chart")).toHaveTextContent("2 points · rate");
await user.click(
within(screen.getByRole("radiogroup", { name: "Show" })).getByRole("radio", { name: "By status code" }),
);
expect(screen.queryByTestId("rate-chart")).not.toBeInTheDocument();
expect(screen.getAllByText("No failed requests in this range").length).toBeGreaterThan(0);
});
it("ranks callers and switches the grouping", async () => {
const user = userEvent.setup();
renderWithProviders(<ErrorsTab activity={activity} loading={false} failed={false} />);
const keys = screen.getByRole("table", { name: "Virtual keys ranked by failed requests" });
const row = within(keys).getAllByRole("row")[1];
expect(row).toHaveTextContent("batch-runner");
expect(row).toHaveTextContent("88aa");
expect(row).toHaveTextContent("20.0%");
expect(row).toHaveTextContent("429 · 38");
await user.click(screen.getByRole("radio", { name: "Teams" }));
expect(screen.getByText("No teams with failed requests in this range")).toBeInTheDocument();
await user.click(screen.getByRole("radio", { name: "Users" }));
const users = screen.getByRole("table", { name: "Users ranked by failed requests" });
expect(within(users).getAllByRole("row")[1]).toHaveTextContent("dev@example.com");
expect(screen.getByRole("button", { name: /Inspect failed requests in Logs/ })).toHaveAttribute(
"href",
uiHref("logs"),
);
});
it("does not present a loading range as empty", () => {
renderWithProviders(<ErrorsTab activity={null} loading failed={false} />);
expect(screen.queryByText(/No requests in this range/)).not.toBeInTheDocument();
expect(screen.queryByText(/No failed requests in this range/)).not.toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
it("reports a failed fetch instead of zeros", () => {
renderWithProviders(<ErrorsTab activity={null} loading={false} failed />);
expect(screen.getByRole("alert")).toHaveTextContent("Error activity could not be loaded");
expect(screen.queryByText("Failed requests")).not.toBeInTheDocument();
});
it("says when the range had no traffic", () => {
const quiet: RequestErrorActivity = {
...activity,
total_successful_requests: 0,
total_failed_requests: 0,
by_date: [],
by_status_code: [],
by_key: [],
by_user: [],
};
renderWithProviders(<ErrorsTab activity={quiet} loading={false} failed={false} />);
expect(screen.getByText("—")).toBeInTheDocument();
expect(screen.getByText("No requests in this range")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,307 @@
"use client";
import React, { useMemo, useState } from "react";
import Link from "next/link";
import { AlertCircle, ArrowUpRight } from "lucide-react";
import { LineChart, STACKED_USAGE_PALETTE, StackedUsageChart } from "@/components/shared/charts";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/cva.config";
import { uiHref } from "@/utils/uiHref";
import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented, Stat } from "../overview/Primitives";
import {
ERROR_ENTITY_KINDS,
ERROR_ENTITY_LABEL,
entityRows,
errorSeries,
type ErrorRateView,
type ErrorSeries,
ERROR_RATE_VIEW_OPTIONS,
rateByStatusRows,
errorSummary,
formatRate,
statusCodeRows,
type ErrorEntityKind,
type RequestErrorActivity,
} from "./errorsData";
interface ErrorsTabProps {
activity: RequestErrorActivity | null;
loading: boolean;
failed: boolean;
userScoped?: boolean;
}
const ENTITY_OPTIONS = ERROR_ENTITY_KINDS.map((kind) => ({ value: kind, label: ERROR_ENTITY_LABEL[kind].plural }));
const shortDate = (iso: string): string =>
new Date(`${iso}T00:00:00`).toLocaleDateString(undefined, { month: "short", day: "numeric" });
const count = (value: number): string => value.toLocaleString();
function RateBar({ rate, className }: { rate: number | null; className?: string }) {
const width = rate === null ? 0 : Math.min(100, Math.max(rate, rate > 0 ? 2 : 0));
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted" aria-hidden="true">
<div className="h-full rounded-full bg-destructive" style={{ width: `${width}%` }} />
</div>
<span className="tabular-nums">{formatRate(rate)}</span>
</div>
);
}
function StatCell({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div role="group" aria-label={label} className="rounded-xl border bg-card p-4">
{children}
</div>
);
}
function PanelBody({
loading,
empty,
height,
children,
}: {
loading: boolean;
empty: string | null;
height: string;
children: React.ReactNode;
}) {
if (loading) return <ChartSkeleton className={height} />;
if (empty !== null) return <EmptyNote>{empty}</EmptyNote>;
return <>{children}</>;
}
function rateChartEmpty(rateView: ErrorRateView, series: ErrorSeries, noTraffic: boolean): string | null {
if (noTraffic || series.rows.length === 0) return "No requests in this range";
if (rateView === "status" && series.codes.length === 0) return "No failed requests in this range";
return null;
}
export default function ErrorsTab({ activity, loading, failed, userScoped = false }: ErrorsTabProps) {
const [kind, setKind] = useState<ErrorEntityKind>("key");
const [rateView, setRateView] = useState<ErrorRateView>("total");
const summary = useMemo(() => (activity ? errorSummary(activity) : null), [activity]);
const series = useMemo(() => (activity ? errorSeries(activity) : { rows: [], codes: [] }), [activity]);
const statusRows = useMemo(() => (activity ? statusCodeRows(activity) : []), [activity]);
const rows = useMemo(() => (activity ? entityRows(activity, kind) : []), [activity, kind]);
const failedByDate = useMemo(() => new Map(series.rows.map((row) => [row.date, row.failed])), [series]);
const rateRows = useMemo(
() =>
(rateView === "total" ? series.rows : rateByStatusRows(series)).map((row) => ({
...row,
date: shortDate(row.date),
})),
[series, rateView],
);
const noTraffic = summary !== null && summary.requests === 0;
const rateEmpty = rateChartEmpty(rateView, series, noTraffic);
if (userScoped) {
return (
<Alert role="status">
<AlertCircle />
<AlertTitle>Errors are deployment-wide</AlertTitle>
<AlertDescription>Clear the user filter in Global Usage to see failure analytics</AlertDescription>
</Alert>
);
}
if (failed) {
return (
<Alert variant="destructive" role="alert">
<AlertCircle />
<AlertTitle>Error activity could not be loaded</AlertTitle>
<AlertDescription>
The proxy did not answer the failed-request query. Reload the page to try again.
</AlertDescription>
</Alert>
);
}
return (
<div className="grid gap-3" data-testid="usage-errors-tab">
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
<StatCell label="Error rate">
<Stat
label="Error rate"
value={summary ? formatRate(summary.rate) : null}
pending={loading}
tone={summary && summary.failed > 0 ? "destructive" : undefined}
hint={summary ? `${count(summary.failed)} of ${count(summary.requests)} requests` : undefined}
/>
</StatCell>
<StatCell label="Failed requests">
<Stat label="Failed requests" value={summary ? count(summary.failed) : null} pending={loading} />
</StatCell>
<StatCell label="Client errors (4xx)">
<Stat label="Client errors (4xx)" value={summary ? count(summary.clientErrors) : null} pending={loading} />
</StatCell>
<StatCell label="Server errors (5xx)">
<Stat label="Server errors (5xx)" value={summary ? count(summary.serverErrors) : null} pending={loading} />
</StatCell>
<StatCell label="Rate limited (429)">
<Stat label="Rate limited (429)" value={summary ? count(summary.rateLimited) : null} pending={loading} />
</StatCell>
<StatCell label="Auth failures (401/403)">
<Stat
label="Auth failures (401/403)"
value={summary ? count(summary.authFailures) : null}
pending={loading}
/>
</StatCell>
</div>
<div className="grid gap-3 lg:grid-cols-2">
<Panel
title="Error rate over time"
subtitle="Failed requests as a share of all requests, per day"
action={<Segmented label="Show" value={rateView} options={ERROR_RATE_VIEW_OPTIONS} onChange={setRateView} />}
>
<PanelBody loading={loading} height="h-64" empty={rateEmpty}>
<LineChart
data={rateRows}
index="date"
categories={rateView === "total" ? ["rate"] : series.codes}
colors={
rateView === "total"
? ["red"]
: series.codes.map((_, index) => STACKED_USAGE_PALETTE[index % STACKED_USAGE_PALETTE.length])
}
valueFormatter={(value) => formatRate(value)}
showLegend={rateView === "status"}
showDots={rateRows.length === 1}
className="h-64"
/>
</PanelBody>
</Panel>
<Panel title="Failed requests by status code" subtitle="Per day, stacked by HTTP status">
<PanelBody
loading={loading}
height="h-64"
empty={series.rows.every((row) => row.failed === 0) ? "No failed requests in this range" : null}
>
<StackedUsageChart
data={series.rows}
series={series.codes}
xKey="date"
xLabel={shortDate}
format={count}
totalFor={(date) => failedByDate.get(date)}
totalLabel="failed"
className="h-64"
/>
<ul
className={cn("flex flex-wrap gap-x-4 gap-y-1 pb-4 text-xs text-muted-foreground", PANEL_INSET_X)}
aria-label="Status codes in the chart"
>
{series.codes.map((code, index) => (
<li key={code} className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="inline-block size-2.5 rounded-sm"
style={{ backgroundColor: STACKED_USAGE_PALETTE[index % STACKED_USAGE_PALETTE.length] }}
/>
{code}
</li>
))}
</ul>
</PanelBody>
</Panel>
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<Panel title="By status code" subtitle="Share of all failed requests">
<PanelBody
loading={loading}
height="h-48"
empty={statusRows.length === 0 ? "No failed requests in this range" : null}
>
<ul className={cn("grid gap-2 pb-4", PANEL_INSET_X)} aria-label="Failed requests by status code">
{statusRows.map((row) => (
<li
key={row.status_code}
className="grid grid-cols-[4rem_minmax(0,1fr)_auto] items-center gap-3 text-sm"
>
<span className="font-medium tabular-nums">{row.status_code === 0 ? "—" : row.status_code}</span>
<span className="min-w-0">
<span className="block truncate text-foreground">{row.label}</span>
<span className="mt-1 block h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden="true">
<span className="block h-full rounded-full bg-destructive" style={{ width: `${row.share}%` }} />
</span>
</span>
<span className="tabular-nums text-muted-foreground">
{count(row.failed_requests)} · {row.share.toFixed(1)}%
</span>
</li>
))}
</ul>
</PanelBody>
</Panel>
<Panel
title="Failures by identity"
subtitle="Keys, teams, users and models ranked by failed requests"
action={<Segmented label="Group by" value={kind} options={ENTITY_OPTIONS} onChange={setKind} />}
>
<PanelBody
loading={loading}
height="h-48"
empty={
rows.length === 0
? `No ${ERROR_ENTITY_LABEL[kind].plural.toLowerCase()} with failed requests in this range`
: null
}
>
<Table aria-label={`${ERROR_ENTITY_LABEL[kind].plural} ranked by failed requests`}>
<TableHeader>
<TableRow>
<TableHead>{ERROR_ENTITY_LABEL[kind].singular}</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Failed</TableHead>
<TableHead>Error rate</TableHead>
<TableHead>Top status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="max-w-56">
<span className="block truncate font-medium" title={row.name}>
{row.name}
</span>
{row.name !== row.id && (
<span className="block truncate font-mono text-xs text-muted-foreground" title={row.id}>
{row.id}
</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">{count(row.requests)}</TableCell>
<TableCell className="text-right tabular-nums text-destructive">{count(row.failed)}</TableCell>
<TableCell>
<RateBar rate={row.rate} />
</TableCell>
<TableCell className="tabular-nums text-muted-foreground">{row.topStatus ?? "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</PanelBody>
<div className={cn("flex justify-end pb-4 pt-2", PANEL_INSET_X)}>
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={uiHref("logs")} />}>
Inspect failed requests in Logs
<ArrowUpRight />
</Button>
</div>
</Panel>
</div>
</div>
);
}
function EmptyNote({ children }: { children: React.ReactNode }) {
return <p className={cn("pb-4 text-sm text-muted-foreground", PANEL_INSET_X)}>{children}</p>;
}

View file

@ -0,0 +1,233 @@
import { describe, expect, it } from "vitest";
import {
entityRows,
errorRate,
errorSeries,
rateByStatusRows,
errorSummary,
formatRate,
statusCodeLabel,
statusCodeRows,
type RequestErrorActivity,
} from "./errorsData";
const activity = (overrides: Partial<RequestErrorActivity> = {}): RequestErrorActivity => ({
total_successful_requests: 900,
total_failed_requests: 100,
by_date: [
{
date: "2026-10-08",
successful_requests: 400,
failed_requests: 60,
client_errors: 50,
server_errors: 5,
by_status_code: [
{ status_code: 429, failed_requests: 40 },
{ status_code: 401, failed_requests: 10 },
{ status_code: 503, failed_requests: 5 },
],
},
{
date: "2026-10-07",
successful_requests: 500,
failed_requests: 40,
client_errors: 40,
server_errors: 0,
by_status_code: [
{ status_code: 429, failed_requests: 30 },
{ status_code: 401, failed_requests: 10 },
],
},
],
by_status_code: [
{ status_code: 429, failed_requests: 70 },
{ status_code: 503, failed_requests: 5 },
{ status_code: 401, failed_requests: 20 },
],
by_key: [
{
id: "hash-1",
label: "prod-key",
api_requests: 500,
failed_requests: 60,
top_status_code: 429,
top_status_code_requests: 55,
},
{
id: "hash-2",
label: null,
api_requests: 0,
failed_requests: 0,
top_status_code: null,
top_status_code_requests: 0,
},
],
by_team: [],
by_user: [],
by_model: [
{
id: "gpt-4o",
label: null,
api_requests: 1000,
failed_requests: 1,
top_status_code: 0,
top_status_code_requests: 1,
},
],
...overrides,
});
describe("errorSummary", () => {
it("derives the rate and the status-class callouts from the edge totals", () => {
const expected = {
requests: 1000,
failed: 100,
rate: 10,
clientErrors: 90,
serverErrors: 5,
rateLimited: 70,
authFailures: 20,
};
expect(errorSummary(activity())).toEqual(expected);
});
it("has no rate when nothing was served", () => {
const empty = activity({ total_successful_requests: 0, total_failed_requests: 0, by_status_code: [] });
expect(errorSummary(empty).rate).toBeNull();
});
});
describe("errorSeries", () => {
it("orders days and stacks the top status codes, with the unrecorded remainder as Other", () => {
const series = errorSeries(activity());
expect(series.codes).toEqual(["429", "401", "503", "Other"]);
expect(series.rows).toEqual([
{
date: "2026-10-07",
requests: 540,
failed: 40,
rate: expect.closeTo(7.41, 2),
"429": 30,
"401": 10,
"503": 0,
Other: 0,
},
{
date: "2026-10-08",
requests: 460,
failed: 60,
rate: expect.closeTo(13.04, 2),
"429": 40,
"401": 10,
"503": 5,
Other: 5,
},
]);
});
it("has no Other column when every failure has a recorded status", () => {
const complete = activity({
by_date: [
{
date: "2026-10-08",
successful_requests: 10,
failed_requests: 2,
client_errors: 2,
server_errors: 0,
by_status_code: [{ status_code: 429, failed_requests: 2 }],
},
],
by_status_code: [{ status_code: 429, failed_requests: 2 }],
});
expect(errorSeries(complete).codes).toEqual(["429"]);
});
});
describe("rateByStatusRows", () => {
it("turns each status code count into a share of that day's requests", () => {
const rows = rateByStatusRows(errorSeries(activity()));
expect(rows).toEqual([
{
date: "2026-10-07",
requests: 540,
failed: 40,
rate: expect.closeTo(7.41, 2),
"429": expect.closeTo(5.56, 2),
"401": expect.closeTo(1.85, 2),
"503": 0,
Other: 0,
},
{
date: "2026-10-08",
requests: 460,
failed: 60,
rate: expect.closeTo(13.04, 2),
"429": expect.closeTo(8.7, 2),
"401": expect.closeTo(2.17, 2),
"503": expect.closeTo(1.09, 2),
Other: expect.closeTo(1.09, 2),
},
]);
});
});
describe("statusCodeRows", () => {
it("ranks codes by count, labels them and adds the unrecorded remainder", () => {
expect(statusCodeRows(activity())).toEqual([
{ status_code: 429, label: "Rate Limited", failed_requests: 70, share: 70 },
{ status_code: 401, label: "Unauthorized", failed_requests: 20, share: 20 },
{ status_code: 0, label: "Not recorded", failed_requests: 5, share: 5 },
{ status_code: 503, label: "Service Unavailable", failed_requests: 5, share: 5 },
]);
});
it("is empty when nothing failed", () => {
expect(statusCodeRows(activity({ total_failed_requests: 0, by_status_code: [] }))).toEqual([]);
});
});
describe("entityRows", () => {
it("names rows by alias, computes the rate and describes the top status", () => {
expect(entityRows(activity(), "key")).toEqual([
{ id: "hash-1", name: "prod-key", requests: 500, failed: 60, rate: 12, topStatus: "429 · 55" },
{ id: "hash-2", name: "hash-2", requests: 0, failed: 0, rate: null, topStatus: null },
]);
});
it("switches source by kind and spells out an unrecorded top status", () => {
expect(entityRows(activity(), "team")).toEqual([]);
expect(entityRows(activity(), "model")[0].topStatus).toBe("No status · 1");
});
});
describe("rates", () => {
it.each([
[0, 0, null],
[1, 1000, 0.1],
[50, 200, 25],
])("errorRate(%s, %s)", (failed, total, expected) => {
expect(errorRate(failed, total)).toBe(expected);
});
it.each([
[null, "—"],
[0, "0.0%"],
[0.04, "<0.1%"],
[12.345, "12.3%"],
])("formatRate(%s)", (rate, expected) => {
expect(formatRate(rate)).toBe(expected);
});
});
describe("statusCodeLabel", () => {
it.each([
[0, "Not recorded"],
[401, "Unauthorized"],
[429, "Rate Limited"],
[503, "Service Unavailable"],
[418, "Client Error"],
[599, "Server Error"],
] as const)("labels status %s", (code, label) => {
expect(statusCodeLabel(code)).toBe(label);
});
});

View file

@ -0,0 +1,218 @@
export interface RequestErrorDailyEntry {
date: string;
successful_requests: number;
failed_requests: number;
client_errors: number;
server_errors: number;
by_status_code: { status_code: number; failed_requests: number }[];
}
export interface RequestErrorEntityEntry {
id: string;
label: string | null;
api_requests: number;
failed_requests: number;
top_status_code: number | null;
top_status_code_requests: number;
}
export interface RequestErrorActivity {
total_successful_requests: number;
total_failed_requests: number;
by_date: RequestErrorDailyEntry[];
by_status_code: { status_code: number; failed_requests: number }[];
by_key: RequestErrorEntityEntry[];
by_team: RequestErrorEntityEntry[];
by_user: RequestErrorEntityEntry[];
by_model: RequestErrorEntityEntry[];
}
export const ERROR_ENTITY_KINDS = ["key", "team", "user", "model"] as const;
export type ErrorEntityKind = (typeof ERROR_ENTITY_KINDS)[number];
export const ERROR_ENTITY_LABEL: Record<ErrorEntityKind, { singular: string; plural: string }> = {
key: { singular: "Virtual key", plural: "Virtual keys" },
team: { singular: "Team", plural: "Teams" },
user: { singular: "User", plural: "Users" },
model: { singular: "Model", plural: "Models" },
};
const STATUS_LABELS: Record<number, string> = {
400: "Bad Request",
401: "Unauthorized",
402: "Payment Required",
403: "Forbidden",
404: "Not Found",
408: "Request Timeout",
413: "Payload Too Large",
422: "Unprocessable Entity",
429: "Rate Limited",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
504: "Gateway Timeout",
};
export const statusCodeLabel = (code: number): string => {
if (code === 0) return "Not recorded";
const known = STATUS_LABELS[code];
if (known) return known;
if (code >= 500) return "Server Error";
if (code >= 400) return "Client Error";
return "Error";
};
export const errorRate = (failed: number, total: number): number | null => (total > 0 ? (failed / total) * 100 : null);
export const formatRate = (rate: number | null): string => {
if (rate === null) return "—";
if (rate > 0 && rate < 0.1) return "<0.1%";
return `${rate.toFixed(1)}%`;
};
export interface ErrorSummary {
requests: number;
failed: number;
rate: number | null;
clientErrors: number;
serverErrors: number;
rateLimited: number;
authFailures: number;
}
export const errorSummary = (activity: RequestErrorActivity): ErrorSummary => {
const requests = activity.total_successful_requests + activity.total_failed_requests;
const byCode = (predicate: (code: number) => boolean) =>
activity.by_status_code
.filter((row) => predicate(row.status_code))
.reduce((sum, row) => sum + row.failed_requests, 0);
return {
requests,
failed: activity.total_failed_requests,
rate: errorRate(activity.total_failed_requests, requests),
clientErrors: byCode((code) => code >= 400 && code <= 499),
serverErrors: byCode((code) => code >= 500 && code <= 599),
rateLimited: byCode((code) => code === 429),
authFailures: byCode((code) => code === 401 || code === 403),
};
};
export interface ErrorSeriesRow extends Record<string, unknown> {
date: string;
requests: number;
failed: number;
rate: number;
}
export interface ErrorSeries {
rows: ErrorSeriesRow[];
codes: string[];
}
export const ERROR_SERIES_MAX_CODES = 6;
export const ERROR_SERIES_OTHER = "Other";
const seriesKey = (code: number): string => (code === 0 ? "No status" : String(code));
export const errorSeries = (activity: RequestErrorActivity): ErrorSeries => {
const ranked = [...activity.by_status_code]
.filter((row) => row.failed_requests > 0)
.sort((a, b) => b.failed_requests - a.failed_requests || a.status_code - b.status_code);
const shown = ranked.slice(0, ERROR_SERIES_MAX_CODES).map((row) => row.status_code);
const codes = shown.map(seriesKey);
const needsOther = ranked.length > shown.length || activity.by_date.some(hasUnrecordedFailures);
const rows = [...activity.by_date]
.sort((a, b) => a.date.localeCompare(b.date))
.map((day) => {
const requests = day.successful_requests + day.failed_requests;
const counts: Record<string, number> = Object.fromEntries(
shown.map((code) => [
seriesKey(code),
day.by_status_code.find((entry) => entry.status_code === code)?.failed_requests ?? 0,
]),
);
const recorded = Object.values(counts).reduce((sum, count) => sum + count, 0);
const other: Record<string, number> = needsOther
? { [ERROR_SERIES_OTHER]: Math.max(0, day.failed_requests - recorded) }
: {};
return {
date: day.date,
requests,
failed: day.failed_requests,
rate: errorRate(day.failed_requests, requests) ?? 0,
...counts,
...other,
};
});
return { rows, codes: needsOther ? [...codes, ERROR_SERIES_OTHER] : codes };
};
export type ErrorRateView = "total" | "status";
export const ERROR_RATE_VIEW_OPTIONS: readonly { value: ErrorRateView; label: string }[] = [
{ value: "total", label: "Total" },
{ value: "status", label: "By status code" },
];
export const rateByStatusRows = (series: ErrorSeries): ErrorSeriesRow[] =>
series.rows.map((row) => ({
date: row.date,
requests: row.requests,
failed: row.failed,
rate: row.rate,
...Object.fromEntries(series.codes.map((code) => [code, errorRate(Number(row[code] ?? 0), row.requests) ?? 0])),
}));
const hasUnrecordedFailures = (day: RequestErrorDailyEntry): boolean =>
day.failed_requests > day.by_status_code.reduce((sum, entry) => sum + entry.failed_requests, 0);
export interface StatusCodeRow {
status_code: number;
label: string;
failed_requests: number;
share: number;
}
export const statusCodeRows = (activity: RequestErrorActivity): StatusCodeRow[] => {
const recorded = activity.by_status_code.reduce((sum, row) => sum + row.failed_requests, 0);
const notRecorded = Math.max(0, activity.total_failed_requests - recorded);
const recordedRows = activity.by_status_code
.filter((row) => row.failed_requests > 0)
.map((row) => ({
status_code: row.status_code,
label: statusCodeLabel(row.status_code),
failed_requests: row.failed_requests,
}));
const unrecordedRows =
notRecorded > 0 ? [{ status_code: 0, label: statusCodeLabel(0), failed_requests: notRecorded }] : [];
const total = activity.total_failed_requests || 1;
return [...recordedRows, ...unrecordedRows]
.sort((a, b) => b.failed_requests - a.failed_requests || a.status_code - b.status_code)
.map((row) => ({ ...row, share: (row.failed_requests / total) * 100 }));
};
export interface ErrorEntityRow {
id: string;
name: string;
requests: number;
failed: number;
rate: number | null;
topStatus: string | null;
}
export const entityRows = (activity: RequestErrorActivity, kind: ErrorEntityKind): ErrorEntityRow[] => {
const source = { key: activity.by_key, team: activity.by_team, user: activity.by_user, model: activity.by_model }[
kind
];
return source.map((entry) => ({
id: entry.id,
name: entry.label ?? entry.id,
requests: entry.api_requests,
failed: entry.failed_requests,
rate: errorRate(entry.failed_requests, entry.api_requests),
topStatus:
entry.top_status_code === null
? null
: `${entry.top_status_code === 0 ? "No status" : entry.top_status_code} · ${entry.top_status_code_requests.toLocaleString()}`,
}));
};

View file

@ -15,6 +15,7 @@ export interface GatewayActivity {
total_failed_requests: number;
by_date: { date: string; successful_requests: number; failed_requests: number }[];
by_route: { category: string; route: string; successful_requests: number; failed_requests: number }[];
by_status_code?: { status_code: number; failed_requests: number }[];
}
/** A fetched result carrying the range key it was fetched for. */
@ -32,11 +33,6 @@ export interface GatewayRouteBar extends Record<string, unknown> {
failed_requests: number;
}
/**
* Identifies what a result was fetched for: the date range, plus any other
* input that changes the answer. The usage aggregate is scoped to a user, so
* two results covering the same dates still describe different numbers.
*/
export const fetchedRangeKey = (
startTime: Date | null | undefined,
endTime: Date | null | undefined,

View file

@ -2465,6 +2465,27 @@ export const gatewayDailyActivityCall = async (accessToken: string, startTime: D
}
};
export const requestErrorActivityCall = async (accessToken: string, startTime: Date, endTime: Date) => {
try {
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
return await apiClient.get(`/gateway/errors/activity`, {
accessToken,
query: {
start_date: formatDate(startTime),
end_date: formatDate(endTime),
},
});
} catch (error) {
console.error("Failed to fetch request error activity:", error);
throw error;
}
};
export const getPossibleUserRoles = async (accessToken: string) => {
try {
const data = (await apiClient.get(`/user/available_roles`, { accessToken })) as Record<

View file

@ -23,6 +23,7 @@ export type LineChartProps<TDatum extends Record<string, unknown>> = {
showTooltip?: boolean;
customTooltip?: ChartTooltipComponent;
connectNulls?: boolean;
showDots?: boolean;
curveType?: LineChartCurveType;
className?: string;
style?: React.CSSProperties;
@ -42,6 +43,7 @@ export function LineChart<TDatum extends Record<string, unknown>>({
showTooltip = true,
customTooltip,
connectNulls = false,
showDots = false,
curveType = "linear",
className,
style,
@ -88,7 +90,7 @@ export function LineChart<TDatum extends Record<string, unknown>>({
dataKey={category}
stroke={fills[i]}
strokeWidth={2}
dot={false}
dot={showDots}
isAnimationActive={false}
connectNulls={connectNulls}
/>