mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
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:
parent
2df5b07308
commit
aa64b07281
9 changed files with 1107 additions and 7 deletions
|
|
@ -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} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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()}`,
|
||||
}));
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue