fix(dashboard): surface real analytics error instead of generic placeholder (#3356) (#3361)

The Analytics page discarded the server's error body on a non-OK response and
rendered a generic "An error occurred", so users (and maintainers) could not see
why /api/usage/analytics 500'd after an upgrade. Now the route returns the real
reason via buildErrorBody (sanitized, Hard Rule #12) and the page surfaces it via
a new readFetchErrorMessage helper that handles both the OpenAI-style and legacy
error shapes.

Reported-by: @superti4r
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-07 07:15:42 -03:00
committed by GitHub
parent 549e2aeeb8
commit c8031be906
4 changed files with 82 additions and 2 deletions

View File

@@ -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,
});
}
}

View File

@@ -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);

View File

@@ -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<string> {
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;
}

View File

@@ -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("<html>500 Internal Server Error</html>", { status: 500 });
assert.equal(await readFetchErrorMessage(res, FALLBACK), FALLBACK);
});