Files
siftlode/frontend/src/lib/log.ts
T
peter 46fce4a767 feat(diagnostics): the debug recorder — start, the pill, steps, screenshots, stop (E16 S2)
- the start dialog is the consent: level (Részletes/Teljes), what is recorded,
  retention and the cap (now served as diagnostics.session_minutes)
- the pill docks under the toasts: elapsed time, Step (an optional label,
  its own Escape layer), Screenshot (tab capture where the browser has it),
  Stop; compact on a phone
- while recording, lib/log tags every event and every request path (req,
  beacon, uploadReq via one identityHeaders) names the session; applyDiagnostics
  raises the level for the session's run and never lowers a raised one, and
  lapses at whichever ends first
- Stop sends what is waiting before the server closes the session; the cap
  says so and opens the composer
- Settings → Diagnosztika and the command palette start/step/stop it
- the evidence leaves the log upload's own request lines out
- the step shortcut is deliberately palette-only: the keymap's D1 rule bars a
  new modifier chord, and a bare letter collides with the players'
2026-09-19 22:08:10 +02:00

262 lines
10 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
// 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<Level, number> = {
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<Exclude<LevelGroup, "all">, 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<string, unknown>;
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<Listener>();
/** 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<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;
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<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) };
}