// The client log (E22 S4): the browser half of the ONE event shape `backend/app/logs.py` defines. // // Every event is `{ts, level, sev, source: "web", event, msg, attrs, request_id?, tab_id, version}` — // the same fields and level names as a server line, so a client batch can land in `log_events` beside // the server's own rows and a request joins across the two by `request_id`. // // Events are kept in memory only, in a bounded ring: nothing leaves the browser from here. (Sending a // batch is the diagnostics switch's job, and only with the user's consent — E22 S5.) // // REDACTION HAPPENS HERE, at capture, not at the call site — the same rules as the server's handler, // held to them by a fixture both test suites run (`backend/tests/fixtures/log_redaction_cases.json`). // A secret cannot leak by forgetting to scrub it at one call site, and a new call site is safe by // default. Never kept, at any level: passwords, tokens, cookies, secret config, E2EE material, email // addresses. What a level ADDS (filter values at DEBUG, search strings at TRACE) is decided by the // call site asking `enabled(level)` before it builds the attribute. // // Dependency-free on purpose (only the build stamp): the request layer, the notification store and // the error boundary all log through here, so it must not import any of them. import { FRONTEND_VERSION } from "./version"; export type Level = "TRACE" | "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL"; /** OpenTelemetry severity numbers — the same table as `logs.SEVERITY` (a contract test compares). */ export const SEVERITY: Record = { TRACE: 1, DEBUG: 5, INFO: 9, WARNING: 13, ERROR: 17, CRITICAL: 21, }; export interface LogEvent { ts: string; level: Level; sev: number; source: "web"; event: string; msg: string; attrs?: Record; request_id?: string; tab_id: string; version: string; } /** Events kept. At ~0.5 KB each the ring stays around a megabyte — a long session's worth of * navigation and requests, without growing for as long as a tab stays open. */ export const RING_MAX = 2000; // --- redaction (mirrors backend/app/logs.py) ------------------------------------------------------ // Substring matching on the KEY: `password`, `current_password`, `plex_server_token`, … — a key we // have never seen is safe by default. const SECRET_KEY_PARTS = [ "password", "passphrase", "token", "secret", "cookie", "authorization", "auth_header", "ciphertext", "private_key", "api_key", "apikey", "credential", "session_key", ]; // Credential-bearing URL parameters (OAuth `code`/`state`, `?token=` links, `#verify=`/`#reset=`). const SECRET_PARAM_RE = /\b((?:access_token|refresh_token|id_token|token|code|state|key|secret|password|pwd|sig|signature|verify|reset|setup)=)([^&\s"']+)/gi; // Deliberately loose, as on the server: over-matching is harmless, under-matching is not. const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g; const REDACTED = "[redacted]"; const REDACTED_EMAIL = "[email]"; /** Scrub free text: credential-bearing URL params and email addresses. */ function redactText(text: string): string { return text.replace(SECRET_PARAM_RE, `$1${REDACTED}`).replace(EMAIL_RE, REDACTED_EMAIL); } /** Recursively scrub a structure destined for the log. A value whose KEY names a secret is dropped * whole — a prefix of a token is still a leak. Everything else is scrubbed as text. */ export function redact(value: unknown, key = ""): unknown { const lowered = key.toLowerCase(); if (key && SECRET_KEY_PARTS.some((part) => lowered.includes(part))) return REDACTED; if (Array.isArray(value)) return value.map((v) => redact(v, key)); if (typeof value === "string") return redactText(value); if (value && typeof value === "object") { return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redact(v, k)])); } return value; } // --- identity ------------------------------------------------------------------------------------- function randomHex(bytes: number): string { const buf = new Uint8Array(bytes); crypto.getRandomValues(buf); return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join(""); } /** An id for one outgoing request, sent as `X-Request-Id`. The server accepts it (it matches * `logs._REQUEST_ID_RE`) and stamps its own line with it, which is what joins the two logs. */ export function newRequestId(): string { return randomHex(8); } // Per TAB, not per page load: a reload loop is one tab's story, and sessionStorage is exactly that // scope. Storage that throws (a locked-down browser) just means a fresh id per load. const TAB_KEY = "siftlode.logTab"; function tabId(): string { try { const known = sessionStorage.getItem(TAB_KEY); if (known) return known; const id = randomHex(6); sessionStorage.setItem(TAB_KEY, id); return id; } catch { return randomHex(6); } } const TAB_ID = tabId(); // --- the logger ----------------------------------------------------------------------------------- let threshold: Level = "INFO"; let ring: LogEvent[] = []; /** The capture level. INFO is the always-on "Alap"; DEBUG and TRACE come from the user's consented * diagnostics level (E22 S5). */ export function setLevel(level: Level): void { threshold = level; } /** Would an event at `level` be kept? Ask before building an attribute that costs something or that * only a higher level may carry (a search string is TRACE-only). */ export function enabled(level: Level): boolean { return SEVERITY[level] >= SEVERITY[threshold]; } /** The kept events, oldest first (a copy). */ export function events(): LogEvent[] { return ring.slice(); } /** Drop every kept event (tests; later the viewer's "clear"). */ export function clear(): void { ring = []; } function emit( level: Level, event: string, msg: string, attrs?: Record, requestId?: string, ): void { if (!enabled(level)) return; try { const entry: LogEvent = { ts: new Date().toISOString(), level, sev: SEVERITY[level], source: "web", event, msg: redactText(msg), tab_id: TAB_ID, version: FRONTEND_VERSION, }; if (attrs && Object.keys(attrs).length) entry.attrs = redact(attrs) as Record; if (requestId) entry.request_id = requestId; ring.push(entry); if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX); } catch { // Logging must never break the code that logs (an attr with a throwing getter, a cycle). } } type Emit = (event: string, msg: string, attrs?: Record) => void; /** `log.info("nav.view", "page opened", {page})` — a stable dotted event code, a short English * message for developers, structured attributes. */ export const log: Record<"trace" | "debug" | "info" | "warn" | "error" | "fatal", Emit> & { at: (level: Level, event: string, msg: string, attrs?: Record) => void; request: (level: Level, msg: string, attrs: Record, requestId: string) => void; } = { trace: (e, m, a) => emit("TRACE", e, m, a), debug: (e, m, a) => emit("DEBUG", e, m, a), info: (e, m, a) => emit("INFO", e, m, a), warn: (e, m, a) => emit("WARNING", e, m, a), error: (e, m, a) => emit("ERROR", e, m, a), fatal: (e, m, a) => emit("CRITICAL", e, m, a), /** At a level decided at run time (a notification's, a status code's). */ at: (level, e, m, a) => emit(level, e, m, a), /** One `http.request` event, carrying the request id as the correlation field. */ request: (level, msg, attrs, requestId) => emit(level, "http.request", msg, attrs, requestId), }; /** An error as loggable attributes: its kind, message and stack — redacted like everything else. */ export function errorAttrs(err: unknown): Record { if (err instanceof Error) { return { error: err.name, message: err.message, ...(err.stack ? { stack: err.stack } : {}) }; } return { error: typeof err, message: String(err) }; }