feat(logs): app log store — backend pino DB sink + frontend error collection
Add a third data stream (app_logs), distinct from the signed ledger and device telemetry, for operational/diagnostic logs — an offline appliance has no Sentry to ship to, so the host is the log store. Backend: a pino stream tees warn/error/fatal into app_logs (info/debug stay stdout-only) with no call-site change; the DB is built before Fastify so the logger has its sink. Frontend (lib/logger.ts): ships failed API requests (minus 401 churn), window.onerror, unhandledrejection, and a top-level React ErrorBoundary; console warn/error forwarded only at debug/trace. Batched/throttled POST, sendBeacon on pagehide, loop-safe (never logs the /api/logs call), best-effort everywhere. POST /api/logs (any signed-in user, CSRF, tolerant) + GET /api/logs gated by a new log:read permission (new `log` RBAC resource; admin holds it). Retention: pruned by age + row cap, hourly + at startup. UI: a Logs screen under /setup (filter level/source/since, expand to context+stack), sq+en. Migration 0009_app_logs. Verified end-to-end via app.inject: login -> POST 204 -> GET 200 with the record; backend warn/error persisted, info dropped; non-admin GET 403 / POST 204. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { AppLogRecord, ClientLogInput, LogLevel } from "@parking/shared";
|
||||
import { requireAuth, requirePermission } from "../auth.js";
|
||||
import type { LogService } from "../log-service.js";
|
||||
|
||||
// Application/diagnostic logs (app_logs) — see wiki/concepts/app-logs.md. Two ends:
|
||||
// - POST /api/logs : the FRONTEND ships its errors here (failed requests, uncaught
|
||||
// exceptions). Any signed-in user may write (it's their own
|
||||
// browser's diagnostics); CSRF still applies (mutation).
|
||||
// - GET /api/logs : read the store — gated by `log:read` (admin/diagnostic role).
|
||||
// Writes go through the shared LogService (bounded, best-effort, reentrancy-guarded);
|
||||
// the DB sink for BACKEND warn+ is wired at the pino stream, not here.
|
||||
|
||||
const LEVELS: ReadonlySet<string> = new Set(["trace", "debug", "info", "warn", "error", "fatal"]);
|
||||
|
||||
/** Cap a single ingest batch so a misbehaving client can't flood the store. */
|
||||
const MAX_BATCH = 50;
|
||||
|
||||
function isValidEntry(e: unknown): e is ClientLogInput {
|
||||
if (!e || typeof e !== "object") return false;
|
||||
const o = e as Record<string, unknown>;
|
||||
return typeof o.message === "string" && typeof o.level === "string" && LEVELS.has(o.level);
|
||||
}
|
||||
|
||||
export async function logRoutes(app: FastifyInstance, logService: LogService): Promise<void> {
|
||||
// INGEST — accept one entry or a small batch ({ entries: [...] }). Returns 204.
|
||||
// Deliberately tolerant: it never 4xx's on a malformed entry (a client erroring
|
||||
// while reporting an error shouldn't get a second error) — invalid items are skipped.
|
||||
app.post<{ Body: ClientLogInput | { entries?: unknown[] } }>(
|
||||
"/api/logs",
|
||||
{ preHandler: requireAuth },
|
||||
async (req, reply) => {
|
||||
const body = req.body as ClientLogInput | { entries?: unknown[] };
|
||||
const raw = Array.isArray((body as { entries?: unknown[] }).entries)
|
||||
? (body as { entries: unknown[] }).entries
|
||||
: [body];
|
||||
const userId = req.user?.sub ?? null;
|
||||
const userAgent = req.headers["user-agent"] ?? null;
|
||||
for (const entry of raw.slice(0, MAX_BATCH)) {
|
||||
if (!isValidEntry(entry)) continue;
|
||||
logService.recordClient(entry, { userId, userAgent });
|
||||
}
|
||||
reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
// READ — newest first, with optional level/source/since filters + a limit. The
|
||||
// booth Logs viewer calls this. Gated by log:read.
|
||||
app.get<{ Querystring: { limit?: string; level?: string; source?: string; since?: string } }>(
|
||||
"/api/logs",
|
||||
{ preHandler: requirePermission("log:read") },
|
||||
async (req): Promise<{ logs: AppLogRecord[] }> => {
|
||||
const limit = Math.min(Math.max(Number(req.query.limit) || 200, 1), 2000);
|
||||
const level = (req.query.level ?? "").trim();
|
||||
const source = (req.query.source ?? "").trim();
|
||||
const since = (req.query.since ?? "").trim();
|
||||
const logs = logService.query({
|
||||
limit,
|
||||
level: LEVELS.has(level) ? (level as LogLevel) : undefined,
|
||||
source: source === "frontend" || source === "backend" ? source : undefined,
|
||||
since: since || undefined,
|
||||
});
|
||||
return { logs };
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user