feat(logging): keep warnings and errors in their own ring and find them by error id

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 22:06:39 +02:00
1 parent 2fd62cc115
commit 1af0de5e87
7 files changed
+186 -13

No files matched your search

@@ -136,4 +136,38 @@ describe("GET /api/admin/app-logs", () => {
expect(json.error).toBe("Validation failed");
expect(json.details.issues.length).toBeGreaterThan(0);
});
it("finds an error by the first 8 characters of its id in the problems ring", async () => {
appendLogEvent(
makeEvent({ level: "error", request_id: "3f9a2c1b-1111-4000-8000-0000" }),
);
for (let i = 0; i < 600; i++) {
appendLogEvent(makeEvent({ level: "info" }));
}
const gone = await GET(req("requestId=3f9a2c1b"));
const goneJson = (await gone.json()) as { data: { events: WideEvent[] } };
expect(goneJson.data.events).toHaveLength(0);
const res = await GET(req("requestId=3f9a2c1b&ring=problems"));
const json = (await res.json()) as {
data: {
events: WideEvent[];
meta: { problemsBufferMax: number; ring: string };
};
};
expect(json.data.events.map((e) => e.request_id)).toEqual([
"3f9a2c1b-1111-4000-8000-0000",
]);
expect(json.data.meta.problemsBufferMax).toBe(1000);
expect(json.data.meta.ring).toBe("problems");
});
it("refuses a request id shorter than 8 characters or outside the id alphabet", async () => {
for (const bad of ["3f9a2c1", "3f9a2c1b%20OR%201", "a".repeat(65)]) {
const res = await GET(req(`requestId=${bad}`));
expect(res.status).toBe(422);
}
const ring = await GET(req("ring=everything"));
expect(ring.status).toBe(422);
});
});
+26 -2
View File
@@ -1,7 +1,11 @@
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { readLogBuffer, LOG_BUFFER_MAX } from "@/lib/logging/in-memory-buffer";
import {
readLogBuffer,
LOG_BUFFER_MAX,
PROBLEMS_BUFFER_MAX,
} from "@/lib/logging/in-memory-buffer";
import { redactSecrets } from "@/lib/logging/redact";
import type { WideEvent } from "@/lib/logging/types";
import { NextRequest } from "next/server";
@@ -10,17 +14,31 @@ import { z } from "zod/v4";
export const dynamic = "force-dynamic";
const querySchema = z.object({
// The error id a person reads out is the first 8 characters of the request
// id; the full id (up to 64) matches as well. Same alphabet as an accepted
// incoming `x-request-id`.
requestId: z
.string()
.trim()
.regex(/^[A-Za-z0-9-]{8,64}$/)
.optional(),
ring: z.enum(["all", "problems"]).default("all"),
traceId: z.string().trim().min(1).max(200).optional(),
level: z.enum(["debug", "info", "warn", "error"]).optional(),
action: z.string().trim().min(1).max(200).optional(),
since: z.string().datetime().optional(),
until: z.string().datetime().optional(),
limit: z.coerce.number().int().min(1).max(500).default(200),
limit: z.coerce.number().int().min(1).max(PROBLEMS_BUFFER_MAX).default(200),
});
/**
* Admin endpoint: tail of the per-process in-memory wide-event ring buffer.
*
* `ring=problems` reads the second ring, which keeps the last 1,000 `warn` and
* `error` events only, so a failure a person reported an hour ago is still
* there after the main ring has turned over. `requestId` finds the event
* behind an error id by prefix.
*
* The buffer is populated by `transports.emitEvent()` so the result mirrors
* what would be shipped to Loki (sampler-gated). Per-process and volatile —
* see `src/lib/logging/in-memory-buffer.ts` for the limitations.
@@ -36,6 +54,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { searchParams } = new URL(request.url);
const result = querySchema.safeParse({
requestId: searchParams.get("requestId") ?? undefined,
ring: searchParams.get("ring") ?? undefined,
traceId: searchParams.get("traceId") ?? undefined,
level: searchParams.get("level") ?? undefined,
action: searchParams.get("action") ?? undefined,
@@ -47,6 +67,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
const parsed = result.data;
const events = readLogBuffer({
ring: parsed.ring,
requestId: parsed.requestId,
traceId: parsed.traceId,
level: parsed.level,
action: parsed.action,
@@ -62,6 +84,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
meta: {
total: redacted.length,
bufferMax: LOG_BUFFER_MAX,
problemsBufferMax: PROBLEMS_BUFFER_MAX,
ring: parsed.ring,
},
});
});