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,20 @@
|
||||
CREATE TABLE `app_logs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`level` text NOT NULL,
|
||||
`source` text NOT NULL,
|
||||
`message` text NOT NULL,
|
||||
`context` text,
|
||||
`http_status` integer,
|
||||
`path` text,
|
||||
`stack` text,
|
||||
`user_id` text,
|
||||
`user_agent` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `app_logs_created_at_idx` ON `app_logs` (`created_at`);--> statement-breakpoint
|
||||
CREATE INDEX `app_logs_level_idx` ON `app_logs` (`level`);--> statement-breakpoint
|
||||
-- Grant the new log:read permission to the built-in admin role (enforcement is
|
||||
-- runtime-special-cased to ALL permissions, but the Roles UI lists the grid from these
|
||||
-- rows — keep it in sync). INSERT OR IGNORE: harmless if the row already exists.
|
||||
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES ('admin','log:read');
|
||||
@@ -64,6 +64,13 @@
|
||||
"when": 1781885100000,
|
||||
"tag": "0008_user_profile_theme",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1781885200000,
|
||||
"tag": "0009_app_logs",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -356,6 +356,39 @@ export const sessions = sqliteTable("sessions", {
|
||||
lastEventIndex: integer("last_event_index"),
|
||||
});
|
||||
|
||||
// --- Application logs (diagnostics, UNSIGNED, prunable) ------------------
|
||||
// A THIRD stream, distinct from the signed ledger_events (business facts) and
|
||||
// device_events (hardware telemetry): operational/diagnostic logs for debugging the
|
||||
// appliance. Backend warn/error/fatal (a Pino sink) AND frontend errors land here —
|
||||
// failed requests, uncaught exceptions, rejected promises — so a booth problem is
|
||||
// queryable from one place on an offline box. Never signed, never reconciled, pruned
|
||||
// by age + row cap. See wiki/concepts/app-logs.md, event-streams-split.md.
|
||||
export const appLogs = sqliteTable("app_logs", {
|
||||
id: text("id").primaryKey(),
|
||||
// pino levels: trace|debug|info|warn|error|fatal. We persist warn+ from the backend.
|
||||
level: text("level", {
|
||||
enum: ["trace", "debug", "info", "warn", "error", "fatal"],
|
||||
}).notNull(),
|
||||
// Which side produced it — the booth UI or the host.
|
||||
source: text("source", { enum: ["frontend", "backend"] }).notNull(),
|
||||
message: text("message").notNull(),
|
||||
// Free-form structured detail: the failed request (path/method/status/body), the
|
||||
// error name, component, anything the caller attaches. Kept in one JSON column.
|
||||
context: text("context", { mode: "json" }).$type<Record<string, unknown>>(),
|
||||
// Pulled out of context for cheap filtering of the common "failed request" case.
|
||||
httpStatus: integer("http_status"),
|
||||
path: text("path"),
|
||||
// Captured stack trace, when there is one (uncaught errors / rejections).
|
||||
stack: text("stack"),
|
||||
// Who was logged in when it happened (frontend) / acted (backend), if known.
|
||||
userId: text("user_id"),
|
||||
// The browser/user-agent for a frontend log (triage which booth/device).
|
||||
userAgent: text("user_agent"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
export type UserRow = typeof users.$inferSelect;
|
||||
export type RoleRow = typeof roles.$inferSelect;
|
||||
export type RolePermissionRow = typeof rolePermissions.$inferSelect;
|
||||
@@ -372,3 +405,4 @@ export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSel
|
||||
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
export type AppLogRow = typeof appLogs.$inferSelect;
|
||||
|
||||
@@ -26,6 +26,7 @@ export const RESOURCES = [
|
||||
"session", // active sessions, lookup
|
||||
"event", // the signed ledger feed + void
|
||||
"report", // events feed, occupancy, future reports
|
||||
"log", // application/diagnostic logs (app_logs) — view + retention
|
||||
] as const;
|
||||
export type Resource = (typeof RESOURCES)[number];
|
||||
|
||||
@@ -51,6 +52,7 @@ export const PERMISSIONS: readonly Permission[] = [
|
||||
"session:read",
|
||||
"event:read", "event:void",
|
||||
"report:read",
|
||||
"log:read",
|
||||
] as const;
|
||||
|
||||
/** The protected built-in role: non-deletable, non-editable, always = ALL
|
||||
@@ -260,6 +262,52 @@ export function reasonPayload(
|
||||
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
|
||||
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
|
||||
|
||||
/**
|
||||
* Application/diagnostic logs — a THIRD unsigned, prunable stream (app_logs), distinct
|
||||
* from the signed ledger and from device telemetry. Backend warn+ and frontend errors
|
||||
* land here so a booth problem is queryable in one place. See
|
||||
* wiki/concepts/app-logs.md, decisions/event-streams-split.md.
|
||||
*/
|
||||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||||
export type LogSource = "frontend" | "backend";
|
||||
|
||||
/** A persisted log record (the read shape returned by GET /api/logs). */
|
||||
export interface AppLogRecord {
|
||||
readonly id: string;
|
||||
readonly level: LogLevel;
|
||||
readonly source: LogSource;
|
||||
readonly message: string;
|
||||
readonly context: Record<string, unknown> | null;
|
||||
readonly httpStatus: number | null;
|
||||
readonly path: string | null;
|
||||
readonly stack: string | null;
|
||||
readonly userId: string | null;
|
||||
readonly userAgent: string | null;
|
||||
readonly createdAt: string;
|
||||
}
|
||||
|
||||
/** One log entry POSTed by the frontend to /api/logs (server stamps id/userId/time). */
|
||||
export interface ClientLogInput {
|
||||
readonly level: LogLevel;
|
||||
readonly message: string;
|
||||
readonly context?: Record<string, unknown> | null;
|
||||
readonly httpStatus?: number | null;
|
||||
readonly path?: string | null;
|
||||
readonly stack?: string | null;
|
||||
/** Client-side capture time (ISO). The server records its own receive time too. */
|
||||
readonly at?: string;
|
||||
}
|
||||
|
||||
/** The numeric ordering of levels (pino-compatible), for threshold comparisons. */
|
||||
export const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
trace: 10,
|
||||
debug: 20,
|
||||
info: 30,
|
||||
warn: 40,
|
||||
error: 50,
|
||||
fatal: 60,
|
||||
};
|
||||
|
||||
/**
|
||||
* The composable rate card stored in a tariff_version.structure.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user