// 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 // them is `lib/diagnostics.ts`'s job, and only while the user's consented level is raised (E22 S5); // it and the Settings log viewer follow the ring through `subscribe`. // // 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, }; /** How a log viewer groups the six levels into its filter chips — "Saját napló" and the admin * console both read a log through these, so a level lands in the same chip on either screen. */ export type LevelGroup = "all" | "error" | "warning" | "info" | "detail"; export const LEVEL_GROUPS: Record, readonly Level[]> = { error: ["ERROR", "CRITICAL"], warning: ["WARNING"], info: ["INFO"], detail: ["DEBUG", "TRACE"], }; 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; /** The debug session recording when the event was kept (E16). */ debug_session_id?: 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 debug session this tab is recording into (E16), or null. Held HERE, the lowest layer, because // two things below everything else need it: every kept event carries it, and `api/core` puts it on // every request as `X-Debug-Session` — which is how the server tags its own lines of that request. let debugSession: string | null = null; /** Record into `id` from now on (null = stop). `lib/diagnostics` sets it from `me.debug_session`. */ export function setDebugSession(id: string | null): void { debugSession = id; } /** The debug session recording right now, if any. */ export function currentDebugSession(): string | null { return debugSession; } /** 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 (the viewer's "clear", tests). */ export function clear(): void { ring = []; notify(null); } // Told about every kept event as it is kept (`null` = the ring was cleared). A listener that throws // is its own problem: it must not stop the others, nor the code that logged. type Listener = (event: LogEvent | null) => void; const listeners = new Set(); /** Follow the ring: called with each event once it is kept, and with `null` when it is cleared. * Returns the unsubscribe. */ export function subscribe(listener: Listener): () => void { listeners.add(listener); return () => { listeners.delete(listener); }; } function notify(event: LogEvent | null): void { for (const listener of listeners) { try { listener(event); } catch { // see Listener } } } 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; if (debugSession) entry.debug_session_id = debugSession; ring.push(entry); if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX); notify(entry); } 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) }; }