Files
siftlode/frontend/src/lib/log.ts
T
peter a210992bfc feat: client log with request ids, joined to the server's
The browser now keeps its half of the one event shape logs.py defines:
lib/log.ts is a levelled, redacted, bounded (2000 events) in-memory ring
with the server's level names and severity numbers. Nothing leaves the
browser yet; sending is the consented diagnostics level (S5).

- lib/http.ts `send` is the one fetch: every request carries an
  X-Request-Id and becomes one http.request event. The server echoes the
  matched route template in X-Route (only for a matched route, so a raw
  path with a value in it is never sent back); without one the client
  templates the path itself. req, beacon, the upload XHR, the public
  watch page and the legal pages all go through it.
- Hooks: app start snapshot, uncaught errors and rejections, History
  push/replace/popstate (a scroll-only replace is not an event; parameter
  values only at DEBUG, typed search only at TRACE), notifications, the
  error dialog, the crash boundary, failed queries and mutations (key
  head only), and the messages socket (lifecycle, frame types at DEBUG).
- Redaction is one contract: backend/tests/fixtures/log_redaction_cases.json
  runs against both redact() implementations.
- Lint: fetch, XMLHttpRequest and WebSocket each belong to one logging
  module, console to none; a contract test proves the rules fire.

Fixes a socket takeover: the close of a socket released by the last
subscriber cleared the newer socket a quick re-subscribe had opened and
scheduled a third, leaving two live sockets that delivered every message
twice (measured in Chromium: 3 created / 2 live before, 2 / 1 after).
2026-09-17 01:44:29 +02:00

207 lines
7.9 KiB
TypeScript

// 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<Level, number> = {
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<string, unknown>;
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<string, unknown>,
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<string, unknown>;
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<string, unknown>) => 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<string, unknown>) => void;
request: (level: Level, msg: string, attrs: Record<string, unknown>, 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<string, unknown> {
if (err instanceof Error) {
return { error: err.name, message: err.message, ...(err.stack ? { stack: err.stack } : {}) };
}
return { error: typeof err, message: String(err) };
}