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).
This commit is contained in:
2026-09-17 01:44:29 +02:00
parent 0517de1f59
commit a210992bfc
24 changed files with 1207 additions and 34 deletions
+2
View File
@@ -95,6 +95,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
| FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` | | FE control | the chip/segmented primitives (`Select` only for >6 options) | a native `<select>` |
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals | | FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached | | FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
| FE network call | `req`/`uploadReq`/`beacon`, or `lib/http.ts` `send` (public pages) | a bare `fetch`/`XMLHttpRequest`/`WebSocket` (lint-enforced) |
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`) | `console.*` (lint-enforced) |
## House rules that bite (learned from real bugs) ## House rules that bite (learned from real bugs)
+16 -5
View File
@@ -391,7 +391,7 @@ async def log_requests(request, call_next):
What changes versus `uvicorn.access`: the ROUTE TEMPLATE instead of the concrete path (so What changes versus `uvicorn.access`: the ROUTE TEMPLATE instead of the concrete path (so
`/api/videos/{video_id}` groups), never the query string (it carried `?account=`, and at TRACE `/api/videos/{video_id}` groups), never the query string (it carried `?account=`, and at TRACE
the redacted query belongs in attrs, not in the URL), a request id echoed back in the redacted query belongs in attrs, not in the URL), a request id echoed back in
`X-Request-Id`, the duration, and the account — read from `request.state`, which the auth `X-Request-Id` (with the template in `X-Route`), the duration, and the account — read from `request.state`, which the auth
dependency fills, because this middleware sits outside both the session middleware and the dependency fills, because this middleware sits outside both the session middleware and the
downstream task's context.""" downstream task's context."""
incoming = request.headers.get("x-request-id", "") incoming = request.headers.get("x-request-id", "")
@@ -446,6 +446,13 @@ async def log_requests(request, call_next):
}, },
) )
response.headers["X-Request-Id"] = rid response.headers["X-Request-Id"] = rid
# The template, so the browser's copy of this line groups the same way (frontend lib/http.ts).
# Only a MATCHED route's: a template never carries a path parameter's value (a share token stays
# `{token}`), while the raw-path fallback of an unmatched request could — the browser then
# templates the path itself.
matched = _matched_route(request)
if matched:
response.headers["X-Route"] = matched
_request_id.reset(token) _request_id.reset(token)
return response return response
@@ -471,8 +478,12 @@ def _request_owner(request, level: int) -> dict[str, Any]:
return {} if _anonymous_lines.allow(client_key(request)) else {PERSIST: False} return {} if _anonymous_lines.allow(client_key(request)) else {PERSIST: False}
def _matched_route(request) -> str | None:
"""The matched route's template (`/api/videos/{video_id}`), or None when nothing matched."""
return getattr(request.scope.get("route"), "path", None)
def _route_template(request) -> str: def _route_template(request) -> str:
"""The matched route's template (`/api/videos/{video_id}`), else the raw path for a 404 or a """The matched route's template, else the raw path for a 404 or a static file. Never the query
static file. Never the query string.""" string."""
route = request.scope.get("route") return _matched_route(request) or request.url.path
return getattr(route, "path", None) or request.url.path
+92
View File
@@ -0,0 +1,92 @@
{
"_comment": "One redaction contract for both logs: backend/app/logs.py redact() and frontend/src/lib/log.ts redact() must turn every `value` (under `key`) into `expected`. Both test suites run this file (backend tests/test_logs.py, frontend src/lib/log.contract.test.ts), so the two implementations cannot drift apart. Some cases prove a secret goes; others prove ordinary content (a search term, a Plex title, a chat line) is not mangled — what a LEVEL may carry is decided at the call site, not here.",
"cases": [
{
"name": "a password field is dropped whole",
"key": "",
"value": {
"email": "a@b.co",
"password": "hunter2",
"current_password": "x"
},
"expected": {
"email": "[email]",
"password": "[redacted]",
"current_password": "[redacted]"
}
},
{
"name": "a token under any spelling of the key, nested and in lists",
"key": "",
"value": {
"plex": { "plex_server_token": "abc", "Authorization": "Bearer z" },
"items": [{ "access_token": "t1" }, { "id": 3 }]
},
"expected": {
"plex": {
"plex_server_token": "[redacted]",
"Authorization": "[redacted]"
},
"items": [{ "access_token": "[redacted]" }, { "id": 3 }]
}
},
{
"name": "E2EE material and cookies",
"key": "",
"value": {
"ciphertext": "AAAA",
"passphrase": "p",
"private_key": "k",
"cookies": "SID=1"
},
"expected": {
"ciphertext": "[redacted]",
"passphrase": "[redacted]",
"private_key": "[redacted]",
"cookies": "[redacted]"
}
},
{
"name": "a reset link and a verify fragment",
"key": "msg",
"value": "open https://app.example/#reset=QWErty123 or /?verify=abc&lang=hu",
"expected": "open https://app.example/#reset=[redacted] or /?verify=[redacted]&lang=hu"
},
{
"name": "an OAuth callback URL",
"key": "url",
"value": "/auth/callback?code=4/0Ab-xyz&state=s3cr3t&scope=email",
"expected": "/auth/callback?code=[redacted]&state=[redacted]&scope=email"
},
{
"name": "an email address inside a sentence",
"key": "message",
"value": "Invitation sent to Some.One+tag@mail.example.org.",
"expected": "Invitation sent to [email]."
},
{
"name": "a search term is kept (TRACE decides whether it is logged at all)",
"key": "q",
"value": "lo-fi beats to relax",
"expected": "lo-fi beats to relax"
},
{
"name": "a Plex title with an at-sign is not an address",
"key": "title",
"value": "Live @ Wembley 1986",
"expected": "Live @ Wembley 1986"
},
{
"name": "a chat line is text like any other; its only secret part is scrubbed",
"key": "body",
"value": "see you at 8, my mail is me@home.example",
"expected": "see you at 8, my mail is [email]"
},
{
"name": "non-text values pass through",
"key": "",
"value": { "status": 404, "ok": false, "ratio": 0.5, "none": null },
"expected": { "status": 404, "ok": false, "ratio": 0.5, "none": null }
}
]
}
+21
View File
@@ -11,6 +11,7 @@ Pure logic + an in-process ASGI app: no database.
import json import json
import logging import logging
from pathlib import Path
import pytest import pytest
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@@ -101,6 +102,18 @@ def test_an_email_in_an_attribute_value_is_scrubbed_too():
assert logs.redact({"to": "a.b+tag@mail.example.org"})["to"] == logs.REDACTED_EMAIL assert logs.redact({"to": "a.b+tag@mail.example.org"})["to"] == logs.REDACTED_EMAIL
# The SAME cases frontend/src/lib/log.redaction.test.ts runs against the browser's copy of redact():
# the one thing that keeps the two logs' idea of "safe to keep" from drifting apart.
_REDACTION_CASES = json.loads(
(Path(__file__).parent / "fixtures" / "log_redaction_cases.json").read_text(encoding="utf-8")
)["cases"]
@pytest.mark.parametrize("case", _REDACTION_CASES, ids=[c["name"] for c in _REDACTION_CASES])
def test_the_shared_redaction_cases(case):
assert logs.redact(case["value"], key=case["key"]) == case["expected"]
def test_redaction_runs_on_the_message_the_attrs_and_the_traceback(sink): def test_redaction_runs_on_the_message_the_attrs_and_the_traceback(sink):
log = logging.getLogger("siftlode.test") log = logging.getLogger("siftlode.test")
try: try:
@@ -263,6 +276,14 @@ def test_the_request_id_is_echoed_and_shared_by_every_line_of_that_request(sink)
assert rid and sink.objects()[-1]["request_id"] == rid assert rid and sink.objects()[-1]["request_id"] == rid
def test_the_route_template_goes_back_to_the_browser_but_never_a_raw_path(sink):
# The browser logs its own copy of each request, grouped by this template (frontend lib/http.ts).
client = _client()
assert client.get("/api/videos/SeCrEt-VaLuE").headers["X-Route"] == "/api/videos/{video_id}"
# Unmatched: the only template there is the raw path, which may carry a value — so none is sent.
assert "X-Route" not in client.get("/api/share/SeCrEt-VaLuE").headers
def test_a_client_supplied_id_is_reused_only_when_it_is_well_formed(sink): def test_a_client_supplied_id_is_reused_only_when_it_is_well_formed(sink):
client = _client() client = _client()
good = client.get("/api/videos/x", headers={"X-Request-Id": "web-0123456789ab"}) good = client.get("/api/videos/x", headers={"X-Request-Id": "web-0123456789ab"})
+47
View File
@@ -14,6 +14,36 @@ import globals from "globals";
// //
// reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself // reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself
// an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate). // an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate).
// E22 S4: every way out of the browser goes through the one module that LOGS it — a request line
// with a request id (lib/http.ts), the upload XHR (lib/api/core.ts), the messages socket
// (lib/messagesSocket.ts). A bare `fetch` elsewhere is a request the client log never sees, which is
// exactly how the baseline survey found four. Each owner is exempt from its OWN global only.
const NETWORK_OWNERS = {
fetch: "src/lib/http.ts",
XMLHttpRequest: "src/lib/api/core.ts",
WebSocket: "src/lib/messagesSocket.ts",
EventSource: null,
};
const networkGlobals = (exempt) =>
Object.entries(NETWORK_OWNERS)
.filter(([name]) => name !== exempt)
.map(([name, owner]) => ({
name,
message: owner
? `Use the logged request layer: \`${name}\` belongs to ${owner} only (E22 S4 — a request outside it never reaches the client log).`
: `No \`${name}\` without a logged owner module (E22 S4).`,
}));
const networkProperties = [
{ object: "window", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
{ object: "globalThis", property: "fetch", message: "Use lib/http.ts `send` (E22 S4)." },
{
object: "navigator",
property: "sendBeacon",
message: "Use lib/api/core.ts `beacon` (E22 S4).",
},
];
export default tseslint.config( export default tseslint.config(
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] }, { ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
{ {
@@ -42,6 +72,11 @@ export default tseslint.config(
// types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from // types; i18n `t` props → TFunction), so the rule is now enforced to keep new ones from
// creeping back in. Prefer `unknown` + narrowing, or a typed boundary, over `any`. // creeping back in. Prefer `unknown` + narrowing, or a typed boundary, over `any`.
"@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-explicit-any": "error",
// E22 S4: the client log (lib/log.ts) is the one place diagnostics go — redacted, levelled,
// bounded. A `console.*` call is none of those, and it is invisible to the user's own log.
"no-console": "error",
"no-restricted-globals": ["error", ...networkGlobals(null)],
"no-restricted-properties": ["error", ...networkProperties],
// Side-effect ternaries/short-circuits are an established idiom here (`cond ? a() : b()`, // Side-effect ternaries/short-circuits are an established idiom here (`cond ? a() : b()`,
// `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else. // `v.paused ? v.play() : v.pause()`) — allow them rather than rewrite working code to if/else.
"@typescript-eslint/no-unused-expressions": [ "@typescript-eslint/no-unused-expressions": [
@@ -131,5 +166,17 @@ export default tseslint.config(
], ],
}, },
}, },
// The network owners (see NETWORK_OWNERS), each exempt from its own global only.
...Object.entries(NETWORK_OWNERS)
.filter(([, owner]) => owner)
.map(([name, owner]) => ({
files: [owner],
rules: { "no-restricted-globals": ["error", ...networkGlobals(name)] },
})),
{
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
files: ["src/**/*.test.{ts,tsx}"],
rules: { "no-restricted-globals": "off", "no-restricted-properties": "off" },
},
prettier, prettier,
); );
+7 -2
View File
@@ -1,6 +1,7 @@
import { Component, type ReactNode } from "react"; import { Component, type ErrorInfo, type ReactNode } from "react";
import i18n from "../i18n"; import i18n from "../i18n";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
import { errorAttrs, log } from "../lib/log";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
@@ -18,7 +19,11 @@ export default class ErrorBoundary extends Component<Props, State> {
return { crashed: true }; return { crashed: true };
} }
componentDidCatch(error: Error) { componentDidCatch(error: Error, info: ErrorInfo) {
log.fatal("ui.crash", "the app crashed while rendering", {
...errorAttrs(error),
...(info.componentStack ? { component_stack: info.componentStack } : {}),
});
notify({ notify({
level: "fatal", level: "fatal",
title: i18n.t("errors.boundary.notifTitle"), title: i18n.t("errors.boundary.notifTitle"),
+3 -2
View File
@@ -4,6 +4,7 @@ import { Button } from "./ui/Button";
import { formatBytes } from "../lib/format"; import { formatBytes } from "../lib/format";
import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay"; import { exposeVideoToBackgroundPlay } from "../lib/browserBackgroundPlay";
import { LS, readJSON, writeJSON } from "../lib/storage"; import { LS, readJSON, writeJSON } from "../lib/storage";
import { send } from "../lib/http";
// Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app // Standalone, login-free video page for a shared /watch/<token> link. Rendered OUTSIDE the app
// tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny // tree (no auth, no providers) — like the /privacy and /terms pages. Uses plain fetch and a tiny
@@ -169,7 +170,7 @@ export default function WatchPage() {
setStatus("gone"); setStatus("gone");
return; return;
} }
fetch(`/api/public/watch/${encodeURIComponent(token)}`) send(`/api/public/watch/${encodeURIComponent(token)}`)
.then((r) => { .then((r) => {
// Only a 404 says the link is gone (unknown, expired, revoked, or its file removed). Anything // Only a 404 says the link is gone (unknown, expired, revoked, or its file removed). Anything
// else — a 5xx while the server restarts, a dropped connection — is a failure worth retrying, // else — a 5xx while the server restarts, a dropped connection — is a failure worth retrying,
@@ -197,7 +198,7 @@ export default function WatchPage() {
setUnlocking(true); setUnlocking(true);
setError(null); setError(null);
try { try {
const r = await fetch(`/api/public/watch/${encodeURIComponent(token)}/unlock`, { const r = await send(`/api/public/watch/${encodeURIComponent(token)}/unlock`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }), body: JSON.stringify({ password }),
@@ -0,0 +1,82 @@
// @vitest-environment jsdom
//
// E22 S4: what the user was SHOWN — a toast, the blocking error dialog, the crash screen — and what
// failed behind the scenes (a query, a mutation) all land in the client log. Each producer is its
// own module, so each is asserted here through the real thing, not a mock of it.
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { clear, events } from "../lib/log";
import { notify } from "../lib/notifications";
import { reportError } from "../lib/errorDialog";
import { createQueryClient } from "../lib/queryClient";
import ErrorBoundary from "./ErrorBoundary";
import { HttpError } from "../lib/api/core";
afterEach(() => {
cleanup();
clear();
vi.restoreAllMocks();
});
const logged = (code: string) => events().filter((e) => e.event === code);
describe("client log wiring", () => {
it("records every notification at its level", () => {
notify({ level: "success", title: "Saved", message: "Playlist renamed" });
notify({ level: "error", message: "Could not reach someone@mail.example" });
expect(logged("ui.notify").map((e) => [e.level, e.msg, e.attrs])).toEqual([
["INFO", "Playlist renamed", { level: "success", title: "Saved" }],
["ERROR", "Could not reach [email]", { level: "error" }],
]);
});
it("records the blocking error dialog", () => {
reportError("The server refused", "Couldn't save");
expect(logged("ui.error_dialog")).toMatchObject([
{ level: "ERROR", msg: "The server refused", attrs: { title: "Couldn't save" } },
]);
});
it("records a failed query by its resource only — the rest of the key is filter state", async () => {
const client = createQueryClient();
await client
.fetchQuery({
queryKey: ["videos", { tags: ["private-tag"] }],
queryFn: () => Promise.reject(new HttpError(503, "down")),
})
.catch(() => {});
expect(logged("query.failed")).toMatchObject([
{ level: "WARNING", attrs: { key: "videos", status: 503, message: "down" } },
]);
expect(JSON.stringify(events())).not.toContain("private-tag");
});
it("records a failed mutation", async () => {
const client = createQueryClient();
const mutation = client.getMutationCache().build(client, {
mutationKey: ["playlists", 42],
mutationFn: () => Promise.reject(new HttpError(409, "conflict")),
meta: { errorShownInline: true },
});
await mutation.execute(undefined).catch(() => {});
expect(logged("mutation.failed")).toMatchObject([
{ level: "WARNING", attrs: { key: "playlists", status: 409 } },
]);
});
it("records a render crash with its component stack", () => {
vi.spyOn(console, "error").mockImplementation(() => {}); // React reports the crash itself too
const Boom = () => {
throw new Error("render exploded");
};
render(
<ErrorBoundary>
<Boom />
</ErrorBoundary>,
);
const [crash] = logged("ui.crash");
expect(crash).toMatchObject({ level: "CRITICAL", attrs: { message: "render exploded" } });
expect(String(crash!.attrs!.component_stack)).toContain("Boom");
});
});
@@ -1,8 +1,9 @@
// Shared shell for the public, login-free legal pages (/privacy, /terms). These must be // Shared shell for the public, login-free legal pages (/privacy, /terms). These must be
// reachable unauthenticated — Google's OAuth consent screen links to them and reviewers // reachable unauthenticated — Google's OAuth consent screen links to them and reviewers
// fetch them directly — so they render outside the authenticated App tree (see main.tsx), // fetch them directly — so they render outside the authenticated App tree (see main.tsx),
// with no react-query provider: the operator contact is fetched with a plain fetch below. // with no react-query provider: the operator contact is fetched with a bare `send` below.
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { send } from "../../lib/http";
const LAST_UPDATED = "June 14, 2026"; const LAST_UPDATED = "June 14, 2026";
@@ -11,7 +12,7 @@ const LAST_UPDATED = "June 14, 2026";
let contactPromise: Promise<string | null> | null = null; let contactPromise: Promise<string | null> | null = null;
function fetchOperatorContact(): Promise<string | null> { function fetchOperatorContact(): Promise<string | null> {
if (!contactPromise) { if (!contactPromise) {
contactPromise = fetch("/auth/config") contactPromise = send("/auth/config")
.then((r) => (r.ok ? r.json() : null)) .then((r) => (r.ok ? r.json() : null))
.then((c) => (c?.operator_contact as string | undefined) ?? null) .then((c) => (c?.operator_contact as string | undefined) ?? null)
.catch(() => null); .catch(() => null);
+29 -11
View File
@@ -7,6 +7,8 @@ import i18n from "../../i18n";
import { formatBytes } from "../format"; import { formatBytes } from "../format";
import { reportError } from "../errorDialog"; import { reportError } from "../errorDialog";
import { activeAccountId, pinActiveAccount } from "../activeAccount"; import { activeAccountId, pinActiveAccount } from "../activeAccount";
import { logRequest, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { newRequestId } from "../log";
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB"). /** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by * Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
@@ -247,7 +249,7 @@ export const getActiveAccount = activeAccountId;
export function beacon(url: string, body: Record<string, unknown>): void { export function beacon(url: string, body: Record<string, unknown>): void {
const active = getActiveAccount(); const active = getActiveAccount();
try { try {
void fetch(url, { void send(url, {
method: "POST", method: "POST",
keepalive: true, keepalive: true,
credentials: "include", credentials: "include",
@@ -277,16 +279,20 @@ export async function req<T = unknown>(
let r: Response; let r: Response;
try { try {
const active = getActiveAccount(); const active = getActiveAccount();
r = await fetch(url, { r = await send(
credentials: "include", url,
headers: { {
"Content-Type": "application/json", credentials: "include",
// Per-tab identity override (only setup calls pass their own headers, and those are headers: {
// pre-auth, so this is safe to put in the defaults that `...opts` may replace). "Content-Type": "application/json",
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), // Per-tab identity override (only setup calls pass their own headers, and those are
// pre-auth, so this is safe to put in the defaults that `...opts` may replace).
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
...opts,
}, },
...opts, attempt ? { attempt: attempt + 1 } : {},
}); );
} catch (e) { } catch (e) {
// Network-level failure (incl. a keepalive connection reset surfacing as a TypeError). // Network-level failure (incl. a keepalive connection reset surfacing as a TypeError).
if (canRetry && attempt === 0) { if (canRetry && attempt === 0) {
@@ -349,6 +355,9 @@ export function uploadReq<T = unknown>(
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", url); xhr.open("POST", url);
xhr.withCredentials = true; xhr.withCredentials = true;
const requestId = newRequestId();
const started = performance.now();
xhr.setRequestHeader(REQUEST_ID_HEADER, requestId);
const active = getActiveAccount(); const active = getActiveAccount();
if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active)); if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active));
if (cfg.onProgress) { if (cfg.onProgress) {
@@ -361,6 +370,11 @@ export function uploadReq<T = unknown>(
cfg.signal.addEventListener("abort", () => xhr.abort()); cfg.signal.addEventListener("abort", () => xhr.abort());
} }
xhr.onload = () => { xhr.onload = () => {
logRequest(requestId, "POST", url, started, {
status: xhr.status,
// Test doubles may not implement it; a real XHR always does.
route: xhr.getResponseHeader?.(ROUTE_HEADER),
});
if (!RETRIABLE_GATEWAY.has(xhr.status)) markConnectivityRestored(); // as in `req` if (!RETRIABLE_GATEWAY.has(xhr.status)) markConnectivityRestored(); // as in `req`
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
try { try {
@@ -388,10 +402,14 @@ export function uploadReq<T = unknown>(
}; };
// A network-level failure (server unreachable) — same soft "connection lost" status as `req`. // A network-level failure (server unreachable) — same soft "connection lost" status as `req`.
xhr.onerror = () => { xhr.onerror = () => {
logRequest(requestId, "POST", url, started, { failed: "network" });
markConnectivityLost(); markConnectivityLost();
reject(new HttpError(0, i18n.t("errors.offline.body"))); reject(new HttpError(0, i18n.t("errors.offline.body")));
}; };
xhr.onabort = () => reject(new HttpError(0, ABORTED)); xhr.onabort = () => {
logRequest(requestId, "POST", url, started, { failed: "aborted" });
reject(new HttpError(0, ABORTED));
};
xhr.send(form); xhr.send(form);
}); });
} }
+5 -2
View File
@@ -1,5 +1,6 @@
import i18n from "../i18n"; import i18n from "../i18n";
import { createStore } from "./store"; import { createStore } from "./store";
import { log } from "./log";
// A single, self-explanatory error dialog (modal) the user must acknowledge — used when the // A single, self-explanatory error dialog (modal) the user must acknowledge — used when the
// server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts // server can't carry out an action (a definitive 4xx/5xx response). Distinct from toasts
@@ -13,10 +14,12 @@ interface AppError {
const store = createStore<AppError | null>(null); const store = createStore<AppError | null>(null);
export function reportError(message?: string, title?: string): void { export function reportError(message?: string, title?: string): void {
store.set({ const shown = {
title: title || i18n.t("errors.title"), title: title || i18n.t("errors.title"),
message: message || i18n.t("errors.generic"), message: message || i18n.t("errors.generic"),
}); };
log.error("ui.error_dialog", shown.message, { title: shown.title });
store.set(shown);
} }
export function dismissError(): void { export function dismissError(): void {
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { pathTemplate, send } from "./http";
import { clear, events } from "./log";
afterEach(() => {
clear();
vi.unstubAllGlobals();
});
const answer = (status: number, headers: Record<string, string> = {}) =>
new Response(status === 204 ? null : "{}", { status, headers });
describe("send", () => {
it("sends a request id and logs the answer under it, grouped by the server's template", async () => {
const fetchMock = vi.fn(async () => answer(200, { "X-Route": "/api/videos/{video_id}" }));
vi.stubGlobal("fetch", fetchMock);
await send("/api/videos/dQw4w9WgXcQ?x=1", { headers: { "Content-Type": "application/json" } });
const sent = new Headers(
(fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1].headers,
);
expect(sent.get("Content-Type")).toBe("application/json"); // the caller's headers survive
const [e] = events();
expect(e).toMatchObject({
level: "INFO",
event: "http.request",
request_id: sent.get("X-Request-Id"),
attrs: { method: "GET", path: "/api/videos/{video_id}", status: 200 },
});
expect(typeof e!.attrs!.duration_ms).toBe("number");
});
it("logs a refused request at the server's level for it", async () => {
vi.stubGlobal("fetch", async () => answer(404));
await send("/api/x");
vi.stubGlobal("fetch", async () => answer(503));
await send("/api/y", { method: "post" });
expect(events().map((e) => [e.level, e.attrs!.method, e.attrs!.status])).toEqual([
["WARNING", "GET", 404],
["ERROR", "POST", 503],
]);
});
it("logs a network failure without the concrete path, and still rejects", async () => {
vi.stubGlobal("fetch", async () => {
throw new TypeError("Failed to fetch");
});
await expect(send("/api/public/watch/Ab3_xYz9-token/unlock")).rejects.toThrow(TypeError);
expect(events()[0]).toMatchObject({
level: "WARNING",
attrs: { path: "/api/public/watch/{}/unlock", failed: "network" },
});
});
it("logs a cancelled request as a cancel, not a failure", async () => {
vi.stubGlobal("fetch", async () => {
throw new DOMException("aborted", "AbortError");
});
await expect(send("/api/x")).rejects.toThrow();
expect(events()[0]).toMatchObject({ level: "INFO", attrs: { failed: "aborted" } });
});
});
describe("pathTemplate", () => {
it("keeps plain words and replaces every value, dropping query and fragment", () => {
expect(pathTemplate("/api/channels/UCabc123/tags/7?q=secret#reset=x")).toBe(
"/api/channels/{}/tags/{}",
);
expect(pathTemplate("/api/plex/stream/42/hls/s1/index.m3u8")).toBe(
"/api/plex/stream/{}/hls/{}/{}",
);
expect(pathTemplate("/api/watch-later/playback_state")).toBe("/api/watch-later/playback_state");
});
});
+99
View File
@@ -0,0 +1,99 @@
// The ONE place the app calls `fetch` (lint-enforced: eslint.config.js). Every request gets an
// `X-Request-Id`, and every answer — or network failure — becomes one `http.request` event in the
// client log, the browser's copy of the server's access line with the same id.
//
// Deliberately nothing else: no error dialog, no connection notice, no account header. `req` and
// `beacon` (lib/api/core.ts) add the app's policy on top; the public pages (/watch, the legal pages)
// use this directly, because they render outside the app and must not pull its notification and
// dialog machinery into their chunks.
import { log, newRequestId, type Level } from "./log";
/** The route TEMPLATE the server matched (`/api/videos/{video_id}`), which it sends back in `X-Route`.
* Grouping by it is what makes a client line comparable, and it is safe by construction: a token in
* the path (`/api/public/watch/{token}`) arrives already replaced by its parameter name. */
export const ROUTE_HEADER = "X-Route";
export const REQUEST_ID_HEADER = "X-Request-Id";
// A static path word: kept. Anything else (an id, a channel id, a share token) becomes `{}`.
const STATIC_SEGMENT = /^[a-z]+(?:[-_.][a-z]+)*$/;
/** The path to log when the server named no template (a network failure, a gateway's own answer):
* the path alone, with every segment that is not a plain lowercase word replaced — never a query,
* never an id or a token. */
export function pathTemplate(url: string): string {
const path = url.split(/[?#]/, 1)[0] ?? "";
return path
.split("/")
.map((seg) => (seg === "" || STATIC_SEGMENT.test(seg) ? seg : "{}"))
.join("/");
}
/** Level of a request line — the server's own rule (`logs.log_requests`), so both sides agree on
* what a 404 is. */
function statusLevel(status: number): Level {
return status >= 500 ? "ERROR" : status >= 400 ? "WARNING" : "INFO";
}
/** Record one request's outcome. `route` is the server's template when it sent one. */
export function logRequest(
requestId: string,
method: string,
url: string,
started: number,
outcome: { status: number; route?: string | null } | { failed: "network" | "aborted" },
extra: Record<string, unknown> = {},
): void {
const duration_ms = Math.round((performance.now() - started) * 10) / 10;
if ("failed" in outcome) {
const path = pathTemplate(url);
log.request(
outcome.failed === "aborted" ? "INFO" : "WARNING",
`${method} ${path} ${outcome.failed}`,
{ method, path, failed: outcome.failed, duration_ms, ...extra },
requestId,
);
return;
}
const path = outcome.route || pathTemplate(url);
log.request(
statusLevel(outcome.status),
`${method} ${path} ${outcome.status}`,
{ method, path, status: outcome.status, duration_ms, ...extra },
requestId,
);
}
function headerOf(r: Response, name: string): string | null {
// Test doubles hand back plain objects; a real Response always has Headers.
return typeof r.headers?.get === "function" ? r.headers.get(name) : null;
}
/** `fetch`, with a request id and one log event. Rejects exactly as `fetch` does. */
export async function send(
url: string,
init: RequestInit = {},
extra: Record<string, unknown> = {},
): Promise<Response> {
const requestId = newRequestId();
const method = (init.method ?? "GET").toUpperCase();
const headers = new Headers(init.headers);
headers.set(REQUEST_ID_HEADER, requestId);
const started = performance.now();
let r: Response;
try {
r = await fetch(url, { ...init, headers });
} catch (e) {
const aborted = e instanceof DOMException && e.name === "AbortError";
logRequest(requestId, method, url, started, { failed: aborted ? "aborted" : "network" }, extra);
throw e;
}
logRequest(
requestId,
method,
url,
started,
{ status: r.status, route: headerOf(r, ROUTE_HEADER) },
extra,
);
return r;
}
+32
View File
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { redact, SEVERITY } from "./log";
// The browser log and the server log are ONE log (E22): a client batch lands in `log_events` beside
// the server's rows. So the two must agree on what is safe to keep and on what a level is worth —
// asserted against the backend's own files, not a copy of them.
const backend = (path: string) =>
readFileSync(new URL(`../../../backend/${path}`, import.meta.url), "utf-8");
const { cases } = JSON.parse(backend("tests/fixtures/log_redaction_cases.json")) as {
cases: { name: string; key: string; value: unknown; expected: unknown }[];
};
describe("redaction matches the server's (shared cases)", () => {
it.each(cases.map((c) => [c.name, c] as const))("%s", (_name, c) => {
expect(redact(c.value, c.key)).toEqual(c.expected);
});
});
describe("severity numbers match logs.SEVERITY", () => {
it("names the same levels with the same numbers", () => {
const block = /SEVERITY: dict\[str, int\] = \{([^}]*)\}/.exec(backend("app/logs.py"))?.[1];
expect(block).toBeTruthy();
const server = Object.fromEntries(
[...block!.matchAll(/"(\w+)":\s*(\d+)/g)].map((m) => [m[1], Number(m[2])]),
);
expect(SEVERITY).toEqual(server);
});
});
+72
View File
@@ -0,0 +1,72 @@
import { afterEach, describe, expect, it } from "vitest";
import { clear, enabled, errorAttrs, events, log, newRequestId, RING_MAX, setLevel } from "./log";
afterEach(() => {
clear();
setLevel("INFO");
});
describe("client log", () => {
it("keeps an event in the server's shape", () => {
log.warn("nav.view", "page opened", { page: "feed" });
const [e] = events();
expect(e).toMatchObject({
level: "WARNING",
sev: 13,
source: "web",
event: "nav.view",
msg: "page opened",
attrs: { page: "feed" },
});
expect(e!.tab_id).toMatch(/^[0-9a-f]{12}$/);
expect(Number.isNaN(Date.parse(e!.ts))).toBe(false);
});
it("drops what is below the level, and the level can be lowered", () => {
log.debug("a", "hidden");
expect(events()).toEqual([]);
expect(enabled("DEBUG")).toBe(false);
setLevel("TRACE");
log.trace("b", "kept");
expect(events().map((e) => e.event)).toEqual(["b"]);
});
it("redacts at capture — message and attributes alike", () => {
log.info("auth.link", "sent to someone@mail.example", {
password: "hunter2",
url: "/?reset=abc123",
});
const [e] = events();
expect(e!.msg).toBe("sent to [email]");
expect(e!.attrs).toEqual({ password: "[redacted]", url: "/?reset=[redacted]" });
});
it("is a ring: the oldest events go once it is full", () => {
for (let i = 0; i < RING_MAX + 5; i++) log.info("n", String(i));
const kept = events();
expect(kept).toHaveLength(RING_MAX);
expect(kept[0]!.msg).toBe("5");
});
it("never throws into the caller", () => {
const hostile = {
get boom() {
throw new Error("getter");
},
};
expect(() => log.error("x", "y", { hostile })).not.toThrow();
});
it("makes request ids the server accepts", () => {
// logs._REQUEST_ID_RE: [A-Za-z0-9._-]{8,64}
const id = newRequestId();
expect(id).toMatch(/^[A-Za-z0-9._-]{8,64}$/);
expect(newRequestId()).not.toBe(id);
});
it("turns an error into attributes", () => {
expect(errorAttrs(new TypeError("bad"))).toMatchObject({ error: "TypeError", message: "bad" });
expect(errorAttrs("plain")).toEqual({ error: "string", message: "plain" });
});
});
+206
View File
@@ -0,0 +1,206 @@
// 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) };
}
+109
View File
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import { clear, events, setLevel } from "./log";
import { installClientLog } from "./logHooks";
// History is wrapped ONCE per window (installClientLog is idempotent), so this file installs once
// and every test reads only what it caused.
beforeAll(() => {
installClientLog();
installClientLog(); // a hot reload's second call must not wrap History twice
expect(events().map((e) => e.event)).toEqual(["app.start", "nav.view"]);
clear();
});
afterEach(() => {
setLevel("INFO");
window.history.replaceState(null, "", "/");
clear();
});
const views = () => events().filter((e) => e.event === "nav.view");
describe("client log hooks", () => {
it("records a page change once, and not a replaceState that only saves a scroll position", () => {
window.history.pushState({ sfPage: "feed" }, "", "/?show=all");
window.history.replaceState({ sfPage: "feed", sfScroll: 120 }, "");
window.history.replaceState({ sfPage: "feed", sfScroll: 480 }, "");
expect(views()).toHaveLength(1);
expect(views()[0]!.attrs).toMatchObject({
via: "push",
page: "feed",
path: "/",
params: ["show"],
overlay: false,
});
});
it("keeps parameter NAMES at the base level, values at DEBUG, typed search only at TRACE", () => {
window.history.pushState({ sfPage: "feed" }, "", "/?tags=music&q=lofi");
expect(views()[0]!.attrs).not.toHaveProperty("values");
expect(JSON.stringify(views())).not.toContain("lofi");
setLevel("DEBUG");
window.history.pushState({ sfPage: "plex" }, "", "/?tags=music&q=lofi");
expect(views()[1]!.attrs!.values).toEqual({ tags: "music" });
setLevel("TRACE");
window.history.pushState({ sfPage: "feed", _yt: "lofi" }, "", "/?tags=music&q=lofi");
expect(views()[2]!.attrs).toMatchObject({
values: { tags: "music", q: "lofi" },
query: "lofi",
});
});
it("records an overlay opening as a view of its own, and Back as a pop", () => {
window.history.pushState({ sfPage: "feed", _ov: 1 }, "");
// What Back does: the browser restores the previous entry's state (the unwrapped prototype
// method, as the browser itself would not go through the app's wrapper), then fires popstate.
History.prototype.replaceState.call(window.history, { sfPage: "feed" }, "");
window.dispatchEvent(new PopStateEvent("popstate"));
expect(views().map((e) => [e.attrs!.via, e.attrs!.overlay])).toEqual([
["push", true],
["pop", false],
]);
});
it("never records a token that rides in the address", () => {
window.history.replaceState(
{ sfPage: "welcome" },
"",
"/watch/Ab3xYz9Tok/unlock?token=zzz#reset=yyy",
);
setLevel("TRACE");
window.history.pushState(
{ sfPage: "welcome2" },
"",
"/watch/Ab3xYz9Tok/unlock?token=zzz#reset=yyy",
);
const text = JSON.stringify(views());
expect(text).not.toContain("Ab3xYz9Tok");
expect(text).not.toContain("zzz");
expect(text).not.toContain("yyy");
});
it("records uncaught errors and unhandled rejections", () => {
window.dispatchEvent(
new ErrorEvent("error", {
error: new TypeError("x is undefined"),
message: "x is undefined",
filename: "http://localhost/assets/index-B1x9.js",
lineno: 3,
colno: 7,
}),
);
const rejection = new Event("unhandledrejection") as Event & { reason: unknown };
rejection.reason = new Error("nope");
window.dispatchEvent(rejection);
window.dispatchEvent(new Event("error")); // a failed <img> load: not a script fault
expect(events().map((e) => [e.event, e.level])).toEqual([
["app.uncaught", "ERROR"],
["app.unhandled_rejection", "ERROR"],
]);
expect(events()[0]!.attrs).toMatchObject({
error: "TypeError",
where: "/assets/index-B1x9.js:3:7",
});
});
});
+106
View File
@@ -0,0 +1,106 @@
// The client log's app-wide hooks (E22 S4), installed once from main.tsx before anything renders:
// the start snapshot, uncaught errors and rejections, and navigation.
//
// Navigation has no single choke point in this router-free app — `setPage`, the channel and search
// overlays, sub-views, the overlay stack and the URL mirrors each call `history.pushState` /
// `replaceState` themselves (lib/history.ts, NavigationProvider, ChannelsProvider, …). Instrumenting
// each would be one more thing a new call site can forget, so the History methods themselves are
// wrapped here, and `popstate` covers Back/Forward. A replaceState that only saves a scroll position
// changes nothing this logs, so it is not an event.
import { FRONTEND_SHA, FRONTEND_VERSION } from "./version";
import { enabled, errorAttrs, log } from "./log";
import { pathTemplate } from "./http";
// URL parameters that carry what the user TYPED — search text. Logged only at TRACE ("Teljes").
// Every other parameter is filter/sort/view state: its NAME is kept at INFO, its value at DEBUG.
const TYPED_PARAMS = new Set(["q", "keywords", "search"]);
type Via = "start" | "push" | "replace" | "pop";
/** What a navigation event records of where the tab is now. Built from `history.state`'s own view
* keys and the query, never the full URL (a `#reset=` fragment, a `?token=` link). */
function viewAttrs(): Record<string, unknown> {
const st = (window.history.state ?? {}) as Record<string, unknown>;
const params = new URLSearchParams(window.location.search);
const view: Record<string, unknown> = {
path: pathTemplate(window.location.pathname),
page: typeof st.sfPage === "string" ? st.sfPage : null,
params: [...new Set(params.keys())].sort(),
overlay: st._ov != null,
channel: st._chan != null,
search: st._yt != null,
};
if (enabled("DEBUG")) {
const values: Record<string, string> = {};
for (const [k, v] of params) {
if (!TYPED_PARAMS.has(k) || enabled("TRACE")) values[k] = v;
}
view.values = values;
if (typeof st._sub === "string") view.sub = st._sub;
}
if (enabled("TRACE") && typeof st._yt === "string") view.query = st._yt;
return view;
}
/** A script's path (`/assets/index-B1x9.js`): the bundle name is what finds the fault, and a script
* URL carries no user data — but a malformed one is dropped rather than logged raw. */
function scriptPath(filename: string): string {
try {
return new URL(filename, window.location.href).pathname;
} catch {
return "?";
}
}
let lastView = "";
function recordView(via: Via): void {
const view = viewAttrs();
const signature = JSON.stringify(view);
if (via !== "start" && signature === lastView) return;
lastView = signature;
log.info("nav.view", `${via} ${String(view.page ?? view.path)}`, { via, ...view });
}
let installed = false;
/** Install the hooks. Idempotent: a second call (a hot reload) must not wrap History twice. */
export function installClientLog(): void {
if (installed) return;
installed = true;
log.info("app.start", "app loaded", {
version: FRONTEND_VERSION,
sha: FRONTEND_SHA,
user_agent: navigator.userAgent,
language: navigator.language,
viewport: `${window.innerWidth}x${window.innerHeight}`,
dpr: window.devicePixelRatio,
});
window.addEventListener("error", (e) => {
// A failed <img>/<script> load also dispatches `error` on window (capture only); those carry no
// `error` object and are not script faults.
if (!(e instanceof ErrorEvent)) return;
log.error("app.uncaught", e.message || "uncaught error", {
...errorAttrs(e.error ?? e.message),
where: e.filename ? `${scriptPath(e.filename)}:${e.lineno}:${e.colno}` : null,
});
});
window.addEventListener("unhandledrejection", (e) => {
log.error("app.unhandled_rejection", "unhandled promise rejection", errorAttrs(e.reason));
});
const history = window.history;
for (const [method, via] of [
["pushState", "push"],
["replaceState", "replace"],
] as const) {
const original = history[method];
history[method] = function (this: History, ...args: Parameters<History["pushState"]>) {
original.apply(this, args);
recordView(via);
};
}
window.addEventListener("popstate", () => recordView("pop"));
recordView("start");
}
+74
View File
@@ -0,0 +1,74 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./api", () => ({ getActiveAccount: () => 7 }));
// A WebSocket double the test drives: each instance is recorded, and its events are fired by hand.
class FakeSocket {
static all: FakeSocket[] = [];
url: string;
onopen: (() => void) | null = null;
onmessage: ((ev: { data: string }) => void) | null = null;
onclose: ((ev: { code: number; wasClean: boolean }) => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
constructor(url: string) {
this.url = url;
FakeSocket.all.push(this);
}
close() {
this.closed = true; // the real close event arrives later, on its own task — fired by the test
}
}
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
FakeSocket.all = [];
vi.stubGlobal("WebSocket", FakeSocket);
vi.stubGlobal("location", { protocol: "https:", host: "app.example" });
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
const frame = (sock: FakeSocket) =>
sock.onmessage?.({ data: JSON.stringify({ type: "message", message: { id: 1 } }) });
describe("messagesSocket", () => {
it("a socket closed on unsubscribe cannot take over from the one a re-subscribe opened", async () => {
const { onMessage } = await import("./messagesSocket");
const first = vi.fn();
onMessage(first)(); // subscribe, then leave (a remount / StrictMode's double effect)
const got = vi.fn();
onMessage(got);
const [old, current] = FakeSocket.all;
expect(old!.closed).toBe(true);
old!.onclose?.({ code: 1000, wasClean: true }); // the OLD socket's close lands late
vi.advanceTimersByTime(60_000);
expect(FakeSocket.all).toHaveLength(2); // no third socket
frame(current!);
expect(got).toHaveBeenCalledOnce();
});
it("reconnects when the current socket drops, and logs it without the account query", async () => {
// Imported after resetModules, like the socket module: the ring it writes to is this instance.
const { onMessage } = await import("./messagesSocket");
const { events } = await import("./log");
onMessage(() => {});
const [sock] = FakeSocket.all;
expect(sock!.url).toBe("wss://app.example/api/messages/ws?account=7");
sock!.onopen?.();
sock!.onclose?.({ code: 1006, wasClean: false });
vi.advanceTimersByTime(1000);
expect(FakeSocket.all).toHaveLength(2);
const logged = events().map((e) => [e.event, e.level, e.attrs?.path]);
expect(logged).toEqual([
["ws.open", "INFO", "/api/messages/ws"],
["ws.close", "WARNING", "/api/messages/ws"],
]);
expect(JSON.stringify(events())).not.toContain("account");
});
});
+35 -8
View File
@@ -3,6 +3,12 @@
// thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the // thread + conversations). Auto-reconnects with backoff; a low-frequency poll elsewhere is the
// safety net if the socket is down. // safety net if the socket is down.
import { getActiveAccount, type Message, type MessageUser } from "./api"; import { getActiveAccount, type Message, type MessageUser } from "./api";
import { log } from "./log";
// What the client log records of the socket: its lifecycle, and at DEBUG each frame's TYPE. Never a
// frame's content — chat plaintext stays out of every log structurally, not by redaction. The path
// is fixed, without the `?account=` query.
const WS_PATH = "/api/messages/ws";
// A pushed message plus both parties, so the dock can open/flash the right window. // A pushed message plus both parties, so the dock can open/flash the right window.
interface IncomingMessage { interface IncomingMessage {
@@ -28,22 +34,33 @@ function url(): string {
// this module and reopens the socket, so it's intentionally fixed for the socket's lifetime. // this module and reopens the socket, so it's intentionally fixed for the socket's lifetime.
const account = getActiveAccount(); const account = getActiveAccount();
const qs = account != null ? `?account=${account}` : ""; const qs = account != null ? `?account=${account}` : "";
return `${proto}://${location.host}/api/messages/ws${qs}`; return `${proto}://${location.host}${WS_PATH}${qs}`;
} }
function open() { function open() {
let sock: WebSocket;
try { try {
ws = new WebSocket(url()); sock = new WebSocket(url());
} catch { } catch (e) {
log.warn("ws.failed", "could not open the socket", { path: WS_PATH, message: String(e) });
schedule(); schedule();
return; return;
} }
ws.onopen = () => { ws = sock;
// Every handler checks it still belongs to the CURRENT socket. A socket closed by the last
// subscriber leaving closes asynchronously: when a subscriber came back in between (a remount,
// StrictMode's double effect) a newer socket is already open, and the old one's close used to
// clear that newer reference and schedule a THIRD — two live sockets, every message delivered twice.
sock.onopen = () => {
if (ws !== sock) return;
log.info("ws.open", "socket open", { path: WS_PATH });
backoff = 1000; backoff = 1000;
}; };
ws.onmessage = (ev) => { sock.onmessage = (ev) => {
if (ws !== sock) return;
try { try {
const data = JSON.parse(ev.data); const data = JSON.parse(ev.data);
log.debug("ws.frame", "socket frame", { path: WS_PATH, type: String(data?.type) });
if (data?.type === "message" && data.message) { if (data?.type === "message" && data.message) {
const e: IncomingMessage = { const e: IncomingMessage = {
message: data.message as Message, message: data.message as Message,
@@ -59,12 +76,22 @@ function open() {
/* ignore malformed frames */ /* ignore malformed frames */
} }
}; };
ws.onclose = () => { sock.onclose = (ev) => {
const current = ws === sock;
// A socket we let go of (the last subscriber left) closing is routine; the current one closing
// is the server or the network going away, and the reconnect that follows is worth seeing.
(current ? log.warn : log.info)("ws.close", "socket closed", {
path: WS_PATH,
code: ev.code,
clean: ev.wasClean,
reconnect_ms: current && started ? backoff : null,
});
if (!current) return;
ws = null; ws = null;
if (started) schedule(); if (started) schedule();
}; };
ws.onerror = () => { sock.onerror = () => {
ws?.close(); sock.close();
}; };
} }
@@ -0,0 +1,47 @@
import { fileURLToPath } from "node:url";
import { ESLint } from "eslint";
import { describe, expect, it } from "vitest";
// E22 S4 put "every request is logged" into eslint.config.js, not prose: `fetch`, `XMLHttpRequest`
// and `WebSocket` each belong to ONE module that logs them, and `console` to none. A restriction
// that matches nothing looks exactly like a clean codebase, so this asserts the rules FIRE — and
// that each owner is exempt from its own global only.
const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
const RULES = new Set(["no-restricted-globals", "no-restricted-properties", "no-console"]);
async function flagged(source: string, filePath: string): Promise<string[]> {
const eslint = new ESLint({ cwd: FRONTEND });
const [result] = await eslint.lintText(source, { filePath });
return (result?.messages ?? [])
.filter((m) => m.ruleId && RULES.has(m.ruleId))
.map((m) => `${m.ruleId}: ${m.message}`);
}
const ALL = [
`export const a = () => fetch("/x");`,
`export const b = () => new XMLHttpRequest();`,
`export const c = () => new WebSocket("wss://x");`,
`export const d = () => window.fetch("/x");`,
`export const e = () => navigator.sendBeacon("/x");`,
`export const f = () => console.log("x");`,
].join("\n");
describe("network and console boundary", () => {
it("refuses every way out of the browser, and console, in an ordinary module", async () => {
const found = await flagged(ALL, "src/components/__network_probe.tsx");
expect(found).toHaveLength(6);
expect(found.filter((f) => f.startsWith("no-console"))).toHaveLength(1);
});
it.each([
["src/lib/http.ts", "fetch"],
["src/lib/api/core.ts", "XMLHttpRequest"],
["src/lib/messagesSocket.ts", "WebSocket"],
])("%s may use %s — and nothing else", async (owner, own) => {
const found = await flagged(ALL, owner);
// Its own global goes quiet; the other two globals, the two properties and console still fire.
expect(found).toHaveLength(5);
expect(found.some((f) => f.includes(`\`${own}\``))).toBe(false);
});
});
+15
View File
@@ -4,6 +4,16 @@
// callbacks are live-only and dropped on reload. // callbacks are live-only and dropped on reload.
import { activeAccountId, onAccountPinned } from "./activeAccount"; import { activeAccountId, onAccountPinned } from "./activeAccount";
import { LS, readAccount, readAccountMerged, writeAccount } from "./storage"; import { LS, readAccount, readAccountMerged, writeAccount } from "./storage";
import { log, type Level } from "./log";
// A notification's level in the client log: what the user was TOLD is part of the story a log tells.
const LOG_LEVEL: Record<NotifLevel, Level> = {
info: "INFO",
success: "INFO",
warning: "WARNING",
error: "ERROR",
fatal: "CRITICAL",
};
export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal"; export type NotifLevel = "info" | "success" | "warning" | "error" | "fatal";
@@ -244,6 +254,11 @@ const COALESCE_WINDOW_MS = 5000;
export function notify(input: NotifyInput): number { export function notify(input: NotifyInput): number {
const requiresInteraction = input.requiresInteraction ?? false; const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info"; const level = input.level ?? "info";
log.at(LOG_LEVEL[level], "ui.notify", input.message, {
level,
...(input.title ? { title: input.title } : {}),
...(input.meta ? { kind: input.meta.kind } : {}),
});
const now = Date.now(); const now = Date.now();
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed). // config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
// A transient status stays put until its producer clears it (no auto-dismiss timer), // A transient status stays put until its producer clears it (no auto-dismiss timer),
+27 -2
View File
@@ -1,6 +1,19 @@
import { MutationCache, QueryClient } from "@tanstack/react-query"; import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query";
import i18n from "../i18n"; import i18n from "../i18n";
import { notifyActionError } from "./actionErrors"; import { notifyActionError } from "./actionErrors";
import { log } from "./log";
/** A failed query or mutation, for the client log. Only the key's HEAD (the resource, `"videos"`):
* the rest of a key is filter state, which the base level does not keep. The status is what
* `req` threw; the request line itself is already logged, under its request id. */
function failureAttrs(key: readonly unknown[] | undefined, err: unknown): Record<string, unknown> {
const status = (err as { status?: unknown } | null)?.status;
return {
key: typeof key?.[0] === "string" ? key[0] : null,
...(typeof status === "number" ? { status } : {}),
message: err instanceof Error ? err.message : String(err),
};
}
declare module "@tanstack/react-query" { declare module "@tanstack/react-query" {
interface Register { interface Register {
@@ -34,8 +47,20 @@ function reportMutationFailure(err: unknown, meta: FailMeta): void {
export function createQueryClient(): QueryClient { export function createQueryClient(): QueryClient {
return new QueryClient({ return new QueryClient({
// Queries report their failure where they render it (QueryState); this only records it.
queryCache: new QueryCache({
onError: (err, query) =>
log.warn("query.failed", "a query failed", failureAttrs(query.queryKey, err)),
}),
mutationCache: new MutationCache({ mutationCache: new MutationCache({
onError: (err, _vars, _onMutateResult, mutation) => reportMutationFailure(err, mutation.meta), onError: (err, _vars, _onMutateResult, mutation) => {
log.warn(
"mutation.failed",
"a mutation failed",
failureAttrs(mutation.options.mutationKey, err),
);
reportMutationFailure(err, mutation.meta);
},
}), }),
defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } }, defaultOptions: { queries: { retry: false, staleTime: 30_000, refetchOnWindowFocus: false } },
}); });
+4
View File
@@ -20,6 +20,10 @@ import "./i18n";
import "./index.css"; import "./index.css";
import "flag-icons/css/flag-icons.min.css"; import "flag-icons/css/flag-icons.min.css";
import { lazyWithPreload } from "./lib/lazyWithPreload"; import { lazyWithPreload } from "./lib/lazyWithPreload";
import { installClientLog } from "./lib/logHooks";
// First, before any render: a crash or a navigation during boot belongs in the log too.
installClientLog();
// Split by top-level route so each entry point is its own chunk: a public /watch share link or a // Split by top-level route so each entry point is its own chunk: a public /watch share link or a
// legal page never downloads the authenticated app bundle, and the app never carries them either. // legal page never downloads the authenticated app bundle, and the app never carries them either.