- 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'
262 lines
10 KiB
TypeScript
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) };
|
|
}
|