mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(logging): keep warnings and errors in their own ring and find them by error id
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user