feat(admin): add production error center and refresh CMS dependencies

This commit is contained in:
Simo committed 2026-09-05 19:53:09 +02:00
1 parent c6b919c01d
commit 816e3875c2
47 files changed
+1545 -547

No files matched your search

+7 -4
View File
@@ -2,9 +2,10 @@
import { Home, RefreshCw } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { ErrorScreen } from "@/components/error-screen";
import Link from "@/components/link";
import { reportBrowserError } from "@/lib/browser-errors";
/**
* Site route-segment error boundary. Renders inside the site shell layout.
@@ -19,8 +20,10 @@ export default function SiteErrorPage({
}) {
const t = useTranslations("pages.error");
const [reference, setReference] = useState(error.digest);
useEffect(() => {
console.error(error);
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
if (error.digest) reportBrowserError(error, "browser.boundary");
}, [error]);
return (
@@ -46,9 +49,9 @@ export default function SiteErrorPage({
</>
}
footer={
error.digest ? (
reference ? (
<p className="error-screen-digest">
{t("reference", { digest: error.digest })}
{t("reference", { digest: reference })}
</p>
) : null
}
@@ -0,0 +1,39 @@
import { expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
guard: vi.fn(),
resolve: vi.fn(),
audit: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: mocks.guard }));
vi.mock("@/lib/error-monitor", () => ({
ErrorStore: class {
resolve = mocks.resolve;
},
}));
vi.mock("@/lib/permissions", () => ({
PERMS: { DEVOPS_EDIT: "admin.devops.edit" },
}));
vi.mock("@/lib/services/audit", () => ({ logAudit: mocks.audit }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { resolveCmsError } from "./actions";
it("requires edit permission before resolving or auditing", async () => {
mocks.guard.mockRejectedValue(new Error("denied"));
await expect(resolveCmsError(new FormData())).rejects.toThrow("denied");
expect(mocks.resolve).not.toHaveBeenCalled();
expect(mocks.audit).not.toHaveBeenCalled();
});
it("records who resolved the problem", async () => {
mocks.guard.mockResolvedValue({ id: 42 });
mocks.resolve.mockResolvedValue(undefined);
mocks.audit.mockResolvedValue(undefined);
const data = new FormData();
data.set("fingerprint", "aabbccddeeff0011");
await resolveCmsError(data);
expect(mocks.guard).toHaveBeenCalledWith("admin.devops.edit");
expect(mocks.audit).toHaveBeenCalledWith(
expect.objectContaining({ userId: 42, action: "cms.error.resolve" }),
);
});
@@ -0,0 +1,17 @@
"use server";
import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard";
import { ErrorStore } from "@/lib/error-monitor";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
export async function resolveCmsError(data: FormData) {
const staff = await requirePermission(PERMS.DEVOPS_EDIT);
await new ErrorStore().resolve(String(data.get("fingerprint") ?? ""));
await logAudit({
userId: staff.id,
action: "cms.error.resolve",
target: "cms-error",
after: { fingerprint: String(data.get("fingerprint")) },
});
revalidatePath("/admin/devops/cms-errors");
}
@@ -0,0 +1,32 @@
import { expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ read: vi.fn(), access: vi.fn() }));
vi.mock("@/lib/permissions", () => ({
getAdminContext: async () => ({
session: { user: { rank: 1 } },
permissions: {},
}),
canAccess: mocks.access,
PERMS: { DEVOPS_VIEW: "devops.view" },
}));
vi.mock("next/navigation", () => ({
redirect: () => {
throw new Error("redirect");
},
}));
vi.mock("@/lib/error-monitor", () => ({
ErrorStore: class {
read = mocks.read;
},
}));
vi.mock("@/components/link", () => ({ default: () => null }));
import Page from "./page";
it("rejects access before reading diagnostic data", async () => {
mocks.access.mockReturnValue(false);
await expect(Page({ searchParams: Promise.resolve({}) })).rejects.toThrow(
"redirect",
);
expect(mocks.read).not.toHaveBeenCalled();
});
+181
View File
@@ -0,0 +1,181 @@
import { redirect } from "next/navigation";
import Link from "@/components/link";
import { ErrorStore } from "@/lib/error-monitor";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { resolveCmsError } from "./actions";
export default async function CmsErrorsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank))
redirect("/admin");
const params = await searchParams;
const query = (params.q ?? "").trim().slice(0, 200).toLowerCase();
let result: Awaited<ReturnType<ErrorStore["read"]>>;
try {
result = await new ErrorStore().read();
} catch {
return (
<div className="admin-card" role="alert">
Error storage could not be read. Check the CMS storage volume and
permissions.
</div>
);
}
const filtered = result.records.filter(
(r) =>
(!params.source || r.source === params.source) &&
(!query || JSON.stringify(r).toLowerCase().includes(query)),
);
const groups = new Map<string, typeof filtered>();
for (const r of filtered) {
const list = groups.get(r.fingerprint) ?? [];
list.push(r);
groups.set(r.fingerprint, list);
}
const page = Math.max(
1,
Math.min(
Number(params.page) || 1,
Math.max(1, Math.ceil(groups.size / 25)),
),
);
const pageHref = (next: number) =>
`?${new URLSearchParams({ q: params.q ?? "", source: params.source ?? "", page: String(next) })}`;
return (
<div className="space-y-5">
<div>
<h1 className="text-2xl font-semibold">CMS error center</h1>
<p className="text-muted-foreground">
Server and browser errors from the last 7 days. Browser reports are
unverified client observations.
</p>
</div>
<div className="flex flex-wrap gap-3">
<Link href="/admin/devops" className="btn btn-outline">
DevOps
</Link>
<Link href="/admin/devops/errors" className="btn btn-outline">
Emulator errors
</Link>
<Link href="/admin/devops/cms-errors" className="btn btn-outline">
Refresh / clear filters
</Link>
</div>
<form className="admin-card flex flex-wrap gap-3">
<input
aria-label="Search errors"
name="q"
defaultValue={params.q}
placeholder="Reference, digest, message, route or release"
className="input min-w-0 flex-1"
/>
<select
name="source"
aria-label="Error source"
defaultValue={params.source ?? ""}
className="input"
>
<option value="">All sources</option>
<option value="server">Server</option>
<option value="browser">Browser</option>
</select>
<button type="submit" className="btn btn-primary">
Search
</button>
</form>
<p className="text-sm text-muted-foreground">
{filtered.length} occurrences · {groups.size} groups.{" "}
{result.truncated
? "Showing the latest 1,000 events. Narrowing filters searches this retained window."
: ""}{" "}
Storage is limited to 10 MB per day; excess events remain in server logs
where available.
</p>
{groups.size === 0 ? (
<div className="admin-card">
No captured errors match these filters. Monitoring starts with this
deployment; older console logs are not imported.
</div>
) : null}
{[...groups.entries()]
.slice((page - 1) * 25, page * 25)
.map(([fingerprint, events]) => {
const r = events[0];
return (
<details key={fingerprint} className="admin-card overflow-hidden">
<summary className="cursor-pointer">
<span className="text-xs text-muted-foreground">
{r.resolved ? "Resolved" : "Open"} · {r.source} ·{" "}
{events.length} occurrences · {r.at}
</span>
<p className="font-semibold break-words">{r.event}</p>
<p className="text-sm break-words">{r.message}</p>
</summary>
<div className="mt-4 space-y-3 text-sm">
<p>
Reference: <code className="break-all">{r.id}</code>
</p>
<p>
Release: <code className="break-all">{r.release}</code>
</p>
<pre className="whitespace-pre-wrap break-all rounded bg-muted p-3">
{JSON.stringify(r.context, null, 2)}
</pre>
<pre className="max-h-96 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-3">
{r.stack ||
"No stack supplied. Use the reference or digest to correlate server logs."}
</pre>
{!r.resolved &&
canAccess(permissions, PERMS.DEVOPS_EDIT, session.user.rank) ? (
<form action={resolveCmsError}>
<input
type="hidden"
name="fingerprint"
value={r.fingerprint}
/>
<button type="submit" className="btn btn-outline">
Mark resolved
</button>
<p className="text-muted-foreground text-xs">
A new occurrence reopens the group automatically.
</p>
</form>
) : null}
<details>
<summary className="cursor-pointer">
Occurrence references
</summary>
<ul>
{events.map((e) => (
<li key={e.id} className="break-all">
{e.at} · {e.id}
</li>
))}
</ul>
</details>
<p className="text-muted-foreground">
Browser stack traces may reference compiled chunks. This view
does not infer a root cause or include request bodies.
</p>
</div>
</details>
);
})}
<nav aria-label="Error pages" className="flex gap-3">
{page > 1 ? (
<Link href={pageHref(page - 1)} className="btn btn-outline">
Previous
</Link>
) : null}
{page * 25 < groups.size ? (
<Link href={pageHref(page + 1)} className="btn btn-outline">
Next
</Link>
) : null}
</nav>
</div>
);
}
+3
View File
@@ -35,6 +35,9 @@ export default async function EmulatorErrorsPage({
return (
<div className="space-y-6">
<a href="/admin/devops/cms-errors" className="btn btn-outline">
CMS error center
</a>
<p className="text-muted-foreground">
{total} total errors — showing page {page} of {lastPage}
</p>
+3
View File
@@ -55,6 +55,9 @@ export default async function DevOpsPage() {
return (
<div className="space-y-6">
<a href="/admin/devops/cms-errors" className="btn btn-outline">
CMS error center
</a>
{/* Status cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<Card>
+7 -4
View File
@@ -2,9 +2,10 @@
import { LayoutDashboard, RefreshCw } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import Link from "@/components/link";
import { Button } from "@/components/ui/button";
import { reportBrowserError } from "@/lib/browser-errors";
/**
* Admin route-segment error boundary. Renders inside the admin layout shell.
@@ -19,8 +20,10 @@ export default function AdminErrorPage({
const t = useTranslations("pages.error");
const tNav = useTranslations("admin.nav");
const [reference, setReference] = useState(error.digest);
useEffect(() => {
console.error(error);
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
if (error.digest) reportBrowserError(error, "browser.boundary");
}, [error]);
return (
@@ -63,12 +66,12 @@ export default function AdminErrorPage({
</Link>
</Button>
</div>
{error.digest ? (
{reference ? (
<p
className="mt-6 text-xs font-mono"
style={{ color: "var(--admin-muted, #9ca3af)" }}
>
{t("reference", { digest: error.digest })}
{t("reference", { digest: reference })}
</p>
) : null}
</div>
+7 -4
View File
@@ -1,8 +1,9 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { reportBrowserError } from "@/lib/browser-errors";
export default function PermissionRankError({
error,
@@ -11,8 +12,10 @@ export default function PermissionRankError({
error: Error & { digest?: string };
reset: () => void;
}) {
const [reference, setReference] = useState(error.digest);
useEffect(() => {
console.error(error);
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
if (error.digest) reportBrowserError(error, "browser.boundary");
}, [error]);
return (
@@ -28,9 +31,9 @@ export default function PermissionRankError({
<pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted p-3 text-xs">
{error.stack ?? error.message}
</pre>
{error.digest ? (
{reference ? (
<p className="text-xs text-muted-foreground">
Reference: {error.digest}
Reference: {reference}
</p>
) : null}
<Button type="button" onClick={reset}>
@@ -0,0 +1,79 @@
import { afterEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ append: vi.fn(), limit: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
clientIp: async () => "test",
rateLimit: mocks.limit,
}));
vi.mock("@/lib/error-monitor", async () => {
const actual = await vi.importActual<typeof import("@/lib/error-monitor")>(
"@/lib/error-monitor",
);
return {
...actual,
ErrorStore: class {
append = mocks.append;
},
};
});
import { POST } from "./route";
afterEach(() => vi.resetAllMocks());
const req = (body: string, origin = "https://cms.test") =>
new Request("https://cms.test/api/diagnostics/errors", {
method: "POST",
headers: { origin, "Content-Type": "application/json" },
body,
});
it("rejects cross-origin reports before touching storage", async () => {
expect((await POST(req("{}", "https://other.test"))).status).toBe(403);
expect(mocks.append).not.toHaveBeenCalled();
});
it("limits reports before reading their body", async () => {
mocks.limit.mockResolvedValue({ ok: false });
expect((await POST(req("{}"))).status).toBe(429);
expect(mocks.append).not.toHaveBeenCalled();
});
it("rejects oversized reports without trusting content-length", async () => {
mocks.limit.mockResolvedValue({ ok: true });
expect((await POST(req("a".repeat(17000)))).status).toBe(413);
expect(mocks.append).not.toHaveBeenCalled();
});
it("stores only sanitized diagnostic fields and identifies browser claims", async () => {
mocks.limit.mockResolvedValue({ ok: true });
mocks.append.mockResolvedValue(undefined);
const r = await POST(
req(
JSON.stringify({
event: "browser.exception",
message: "token=secret",
path: "/news?token=hidden",
body: "never store",
release: "old-client",
}),
),
);
expect(r.status).toBe(202);
const saved = mocks.append.mock.calls[0][0];
expect(saved.source).toBe("browser");
expect(saved.context.clientRelease).toBe("old-client");
expect(JSON.stringify(saved)).not.toMatch(/hidden|never store|token=secret/);
});
it("reports storage failure rather than claiming receipt", async () => {
mocks.limit.mockResolvedValue({ ok: true });
mocks.append.mockRejectedValue(new Error("disk"));
expect(
(
await POST(
req(
JSON.stringify({
event: "browser.exception",
message: "failure",
path: "/",
}),
),
)
).status,
).toBe(503);
});
+66
View File
@@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { createErrorRecord, ErrorStore } from "@/lib/error-monitor";
import { clientIp, rateLimit } from "@/lib/rate-limit";
const schema = z.object({
event: z.enum([
"browser.exception",
"browser.boundary",
"browser.unhandled_rejection",
]),
clientReference: z.string().uuid().optional(),
message: z.string().max(1000),
stack: z.string().max(6000).optional(),
path: z.string().startsWith("/").max(1000),
digest: z.string().max(200).optional(),
release: z.string().max(100).optional(),
});
export async function POST(request: Request) {
if (
request.headers.get("origin") !==
new URL(
process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL || request.url,
).origin ||
!request.headers.get("content-type")?.startsWith("application/json")
)
return new Response(null, { status: 403 });
const limit = await rateLimit(`cms-error:${await clientIp()}`, 20, 60000);
if (!limit.ok) return new Response(null, { status: 429 });
const reader = request.body?.getReader();
if (!reader) return new Response(null, { status: 400 });
const chunks: Uint8Array[] = [];
let length = 0;
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
length += value.byteLength;
if (length > 16000) {
await reader.cancel();
return new Response(null, { status: 413 });
}
chunks.push(value);
}
const parsed = schema.safeParse(
JSON.parse(Buffer.concat(chunks).toString("utf8")),
);
if (!parsed.success) return new Response(null, { status: 400 });
const data = parsed.data;
const error = new Error(data.message);
error.stack = data.stack ?? "";
const record = createErrorRecord("browser", data.event, error, {
clientReference: data.clientReference ?? null,
path: data.path.split("?")[0],
digest: data.digest ?? null,
clientRelease: data.release ?? "unknown",
});
await new ErrorStore().append(record);
return NextResponse.json(
{ id: record.id },
{ status: 202, headers: { "Cache-Control": "no-store" } },
);
} catch {
return new Response(null, { status: 503 });
}
}
+7 -4
View File
@@ -2,9 +2,10 @@
import { Home, RefreshCw } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { ErrorScreen } from "@/components/error-screen";
import Link from "@/components/link";
import { reportBrowserError } from "@/lib/browser-errors";
/**
* Route-segment error boundary. Renders inside the root layout (so the shell
@@ -19,8 +20,10 @@ export default function ErrorPage({
}) {
const t = useTranslations("pages.error");
const [reference, setReference] = useState(error.digest);
useEffect(() => {
console.error(error);
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
if (error.digest) reportBrowserError(error, "browser.boundary");
}, [error]);
return (
@@ -46,9 +49,9 @@ export default function ErrorPage({
</>
}
footer={
error.digest ? (
reference ? (
<p className="error-screen-digest">
{t("reference", { digest: error.digest })}
{t("reference", { digest: reference })}
</p>
) : null
}
+7 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { reportBrowserError } from "@/lib/browser-errors";
/**
* Last-resort boundary for errors thrown in the root layout itself. It replaces
@@ -14,8 +15,10 @@ export default function GlobalError({
error: Error & { digest?: string };
reset: () => void;
}) {
const [reference, setReference] = useState(error.digest);
useEffect(() => {
console.error(error);
setReference(error.digest ?? reportBrowserError(error, "browser.boundary"));
if (error.digest) reportBrowserError(error, "browser.boundary");
}, [error]);
return (
@@ -107,8 +110,8 @@ export default function GlobalError({
<button type="button" className="ge-btn" onClick={() => reset()}>
Try again
</button>
{error.digest ? (
<p className="ge-digest">Reference: {error.digest}</p>
{reference ? (
<p className="ge-digest">Reference: {reference}</p>
) : null}
</div>
</body>
-2
View File
@@ -9,7 +9,6 @@ import type { ReactNode } from "react";
import { Toaster } from "sonner";
import { PwaRegister } from "@/components/pwa-register";
import { ScopedThemeVars } from "@/components/scoped-theme-vars";
import { SmoothScroll } from "@/components/smooth-scroll";
import { ThemeScopeDetector } from "@/components/theme-scope-detector";
import { ThemeVars } from "@/components/theme-vars";
import { enforceSiteAccess } from "@/lib/access-guard";
@@ -102,7 +101,6 @@ export default async function RootLayout({
<ThemeScopeDetector />
{children}
<PwaRegister />
<SmoothScroll />
<Toaster
position="top-right"
richColors
+2 -4
View File
@@ -2,10 +2,8 @@
import { useEffect, useId, useRef, useState } from "react";
// TinyMCE community build, loaded from the public jsDelivr CDN. `no-api-key`
// is the documented sentinel for the free self-hosted/CDN build and silences
// the "register for an API key" notification.
const TINYMCE_SRC = "https://cdn.jsdelivr.net/npm/tinymce@6/tinymce.min.js";
// Editor assets are pinned in the lockfile and copied during build.
const TINYMCE_SRC = "/vendor/tinymce/tinymce.min.js";
type TinyMCEGlobal = {
init: (opts: Record<string, unknown>) => Promise<unknown>;
-34
View File
@@ -1,34 +0,0 @@
"use client";
import { Lenis } from "lenis/react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
export function SmoothScroll() {
const pathname = usePathname();
const isAdmin = pathname === "/admin" || pathname?.startsWith("/admin/");
const [prefersReduced, setPrefersReduced] = useState(true);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReduced(mq.matches);
const onChange = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}, []);
// Admin panels own their scroll containers; root wheel smoothing consumes their events.
if (prefersReduced || isAdmin) return null;
return (
<Lenis
root
options={{
duration: 1.2,
lerp: 0.1,
smoothWheel: true,
wheelMultiplier: 1,
}}
/>
);
}
@@ -15,6 +15,7 @@ export const systemManifest = {
PERMS.LOGS_VIEW,
PERMS.ANALYTICS_VIEW,
PERMS.DEVOPS_VIEW,
PERMS.DEVOPS_EDIT,
PERMS.NOTIFICATIONS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.RCON_EXECUTE,
@@ -612,8 +612,8 @@ describe("housekeeping foundation completion contracts", () => {
HOUSEKEEPING_MIGRATION_MATRIX,
);
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138);
expect(discovered).toHaveLength(138);
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
expect(discovered).toHaveLength(139);
expect(issues).toEqual([]);
});
});
@@ -173,6 +173,7 @@ const expectedManifests = [
PERMS.LOGS_VIEW,
PERMS.ANALYTICS_VIEW,
PERMS.DEVOPS_VIEW,
PERMS.DEVOPS_EDIT,
PERMS.NOTIFICATIONS_VIEW,
PERMS.PERMISSIONS_MANAGE,
PERMS.RCON_EXECUTE,
@@ -29,7 +29,7 @@ describe("discoverLegacyPages", () => {
it("discovers the exact legacy administration inventory", () => {
const pages = discoverLegacyPages();
expect(pages).toHaveLength(138);
expect(pages).toHaveLength(139);
expect(pages).toContainEqual({
surface: "admin",
legacyPath: "/admin/users/:id/edit",
@@ -4,10 +4,10 @@ import { HOUSEKEEPING_MIGRATION_MATRIX } from "./matrix";
import { validateMigrationEntries } from "./validate-matrix";
describe("HOUSEKEEPING_MIGRATION_MATRIX", () => {
it("covers all 138 legacy pages exactly once", () => {
it("covers all 139 legacy pages exactly once", () => {
const discovered = discoverLegacyPages();
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(138);
expect(HOUSEKEEPING_MIGRATION_MATRIX).toHaveLength(139);
expect(
validateMigrationEntries(discovered, HOUSEKEEPING_MIGRATION_MATRIX),
).toEqual([]);
@@ -19,8 +19,8 @@ const SYSTEM_PREFIXES = [
] as const;
describe("systemMigrationEntries", () => {
it("covers all 19 System pages exactly once", () => {
expect(systemMigrationEntries).toHaveLength(19);
it("covers all 20 System pages exactly once", () => {
expect(systemMigrationEntries).toHaveLength(20);
expect(
validateMigrationEntries(
ownedLegacyPages(SYSTEM_PREFIXES),
@@ -193,6 +193,18 @@ export const systemMigrationEntries: readonly MigrationEntry[] = [
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/devops/cms-errors",
sourceFile: "src/app/admin/devops/cms-errors/page.tsx",
targetPath: "/admin/system/observability/devops/cms-errors",
decision: "REBUILD",
capabilities: { read: [PERMS.DEVOPS_VIEW], mutate: [PERMS.DEVOPS_EDIT] },
dependencies: { queries: ["ErrorStore"], mutations: ["resolveCmsError"] },
auditRequirement: "MUTATION",
localization: "PARTIAL",
accessibility: "PARTIAL",
}),
plannedSystemEntry({
surface: "admin",
legacyPath: "/admin/emulator",
+8
View File
@@ -0,0 +1,8 @@
import { reportBrowserError } from "@/lib/browser-errors";
window.addEventListener("error", (event) => {
if (event.error) reportBrowserError(event.error);
});
window.addEventListener("unhandledrejection", (event) =>
reportBrowserError(event.reason, "browser.unhandled_rejection"),
);
+2 -2
View File
@@ -122,12 +122,12 @@ export function withAdmin(
return response;
} catch (error) {
await finishExport?.().catch(() => undefined);
logServerError("admin.api_failed", error, {
const errorId = logServerError("admin.api_failed", error, {
path: request.nextUrl.pathname,
userId: context.session.user.id,
});
return NextResponse.json(
{ ok: false, error: "Internal server error" },
{ ok: false, error: "Internal server error", errorId },
{ status: 500 },
);
}
+28
View File
@@ -0,0 +1,28 @@
import { afterEach, expect, it, vi } from "vitest";
import { reportBrowserError } from "./browser-errors";
afterEach(() => vi.unstubAllGlobals());
it("returns a reusable reference, deduplicates reports and omits URL parameters", () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true });
vi.stubGlobal("window", {});
vi.stubGlobal("location", {
pathname: "/admin/studio",
search: "?token=secret",
});
vi.stubGlobal("fetch", fetchMock);
const error = new Error("browser-test-unique");
const id = reportBrowserError(error);
expect(reportBrowserError(error)).toBe(id);
expect(fetchMock).toHaveBeenCalledTimes(1);
const payload = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(payload.clientReference).toBe(id);
expect(payload.path).toBe("/admin/studio");
expect(JSON.stringify(payload)).not.toContain("token=secret");
});
it("never throws again when reporting fails on the network", async () => {
vi.stubGlobal("window", {});
vi.stubGlobal("location", { pathname: "/" });
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline")));
expect(reportBrowserError(new Error("offline-test"))).toBeTruthy();
await Promise.resolve();
});
+34
View File
@@ -0,0 +1,34 @@
const seen = new Map<string, { at: number; id: string }>();
export function reportBrowserError(
error: unknown,
event = "browser.exception",
) {
if (typeof window === "undefined") return;
const candidate =
error instanceof Error
? error
: new Error(typeof error === "string" ? error : "Unknown browser error");
const key = `${event}:${location.pathname}:${"digest" in candidate ? candidate.digest : ""}:${candidate.message}`;
const now = Date.now();
const prior = seen.get(key);
if (prior && now - prior.at < 60000) return prior.id;
const reference = crypto.randomUUID();
if (seen.size >= 100) seen.clear();
seen.set(key, { at: now, id: reference });
const digest = "digest" in candidate ? String(candidate.digest) : undefined;
void fetch("/api/diagnostics/errors", {
method: "POST",
headers: { "Content-Type": "application/json" },
keepalive: true,
body: JSON.stringify({
event,
clientReference: reference,
message: candidate.message.slice(0, 1000),
stack: candidate.stack?.slice(0, 6000),
path: location.pathname,
digest,
release: process.env.NEXT_PUBLIC_CMS_RELEASE,
}),
}).catch(() => {});
return reference;
}
+56
View File
@@ -0,0 +1,56 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, it } from "vitest";
import { createErrorRecord, ErrorStore } from "./error-monitor";
it("redacts secrets in messages, stack and context while retaining a reference", () => {
const r = createErrorRecord(
"server",
"payment.failed",
new Error("https://user:password@host/a?token=abc password=hello"),
{
token: "secret",
path: "/a?session=hidden",
nested: { password: "never" },
},
);
const text = JSON.stringify(r);
for (const secret of ["password@", "token=abc", "hello", "hidden", "never"])
expect(text).not.toContain(secret);
expect(r.id).toBeTruthy();
expect(r.stack).toContain("Error");
});
it("persists across instances and groups repeated errors without losing references", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
try {
const store = new ErrorStore(dir);
const a = createErrorRecord("server", "test", new Error("failed"));
await store.append(a);
await store.append({ ...a, id: "second" });
const result = await new ErrorStore(dir).read();
expect(result.records).toHaveLength(2);
expect(result.records[0].fingerprint).toBe(result.records[1].fingerprint);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("resolves a group and reopens it when a later occurrence arrives", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "cms-errors-"));
try {
const store = new ErrorStore(dir);
const a = createErrorRecord("server", "test", new Error("failed"));
await store.append(a);
await store.resolve(a.fingerprint);
expect((await store.read()).records[0].resolved).toBe(true);
await store.append({
...a,
id: "later",
at: new Date(Date.now() + 1000).toISOString(),
});
expect((await store.read()).records[0].resolved).toBe(false);
await expect(store.resolve("../escape")).rejects.toThrow();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+209
View File
@@ -0,0 +1,209 @@
import { createHash, randomUUID } from "node:crypto";
import {
appendFile,
mkdir,
readdir,
readFile,
stat,
unlink,
writeFile,
} from "node:fs/promises";
import path from "node:path";
export function redactErrorText(value: string): string {
return value
.slice(0, 12000)
.replace(
/(https?:\/\/|mysql:\/\/|redis:\/\/)[^\s/@]+:[^\s/@]+@/gi,
"$1[REDACTED]@",
)
.replace(/([?&])[^\s#)]+/g, "$1[REDACTED]")
.replace(
/((?:password|secret|token|authorization|cookie|api[_-]?key)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
"$1[REDACTED]",
)
.replace(/Bearer\s+[^\s,;]+/gi, "Bearer [REDACTED]");
}
export interface CmsErrorRecord {
id: string;
resolved?: boolean;
at: string;
source: "server" | "browser";
event: string;
message: string;
stack: string;
release: string;
fingerprint: string;
context: Record<string, string | number | boolean | null>;
}
export function createErrorRecord(
source: CmsErrorRecord["source"],
event: string,
error: unknown,
context: Record<string, unknown> = {},
): CmsErrorRecord {
const message = redactErrorText(
error instanceof Error
? error.message
: typeof error === "string"
? error
: "Unknown error",
);
const stack =
error instanceof Error ? redactErrorText(error.stack ?? "") : "";
const safeContext = Object.fromEntries(
Object.entries(context)
.slice(0, 24)
.map(([key, value]) => [
key,
/password|secret|token|cookie|authorization|body|query|email|api[_-]?key/i.test(
key,
)
? "[REDACTED]"
: typeof value === "string"
? redactErrorText(value).slice(0, 1000)
: typeof value === "number" ||
typeof value === "boolean" ||
value === null
? value
: "[NON_SCALAR]",
]),
);
return {
id: randomUUID(),
at: new Date().toISOString(),
source,
event: redactErrorText(event).slice(0, 160),
message,
stack,
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
fingerprint: createHash("sha256")
.update(`${source}:${event}:${message}:${stack.split("\n")[1] ?? ""}`)
.digest("hex")
.slice(0, 16),
context: safeContext,
};
}
const DAY_LIMIT = 10 * 1024 * 1024;
const RETENTION_DAYS = 7;
export class ErrorStore {
private static queue: Promise<void> = Promise.resolve();
private static pending = 0;
constructor(
private directory = path.join(process.cwd(), "storage", "cms-errors"),
) {}
async append(record: CmsErrorRecord) {
if (ErrorStore.pending >= 100) throw new Error("Error storage queue full");
ErrorStore.pending++;
const write = ErrorStore.queue.then(() => this.writeRecord(record));
ErrorStore.queue = write.catch(() => {});
try {
await write;
} finally {
ErrorStore.pending--;
}
}
private async writeRecord(record: CmsErrorRecord) {
await mkdir(this.directory, { recursive: true });
const files = await readdir(this.directory);
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
.toISOString()
.slice(0, 10);
for (const name of files)
if (
/^\d{4}-\d{2}-\d{2}(\.jsonl|\.[a-f0-9]{16}\.resolved)$/.test(name) &&
name.slice(0, 10) < cutoff
)
await unlink(path.join(this.directory, name)).catch(() => {});
const file = path.join(this.directory, record.at.slice(0, 10) + ".jsonl");
const size = await stat(file)
.then((s) => s.size)
.catch(() => 0);
if (size >= DAY_LIMIT) throw new Error("Daily error storage limit reached");
await appendFile(file, JSON.stringify(record) + "\n", { mode: 0o600 });
}
async resolve(fingerprint: string) {
if (!/^[a-f0-9]{16}$/.test(fingerprint))
throw new Error("Invalid fingerprint");
const { records } = await this.read();
if (!records.some((r) => r.fingerprint === fingerprint))
throw new Error("Error group no longer available");
const now = new Date().toISOString();
await writeFile(
path.join(this.directory, `${now.slice(0, 10)}.${fingerprint}.resolved`),
now,
{ mode: 0o600 },
);
}
async read(): Promise<{ records: CmsErrorRecord[]; truncated: boolean }> {
const files = await readdir(this.directory).catch(
(e: NodeJS.ErrnoException) => {
if (e.code === "ENOENT") return [];
throw e;
},
);
const resolved = new Map<string, string>();
for (const name of files
.filter((f) => /^\d{4}-\d{2}-\d{2}\.[a-f0-9]{16}\.resolved$/.test(f))
.sort()) {
const at = await readFile(path.join(this.directory, name), "utf8");
resolved.set(name.split(".")[1], at);
}
const records: CmsErrorRecord[] = [];
const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400000)
.toISOString()
.slice(0, 10);
for (const name of files
.filter(
(f) => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f) && f.slice(0, 10) >= cutoff,
)
.sort()
.reverse()) {
const file = path.join(this.directory, name);
if ((await stat(file)).size > DAY_LIMIT + 100000) continue;
const lines = (await readFile(file, "utf8")).trim().split("\n").reverse();
for (const line of lines) {
try {
const r = JSON.parse(line);
if (r.id && r.fingerprint)
records.push({
...r,
resolved: (resolved.get(r.fingerprint) ?? "") >= r.at,
});
} catch {
/* Ignore an interrupted final write. */
}
if (records.length >= 1000) return { records, truncated: true };
}
}
return { records, truncated: false };
}
}
const store = new ErrorStore();
let pending = 0;
let lastFailure = 0;
export function captureCmsError(
source: CmsErrorRecord["source"],
event: string,
error: unknown,
context: Record<string, unknown> = {},
) {
const record = createErrorRecord(source, event, error, context);
if (process.env.NODE_ENV === "test" || process.env.VITEST || pending >= 100)
return record.id;
pending++;
void store
.append(record)
.catch(() => {
if (Date.now() - lastFailure > 60000) {
lastFailure = Date.now();
process.stderr.write(
"CMS error monitor could not persist an event; check storage permissions or daily quota.\n",
);
}
})
.finally(() => {
pending--;
});
return record.id;
}
+17 -2
View File
@@ -1,5 +1,6 @@
import pino from "pino";
import { env } from "@/env";
import { captureCmsError } from "./error-monitor";
type LogLevel = "debug" | "info" | "warn" | "error";
@@ -41,7 +42,21 @@ export const logger = {
warn(message: string, meta: Record<string, unknown> = {}): void {
pinoLogger.warn(meta, message);
},
error(message: string, meta: Record<string, unknown> = {}): void {
pinoLogger.error(meta, message);
error(message: string, meta: Record<string, unknown> = {}): string {
const error =
meta.err instanceof Error
? meta.err
: meta.error instanceof Error
? meta.error
: new Error(
typeof meta.err === "string"
? meta.err
: typeof meta.error === "string"
? meta.error
: message,
);
const errorId = captureCmsError("server", message, error, meta);
pinoLogger.error({ ...meta, errorId }, message);
return errorId;
},
};
+12 -8
View File
@@ -1,10 +1,14 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
import { expect, it } from "vitest";
import { createErrorRecord } from "./error-monitor";
describe("observability dependency contract", () => {
it("does not ship the Sentry SDK", () => {
const require = createRequire(import.meta.url);
expect(() => require.resolve("@sentry/nextjs")).toThrow();
});
it("captures diagnostic data without serializing arbitrary request bodies", () => {
const record = createErrorRecord(
"server",
"request.failed",
new Error("failure"),
{ body: { password: "secret" }, path: "/admin/studio" },
);
expect(record.context.body).toBe("[REDACTED]");
expect(record.context.path).toBe("/admin/studio");
expect(record.stack).toContain("failure");
});
+1 -2
View File
@@ -5,9 +5,8 @@ import { logger } from "@/lib/logger";
* Domain errors (validation, auth, etc.) should NOT go through here.
*/
export function reportError(error: unknown, context = "Unhandled error"): void {
const message = error instanceof Error ? error.message : String(error);
logger.error(context, {
err: message,
err: error,
name: error instanceof Error ? error.name : undefined,
});
}
+5 -2
View File
@@ -38,8 +38,11 @@ export function logServerError(
event: string,
error: unknown,
context: LogContext = {},
): void {
logger.error(JSON.stringify(serverErrorRecord(event, error, context)));
): string {
return logger.error(event, {
...serverErrorRecord(event, error, context).context,
err: error,
});
}
function logScalar(value: unknown): LogScalar {
+3 -1
View File
@@ -17,7 +17,9 @@ let running: Promise<void> | undefined;
export function drainFurnitureImports(): Promise<void> {
if (running) return running;
running = drain()
.catch((error) => logServerError("furni.worker_failed", error))
.catch((error) => {
logServerError("furni.worker_failed", error);
})
.finally(() => {
running = undefined;
});