diff --git a/src/app/api/usage/analytics/route.ts b/src/app/api/usage/analytics/route.ts index 540f1f93d7..50036d299f 100644 --- a/src/app/api/usage/analytics/route.ts +++ b/src/app/api/usage/analytics/route.ts @@ -1321,6 +1321,12 @@ export async function GET(request: Request) { return NextResponse.json(analytics); } catch (error) { console.error("Error computing analytics:", error); - return NextResponse.json({ error: "Failed to compute analytics" }, { status: 500 }); + // Surface the real (sanitized) reason so the dashboard can show it instead of a + // generic placeholder (#3356). buildErrorBody strips stacks/absolute paths. + const { buildErrorBody } = await import("@omniroute/open-sse/utils/error"); + const message = error instanceof Error ? error.message : String(error); + return NextResponse.json(buildErrorBody(500, message || "Failed to compute analytics"), { + status: 500, + }); } } diff --git a/src/shared/components/UsageAnalytics.tsx b/src/shared/components/UsageAnalytics.tsx index 508b6502c6..c9282d03cc 100644 --- a/src/shared/components/UsageAnalytics.tsx +++ b/src/shared/components/UsageAnalytics.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import Card from "./Card"; import { CardSkeleton } from "./Loading"; import { fmtCompact as fmt, fmtFull, fmtCost } from "@/shared/utils/formatting"; +import { readFetchErrorMessage } from "@/shared/utils/fetchError"; import { StatCard, CompactStatGrid, @@ -59,7 +60,7 @@ export default function UsageAnalytics() { params.set("apiKeyIds", selectedApiKeys.join(",")); } const res = await fetch(`/api/usage/analytics?${params.toString()}`); - if (!res.ok) throw new Error(tCommon("error")); + if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error"))); const data = await res.json(); setAnalytics(data); setError(null); diff --git a/src/shared/utils/fetchError.ts b/src/shared/utils/fetchError.ts new file mode 100644 index 0000000000..60c4f7810c --- /dev/null +++ b/src/shared/utils/fetchError.ts @@ -0,0 +1,26 @@ +/** + * Extract a human-readable message from a failed `fetch` Response body. + * + * Handles both response shapes OmniRoute routes emit: + * - OpenAI-style `{ error: { message, type, code } }` (from `buildErrorBody`) + * - legacy `{ error: "..." }` string bodies + * + * The server already sanitizes these messages (stack traces / absolute paths + * stripped via `sanitizeErrorMessage`), so surfacing them in the UI is safe. + * Falls back to `fallback` when the body is absent, unparseable, or carries no + * usable message. Never throws -- safe to call directly inside a fetch guard. + */ +export async function readFetchErrorMessage(res: Response, fallback: string): Promise { + try { + const body = (await res.json()) as unknown; + const err = (body as { error?: unknown } | null)?.error; + if (typeof err === "string" && err.trim()) return err.trim(); + if (err && typeof err === "object") { + const message = (err as { message?: unknown }).message; + if (typeof message === "string" && message.trim()) return message.trim(); + } + } catch { + // Non-JSON body (e.g. an HTML 500 page) or a read failure -> use the fallback. + } + return fallback; +} diff --git a/tests/unit/fetch-error-message.test.ts b/tests/unit/fetch-error-message.test.ts new file mode 100644 index 0000000000..37618f29da --- /dev/null +++ b/tests/unit/fetch-error-message.test.ts @@ -0,0 +1,47 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { readFetchErrorMessage } from "../../src/shared/utils/fetchError.ts"; + +const FALLBACK = "An error occurred"; + +function jsonResponse(body: unknown, status = 500) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +// #3356: the Analytics page rendered a generic "An error occurred" because it +// discarded the server's error body. The page must surface the real (already +// sanitized server-side) message instead. + +test("reads the OpenAI-style { error: { message } } shape from buildErrorBody", async () => { + const res = jsonResponse({ error: { message: "Failed to compute analytics", type: "api_error" } }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), "Failed to compute analytics"); +}); + +test("reads the legacy { error: '...' } string shape", async () => { + const res = jsonResponse({ error: "no such column: combo_name" }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), "no such column: combo_name"); +}); + +test("trims surrounding whitespace from the extracted message", async () => { + const res = jsonResponse({ error: { message: " boom " } }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), "boom"); +}); + +test("falls back when the error message is blank", async () => { + const res = jsonResponse({ error: { message: " " } }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), FALLBACK); +}); + +test("falls back when there is no error field", async () => { + const res = jsonResponse({ data: 1 }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), FALLBACK); +}); + +test("falls back on a non-JSON body without throwing", async () => { + const res = new Response("500 Internal Server Error", { status: 500 }); + assert.equal(await readFetchErrorMessage(res, FALLBACK), FALLBACK); +});