A tab names the account it was claimed for on every request, beacon and upload (X-Siftlode-Expect-Account); current_user and /auth/logout refuse a mismatch with 401 account_changed and keep the session, so a tab left showing the account signed in before reloads instead of writing to the new one. /auth/login clears a connect marker left by an abandoned connect, and a link completes only on a session that still holds. The prefs merge reads the row under a lock. A tab's sessionStorage records its own owner. A page the account loses is given up, address bar and remembered page included. The panel layouts are preferences; the storage contract also refuses a key handed to the Storage API or to usePref outside its kind. A refused sign-in as someone else and a failed sign-out say so. The chromium-only e2e specs are in the gate register, the member walk waits for its requests, and devices.spec checks the save itself.
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
// The client log: 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;
|
|
// 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, ciphertext, 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.
|
|
//
|
|
// Nearly dependency-free on purpose (the build stamp and the storage key registry, which import
|
|
// nothing of the app at runtime): the request layer, the notification store and the error boundary
|
|
// all log through here, so it must not import any of them, not even through another module
|
|
// (logImports.contract.test.ts follows the runtime imports).
|
|
import { LS } from "./storage";
|
|
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. */
|
|
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 = LS.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, 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. */
|
|
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) };
|
|
}
|