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'
This commit is contained in:
2026-09-19 22:08:10 +02:00
parent 2d9595b0e5
commit 46fce4a767
26 changed files with 1347 additions and 89 deletions
+2 -1
View File
@@ -68,7 +68,7 @@ import { messagingApi } from "./api/messaging";
export type * from "./api/messaging";
import { downloadsApi } from "./api/downloads";
export type * from "./api/downloads";
import { diagnosticsApi } from "./api/diagnostics";
import { debugSessionsApi, diagnosticsApi } from "./api/diagnostics";
export type * from "./api/diagnostics";
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
@@ -90,4 +90,5 @@ export const api = {
...messagingApi,
...downloadsApi,
...diagnosticsApi,
...debugSessionsApi,
};
+3 -1
View File
@@ -1,7 +1,7 @@
// The signed-in account & session: the bootstrap `me` probe, multi-account switch/list, sign-out,
// app version, and preference save. Only touches core's req.
import { req } from "./core";
import type { DiagnosticsState } from "./diagnostics";
import type { DebugSession, DiagnosticsState } from "./diagnostics";
export interface Me {
id: number;
@@ -33,6 +33,8 @@ export interface Me {
preferences: Record<string, unknown>;
/** The consented diagnostics level in force and when it lapses (E22 S5). */
diagnostics: DiagnosticsState;
/** The debug session recording right now, if any (E16) — a reload resumes it. */
debug_session: DebugSession | null;
}
/** The `nav.role.*` key an account is shown under. Demo isn't a real role (it's a flag on a `user`
+31
View File
@@ -22,7 +22,9 @@ vi.mock("../activeAccount", () => ({
pinActiveAccount: () => {},
}));
import { setDebugSession } from "../log";
import {
beacon,
req,
uploadReq,
setUnauthorizedHandler,
@@ -310,3 +312,32 @@ describe("req — what it has already put in front of the user", () => {
expect(remove).not.toHaveBeenCalled();
});
});
// E16: while a debug session records, every request path names it — the server tags ITS lines of
// that request with it. A path that forgot the header would leave holes in the evidence exactly
// where the upload (XHR) or the unload beacon happened.
describe("the debug session a tab records into", () => {
const sentHeaders = () => {
const init = vi.mocked(globalThis.fetch).mock.lastCall?.[1] as RequestInit;
return new Headers(init.headers as HeadersInit);
};
it("rides every request path while it records, and none once it stops", async () => {
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
Promise.resolve(new Response("{}", { status: 200 })),
);
setDebugSession("0123456789abcdef");
try {
await req("/api/x");
expect(sentHeaders().get("X-Debug-Session")).toBe("0123456789abcdef");
beacon("/api/y", {});
expect(sentHeaders().get("X-Debug-Session")).toBe("0123456789abcdef");
void uploadReq("/u", form());
expect(FakeXHR.last.headers["X-Debug-Session"]).toBe("0123456789abcdef");
} finally {
setDebugSession(null);
}
await req("/api/x");
expect(sentHeaders().has("X-Debug-Session")).toBe(false);
});
});
+22 -12
View File
@@ -8,7 +8,7 @@ import { formatBytes } from "../format";
import { reportError } from "../errorDialog";
import { activeAccountId, pinActiveAccount } from "../activeAccount";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
import { newRequestId } from "../log";
import { currentDebugSession, newRequestId } from "../log";
/** 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
@@ -253,24 +253,35 @@ export const setupHeaders = (token: string) => ({
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
// The debug session this tab is recording (E16): the server tags its own lines of the request with
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
const DEBUG_SESSION_HEADER = "X-Debug-Session";
export const getActiveAccount = activeAccountId;
/** Who this request is from — the tab's account and the debug session it records into — as the
* headers every request path sends (`req`, `beacon`, `uploadReq`): one definition, so a path
* cannot quietly go out without them. */
export function identityHeaders(): Record<string, string> {
const active = getActiveAccount();
const session = currentDebugSession();
return {
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
...(session ? { [DEBUG_SESSION_HEADER]: session } : {}),
};
}
// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
// would be lost.
export function beacon(url: string, body: Record<string, unknown>): void {
const active = getActiveAccount();
try {
void send(url, {
method: "POST",
keepalive: true,
credentials: "include",
headers: {
"Content-Type": "application/json",
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
headers: { "Content-Type": "application/json", ...identityHeaders() },
body: JSON.stringify(body),
}).catch(() => {});
} catch {
@@ -292,16 +303,15 @@ export async function req<T = unknown>(
for (;;) {
let r: Response;
try {
const active = getActiveAccount();
r = await send(
url,
{
credentials: "include",
headers: {
"Content-Type": "application/json",
// 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) } : {}),
// Per-tab identity override (only setup calls and raw uploads pass their own headers:
// setup is pre-auth, and an upload spreads `identityHeaders()` into its own).
...identityHeaders(),
},
...opts,
},
@@ -372,8 +382,8 @@ export function uploadReq<T = unknown>(
const requestId = newRequestId();
const started = performance.now();
xhr.setRequestHeader(REQUEST_ID_HEADER, requestId);
const active = getActiveAccount();
if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active));
for (const [name, value] of Object.entries(identityHeaders()))
xhr.setRequestHeader(name, value);
if (cfg.onProgress) {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100));
+66 -1
View File
@@ -1,7 +1,8 @@
// The user's diagnostics level (E22 S5) and the browser log it lets out. Only touches core's req and
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
import type { LogEvent } from "../log";
import { beacon, req } from "./core";
import type { ServerLogRow } from "./admin";
import { beacon, getActiveAccount, identityHeaders, req } from "./core";
/** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
@@ -12,6 +13,8 @@ export interface DiagnosticsState {
until: string | null;
/** How many days this instance keeps stored log lines — the consent text states it. */
retention_days: number;
/** How long a debug session records at most — the start dialog's consent text states it. */
session_minutes: number;
}
/** Where batches go. The shipper leaves the log lines ABOUT this request out of what it sends, or
@@ -33,3 +36,65 @@ export const diagnosticsApi = {
/** The last batch of a closing tab (keepalive, fire-and-forget). */
sendLogEventsOnUnload: (events: LogEvent[]): void => beacon(DIAGNOSTICS_EVENTS_PATH, { events }),
};
// --- debug sessions (E16) ----------------------------------------------------------------------
/** The two levels a recording can run at — it always records more than the base. */
export type DebugSessionLevel = "DEBUG" | "TRACE";
/** One recording: Start → reproduce → Stop. `until` is the server's cap; past it the session is
* over whether or not it was stopped. */
export interface DebugSession {
id: string;
level: DebugSessionLevel;
started_at: string;
until: string;
ended_at: string | null;
ended_reason: "stopped" | "expired" | null;
/** The issue it was sent as, once sent. */
issue_url: string | null;
screenshots: number;
}
export interface DebugScreenshot {
id: number;
mime: string;
size: number;
created_at: string;
}
const SESSIONS = "/api/debug-sessions";
export const debugSessionsApi = {
startDebugSession: (level: DebugSessionLevel): Promise<{ ok: true; session: DebugSession }> =>
req(SESSIONS, { method: "POST", body: JSON.stringify({ level }) }),
stopDebugSession: (id: string): Promise<{ ok: true; session: DebugSession }> =>
req(`${SESSIONS}/${id}/stop`, { method: "POST" }),
debugSessions: (): Promise<{ items: DebugSession[] }> => req(SESSIONS),
debugSession: (id: string): Promise<DebugSession> => req(`${SESSIONS}/${id}`),
/** The rows an issue from this session would carry, browser and server, in time order. */
debugSessionEvents: (id: string): Promise<{ items: ServerLogRow[]; total: number }> =>
req(`${SESSIONS}/${id}/events`),
debugScreenshots: (id: string): Promise<{ items: DebugScreenshot[] }> =>
req(`${SESSIONS}/${id}/screenshots`),
/** One image as the raw body — the server checks what it really is, whatever `type` says. */
addDebugScreenshot: (
id: string,
image: Blob,
): Promise<{ ok: true; screenshot: DebugScreenshot }> =>
req(`${SESSIONS}/${id}/screenshots`, {
method: "POST",
headers: { "Content-Type": image.type || "application/octet-stream", ...identityHeaders() },
body: image,
}),
removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> =>
req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }),
/** Where an `<img>` loads a screenshot from. An `<img>` cannot send the account header, so the
* tab's account rides as `?account=` (the wallet-gated selection downloads use); without it a
* per-tab account's screenshot resolves to the session default and 404s. */
debugScreenshotUrl: (id: string, shotId: number): string => {
const a = getActiveAccount();
const url = `${SESSIONS}/${id}/screenshots/${shotId}`;
return a != null ? `${url}?account=${a}` : url;
},
};
+1
View File
@@ -33,6 +33,7 @@ const SLICE_MODULES = [
["messagingApi", () => import("./messaging")],
["downloadsApi", () => import("./downloads")],
["diagnosticsApi", () => import("./diagnostics")],
["debugSessionsApi", () => import("./diagnostics")],
] as const;
let api: Slice;
+62
View File
@@ -0,0 +1,62 @@
// One screenshot of this tab, for a debug session (E16 D5).
//
// Through the Screen Capture API with the current tab preferred: the browser asks every time (there
// is no silent capture on the web, by design), the user can pick something else or cancel, and one
// frame is taken before the track is stopped at once — nothing keeps recording.
//
// A cancelled or refused picker is `null`, not an error: it is the user saying no. Measured in the
// desktop app's Chromium (2026-09-19): refused capture rejects with `NotAllowedError` — the same
// name Chrome gives a cancelled picker.
//
// The frame is read through a <video> and a canvas rather than `ImageCapture.grabFrame`, which
// Firefox and Safari lack.
/** Whether this browser can capture a tab at all (iOS Safari cannot — the button is not shown). */
export function canCaptureTab(): boolean {
return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getDisplayMedia;
}
/** Ask for this tab and return one PNG frame of it, or null when the user did not share. */
export async function captureTab(): Promise<Blob | null> {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: "browser" },
audio: false,
// Chromium hints: offer THIS tab first, and nothing that would record the whole screen.
preferCurrentTab: true,
selfBrowserSurface: "include",
surfaceSwitching: "exclude",
monitorTypeSurfaces: "exclude",
} as DisplayMediaStreamOptions);
} catch (err) {
if (
err instanceof DOMException &&
(err.name === "NotAllowedError" || err.name === "AbortError")
)
return null;
throw err;
}
try {
return await firstFrame(stream);
} finally {
for (const track of stream.getTracks()) track.stop();
}
}
async function firstFrame(stream: MediaStream): Promise<Blob | null> {
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.srcObject = stream;
await video.play();
// The first frame can arrive a beat after `play` resolves; wait until it has a size.
for (let i = 0; i < 20 && !video.videoWidth; i++) await new Promise((r) => setTimeout(r, 50));
if (!video.videoWidth) return null;
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext("2d")?.drawImage(video, 0, 0);
video.srcObject = null;
return new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
}
+49
View File
@@ -0,0 +1,49 @@
import { createStore } from "./store";
// The debug recorder's app-wide state (E16) — what several surfaces open or read, held outside any
// one of them: Settings, the command palette and the recorder itself all open the start dialog, and
// the composer opens for whichever session just ended.
const startOpen = createStore(false);
/** Open the start dialog (Settings → Diagnosztika, the palette). */
export const openRecorderStart = (): void => startOpen.set(true);
export const closeRecorderStart = (): void => startOpen.set(false);
export const useRecorderStartOpen = (): boolean => startOpen.use();
const composing = createStore<string | null>(null);
/** Open the issue composer for session `id` — after Stop, when the cap ends it, from Settings. */
export const openComposer = (id: string): void => composing.set(id);
export const closeComposer = (): void => composing.set(null);
export const useComposerSession = (): string | null => composing.use();
// Step markers are numbered per session. The count survives a reload (sessionStorage, per tab): a
// reproduction is often "reload and it happens again", and step 4 coming back as step 1 would make
// the list the issue is drafted from contradict itself.
const STEP_KEY = "siftlode.debugSteps";
function readSteps(): Record<string, number> {
try {
const raw = JSON.parse(sessionStorage.getItem(STEP_KEY) ?? "{}") as unknown;
return raw && typeof raw === "object" ? (raw as Record<string, number>) : {};
} catch {
return {};
}
}
const steps = createStore<Record<string, number>>(readSteps());
/** How many steps session `id` has marked in this tab. */
export const useStepCount = (id: string): number => steps.use()[id] ?? 0;
/** Take the next step number for session `id`. */
export function nextStep(id: string): number {
const n = (steps.get()[id] ?? 0) + 1;
// Only this session's count is kept: the others ended, and their numbers are in their events.
steps.set({ [id]: n });
try {
sessionStorage.setItem(STEP_KEY, JSON.stringify(steps.get()));
} catch {
// Storage off: the count lives until the reload, which is all it is for.
}
return n;
}
+53 -24
View File
@@ -34,7 +34,11 @@ beforeEach(() => {
let current: typeof import("./diagnostics") | null = null;
afterEach(() => {
// A module copy left streaming would still hear `pagehide` in the next test.
current?.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
current?.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
current = null;
vi.useRealTimers();
});
@@ -51,6 +55,7 @@ const raised = (level: "DEBUG" | "TRACE", hours = 24) => ({
level,
until: new Date(Date.now() + hours * 3_600_000).toISOString(),
retention_days: 7,
session_minutes: 60,
});
const codes = (batch: unknown[] | undefined) =>
(batch ?? []).map((e) => (e as { event: string }).event);
@@ -58,7 +63,11 @@ const codes = (batch: unknown[] | undefined) =>
describe("diagnostics level", () => {
it("the base level at boot sets INFO, streams nothing and logs nothing", async () => {
const d = await fresh();
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
d.log.info("nav.view", "x");
await vi.advanceTimersByTimeAsync(60_000);
expect(sent).toEqual([]);
@@ -69,7 +78,7 @@ describe("diagnostics level", () => {
it("a raised level streams only what happens after it, without its own request lines", async () => {
const d = await fresh();
d.log.info("before.consent", "not sent");
d.applyDiagnostics(raised("DEBUG"), () => {});
d.applyDiagnostics(raised("DEBUG"), null, () => {});
expect(d.enabled("DEBUG")).toBe(true);
d.log.debug("ui.click", "click");
d.log.request("INFO", "POST", { path: "/api/diagnostics/events", status: 200 }, "abcdef0123");
@@ -82,7 +91,7 @@ describe("diagnostics level", () => {
it("sends in batches of at most 100, and a full batch goes without waiting", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
d.applyDiagnostics(raised("DEBUG"), null, () => {});
for (let i = 0; i < 250; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0);
expect(sent.map((b) => b.length)).toEqual([100]);
@@ -94,9 +103,13 @@ describe("diagnostics level", () => {
it("stops at once when the level is lowered, and drops what was not sent yet", async () => {
const d = await fresh();
d.applyDiagnostics(raised("TRACE"), () => {});
d.applyDiagnostics(raised("TRACE"), null, () => {});
d.log.trace("search.typed", "lofi");
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
d.log.info("nav.view", "after");
await vi.advanceTimersByTimeAsync(60_000);
expect(sent).toEqual([]);
@@ -106,7 +119,7 @@ describe("diagnostics level", () => {
it("lapses on its own timer and tells the app to refetch", async () => {
const d = await fresh();
const lapsed = vi.fn();
d.applyDiagnostics(raised("DEBUG", 1), lapsed);
d.applyDiagnostics(raised("DEBUG", 1), null, lapsed);
await vi.advanceTimersByTimeAsync(3_600_000 + 1);
expect(lapsed).toHaveBeenCalledOnce();
expect(d.enabled("DEBUG")).toBe(false);
@@ -118,14 +131,14 @@ describe("diagnostics level", () => {
it("an already-expired level is the base level", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG", -1), () => {});
d.applyDiagnostics(raised("DEBUG", -1), null, () => {});
expect(d.enabled("DEBUG")).toBe(false);
});
it("the server saying it is off stops the stream; being told to slow down keeps the batch", async () => {
const d = await fresh();
const lapsed = vi.fn();
d.applyDiagnostics(raised("DEBUG"), lapsed);
d.applyDiagnostics(raised("DEBUG"), null, lapsed);
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
d.log.debug("ui.click", "kept for later");
await vi.advanceTimersByTimeAsync(5_000);
@@ -138,7 +151,7 @@ describe("diagnostics level", () => {
it("after a 429 nothing is sent until the back-off is over, however full the queue", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
d.applyDiagnostics(raised("DEBUG"), null, () => {});
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0);
@@ -153,7 +166,7 @@ describe("diagnostics level", () => {
it("past the queue cap the oldest events go, a refused batch included", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
d.applyDiagnostics(raised("DEBUG"), null, () => {});
answer = () => Promise.reject(new d.HttpError(429, "slow down"));
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0);
@@ -173,14 +186,18 @@ describe("diagnostics level", () => {
const d = await fresh();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), () => {});
d.applyDiagnostics(raised("DEBUG"), null, () => {});
d.log.debug("ui.click", "under the first consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
refuse(new d.HttpError(429, "slow down"));
await vi.advanceTimersByTimeAsync(0);
answer = () => Promise.resolve({ ok: true, stored: 0 });
d.applyDiagnostics(raised("TRACE"), () => {});
d.applyDiagnostics(raised("TRACE"), null, () => {});
await vi.advanceTimersByTimeAsync(60_000);
const later = sent
.slice(1)
@@ -198,12 +215,16 @@ describe("diagnostics level", () => {
const d = await fresh();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), () => {});
d.applyDiagnostics(raised("DEBUG"), null, () => {});
d.log.debug("ui.click", "under the first consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, () => {});
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
() => {},
);
answer = () => Promise.resolve({ ok: true, stored: 0 });
d.applyDiagnostics(raised("TRACE"), () => {});
d.applyDiagnostics(raised("TRACE"), null, () => {});
refuse(new d.HttpError(429, "slow down")); // only now does the old consent's batch come back
await vi.advanceTimersByTimeAsync(60_000);
const later = sent
@@ -219,12 +240,16 @@ describe("diagnostics level", () => {
const lapsed = vi.fn();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), lapsed);
d.applyDiagnostics(raised("DEBUG"), null, lapsed);
d.log.debug("ui.click", "under the first consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, lapsed);
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
lapsed,
);
answer = () => Promise.resolve({ ok: true, stored: 0 });
d.applyDiagnostics(raised("TRACE"), lapsed);
d.applyDiagnostics(raised("TRACE"), null, lapsed);
refuse(new d.HttpError(409, "off"));
await vi.advanceTimersByTimeAsync(60_000);
expect(lapsed).not.toHaveBeenCalled();
@@ -238,10 +263,14 @@ describe("diagnostics level", () => {
const lapsed = vi.fn();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), lapsed);
d.applyDiagnostics(raised("DEBUG"), null, lapsed);
d.log.debug("ui.click", "under the consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight
d.applyDiagnostics({ level: "INFO", until: null, retention_days: 7 }, lapsed);
d.applyDiagnostics(
{ level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
null,
lapsed,
);
refuse(new d.HttpError(409, "off"));
await vi.advanceTimersByTimeAsync(0);
expect(lapsed).not.toHaveBeenCalled();
@@ -255,7 +284,7 @@ describe("diagnostics level", () => {
const d = await fresh();
let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), () => {});
d.applyDiagnostics(raised("DEBUG"), null, () => {});
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `old ${i}`);
await vi.advanceTimersByTimeAsync(0); // the 100 oldest are in flight, the queue is empty
for (let i = 0; i < 1_000; i++) d.log.debug("ui.click", `new ${i}`);
@@ -273,7 +302,7 @@ describe("diagnostics level", () => {
it("a closing tab sends what is still waiting with a keepalive request", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {});
d.applyDiagnostics(raised("DEBUG"), null, () => {});
d.log.debug("ui.click", "last click");
window.dispatchEvent(new Event("pagehide"));
expect(codes(unloaded[0])).toEqual(["diagnostics.level", "ui.click"]);
@@ -0,0 +1,141 @@
// @vitest-environment jsdom
//
// E16: a debug session applied to the tab. It raises the level for its own run (starting it was the
// consent), never lowers a raised one, tags every event it keeps, and its last seconds reach the
// server before Stop. Its own file: `lib/diagnostics` and `lib/log` keep module-level state, so every
// test imports fresh copies of both, as diagnostics.dom.test does.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sent: { event: string; debug_session_id?: string }[][] = [];
vi.mock("./api", async () => {
const core = await vi.importActual<typeof import("./api/core")>("./api/core");
return {
HttpError: core.HttpError,
DIAGNOSTICS_EVENTS_PATH: "/api/diagnostics/events",
api: {
sendLogEvents: (events: { event: string }[]) => {
sent.push(events);
return Promise.resolve({ ok: true, stored: events.length });
},
sendLogEventsOnUnload: () => {},
},
};
});
const BASE = { level: "INFO" as const, until: null, retention_days: 7, session_minutes: 60 };
const raised = (level: "DEBUG" | "TRACE") => ({
...BASE,
level,
until: new Date(Date.now() + 24 * 3_600_000).toISOString(),
});
const recording = (level: "DEBUG" | "TRACE", minutes = 60, over = {}) => ({
id: "0123456789abcdef",
level,
started_at: new Date().toISOString(),
until: new Date(Date.now() + minutes * 60_000).toISOString(),
ended_at: null,
ended_reason: null,
issue_url: null,
screenshots: 0,
...over,
});
let current: typeof import("./diagnostics") | null = null;
beforeEach(() => {
vi.useFakeTimers();
vi.resetModules();
sent.length = 0;
});
afterEach(() => {
current?.applyDiagnostics(BASE, null, () => {});
current = null;
vi.useRealTimers();
});
async function fresh() {
const diagnostics = await import("./diagnostics");
current = diagnostics;
return { ...diagnostics, ...(await import("./log")) };
}
const flat = () => sent.flat();
describe("a debug session applied to the tab", () => {
it("raises the base level to its own, and tags and streams what it keeps", async () => {
const d = await fresh();
d.applyDiagnostics(BASE, recording("DEBUG"), () => {});
expect(d.enabled("DEBUG")).toBe(true);
expect(d.currentDebugSession()).toBe("0123456789abcdef");
d.log.debug("ui.click", "click");
expect(d.events().slice(-1)[0]?.debug_session_id).toBe("0123456789abcdef");
await vi.advanceTimersByTimeAsync(5_000);
const click = flat().find((e) => e.event === "ui.click");
expect(click?.debug_session_id).toBe("0123456789abcdef");
});
it("never lowers a level the user raised further", async () => {
const d = await fresh();
d.applyDiagnostics(raised("TRACE"), recording("DEBUG"), () => {});
expect(d.enabled("TRACE")).toBe(true);
expect(d.currentDebugSession()).toBe("0123456789abcdef");
});
it("an ended session records nothing and tags nothing", async () => {
const d = await fresh();
d.applyDiagnostics(
BASE,
recording("DEBUG", 60, { ended_at: new Date().toISOString() }),
() => {},
);
expect(d.currentDebugSession()).toBeNull();
expect(d.enabled("DEBUG")).toBe(false);
});
it("at its cap it lapses back to the user's own level, untagged", async () => {
const d = await fresh();
const lapsed = vi.fn();
d.applyDiagnostics(raised("DEBUG"), recording("TRACE", 1), lapsed);
expect(d.enabled("TRACE")).toBe(true);
await vi.advanceTimersByTimeAsync(60_000);
expect(d.currentDebugSession()).toBeNull();
// The user's own Részletes is still in force — the session's end is not the consent's.
expect(d.enabled("DEBUG")).toBe(true);
expect(d.enabled("TRACE")).toBe(false);
expect(lapsed).toHaveBeenCalled();
});
it("at its cap the tag goes even when the user's own level outlasts the session", async () => {
// Own Teljes for 24 h, a Részletes session for 1 min: the level never changes, so only the
// session's own end can take its tag off the events that follow.
const d = await fresh();
d.applyDiagnostics(raised("TRACE"), recording("DEBUG", 1), () => {});
expect(d.currentDebugSession()).toBe("0123456789abcdef");
await vi.advanceTimersByTimeAsync(60_000);
expect(d.currentDebugSession()).toBeNull();
expect(d.enabled("TRACE")).toBe(true);
d.log.debug("ui.after", "after");
expect(d.events().slice(-1)[0]?.debug_session_id).toBeUndefined();
});
it("starting a recording on a raised level drops nothing already waiting", async () => {
const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), null, () => {});
d.log.debug("ui.before", "before");
d.applyDiagnostics(raised("DEBUG"), recording("TRACE"), () => {});
d.log.debug("ui.after", "after");
await vi.advanceTimersByTimeAsync(5_000);
const codes = flat().map((e) => e.event);
expect(codes).toEqual(expect.arrayContaining(["ui.before", "ui.after"]));
});
it("flushNow sends what is waiting at once — before Stop closes the session", async () => {
const d = await fresh();
d.applyDiagnostics(BASE, recording("DEBUG"), () => {});
d.log.info("debug.step", "the last step");
expect(flat().some((e) => e.event === "debug.step")).toBe(false);
await d.flushNow();
const step = flat().find((e) => e.event === "debug.step");
expect(step?.debug_session_id).toBe("0123456789abcdef");
});
});
+87 -24
View File
@@ -1,19 +1,29 @@
// The user's consented diagnostics level, applied to this tab (E22 S5).
// The user's consented diagnostics level, applied to this tab (E22 S5) — and the debug session it
// may be recording (E16).
//
// `applyDiagnostics(me.diagnostics)` runs whenever `me` answers. It sets the client log's level, and
// while that level is raised (Részletes / Teljes) it streams the log to the server:
// `applyDiagnostics(me.diagnostics, me.debug_session)` runs whenever `me` answers. It sets the client
// log's level, and while that level is raised (Részletes / Teljes) it streams the log to the server:
// * only events kept AFTER the level was raised — the consent is for what happens from then on;
// * in batches, every FLUSH_MS or once BATCH_MAX are waiting, and the rest with a keepalive
// request when the tab closes;
// * never the lines about its own requests, or each batch would carry the previous one's;
// * and it stops at once when the level is lowered, when it lapses (a timer, not the next `me`
// refetch), or when the server answers that it is off.
// A recording raises the level to its own for as long as it runs (starting it was the consent), and
// never lowers it: a user already at Teljes records at Teljes. Every event kept while it runs carries
// its id, and so does every request (`api/core`), which is how the server tags its own lines.
// A lost batch is dropped, not retried in a loop: this is a diagnostic stream, not a ledger, and a
// retry storm against a struggling server is the last thing a diagnosis needs. The one exception is
// being told to slow down (429): that batch goes back to the front of the queue, and nothing is sent
// for BACKOFF_MS — not even when a full batch is waiting, or every new event would send one.
import { api, DIAGNOSTICS_EVENTS_PATH, HttpError, type DiagnosticsState } from "./api";
import { log, setLevel, subscribe, type LogEvent } from "./log";
import {
api,
DIAGNOSTICS_EVENTS_PATH,
HttpError,
type DebugSession,
type DiagnosticsState,
} from "./api";
import { log, setDebugSession, setLevel, subscribe, type LogEvent } from "./log";
const FLUSH_MS = 5_000;
/** Per request. The server takes up to 200; staying under it leaves room for a burst. */
@@ -29,19 +39,41 @@ export function levelInForce(state: DiagnosticsState | null | undefined, now = D
return Date.parse(state.until) > now ? state.level : "INFO";
}
/** The session if it is still recording: not ended, and before its cap. */
export function liveSession(
session: DebugSession | null | undefined,
now = Date.now(),
): DebugSession | null {
if (!session || session.ended_at || Date.parse(session.until) <= now) return null;
return session;
}
const DETAIL = { INFO: 0, DEBUG: 1, TRACE: 2 } as const;
let applied: string | null = null;
let lapseTimer: number | null = null;
let onLapsed: (() => void) | null = null;
let lastState: DiagnosticsState | null | undefined = null;
let lastSession: DebugSession | null | undefined = null;
/** Apply `state` to this tab. `lapsed` is called when a raised level runs out or the server says it
* is off, so the caller can refetch `me` (and the Settings tab can show the base level again). */
/** Apply `state` and `session` to this tab. `lapsed` is called when a raised level or a recording
* runs out, or the server says it is off, so the caller can refetch `me` (and Settings and the
* recorder show what is in force now). */
export function applyDiagnostics(
state: DiagnosticsState | null | undefined,
session: DebugSession | null | undefined,
lapsed: () => void,
): void {
onLapsed = lapsed;
const level = levelInForce(state);
const key = level === "INFO" ? "INFO" : `${level}@${state?.until}`;
lastState = state;
lastSession = session;
const own = levelInForce(state);
const live = liveSession(session);
const bySession = !!live && DETAIL[live.level] > DETAIL[own];
const level = bySession ? live.level : own;
const until = bySession ? live.until : (state?.until ?? null);
setDebugSession(live?.id ?? null);
const key = level === "INFO" ? "INFO" : `${level}@${until}#${live?.id ?? ""}`;
if (key === applied) return;
const wasRaised = applied !== null && applied !== "INFO";
applied = key;
@@ -56,27 +88,42 @@ export function applyDiagnostics(
}
setLevel(level);
startStreaming(); // first, so the stream opens with the line saying why it exists
log.info("diagnostics.level", "diagnostics level raised", { level, until: state?.until });
// setTimeout's delay is a signed 32-bit number; a longer wait would fire at once. The server
// grants 24 h, so the clamp only matters for a clock that is badly off.
const wait = Math.min(Date.parse(state!.until!) - Date.now(), 2 ** 31 - 1);
log.info("diagnostics.level", "diagnostics level raised", {
level,
until,
...(live ? { debug_session: live.id } : {}),
});
// Whichever runs out first — the level or the recording — changes what is in force. setTimeout's
// delay is a signed 32-bit number; a longer wait would fire at once. The server grants 24 h, so
// the clamp only matters for a clock that is badly off.
const ends = [until, live?.until].filter((t): t is string => !!t).map((t) => Date.parse(t));
const wait = Math.min(Math.min(...ends) - Date.now(), 2 ** 31 - 1);
lapseTimer = window.setTimeout(lapse, wait);
}
/** Something in force ran out: apply what is left of it (the clock decides), then let `me` catch up. */
function lapse(): void {
lapseTimer = null;
applied = "INFO";
stopStreaming();
setLevel("INFO");
log.info("diagnostics.level", "diagnostics level lapsed", { level: "INFO" });
const before = applied;
applied = null;
applyDiagnostics(lastState, lastSession, onLapsed ?? (() => {}));
if (applied === "INFO" && before !== "INFO")
log.info("diagnostics.level", "diagnostics level lapsed", { level: "INFO" });
onLapsed?.();
}
/** The server has it off: lapse everything this tab thought was in force. */
function serverSaysOff(): void {
lastState = null;
lastSession = null;
lapse();
}
// --- streaming ----------------------------------------------------------------------------------
let queue: LogEvent[] = [];
let unsubscribe: (() => void) | null = null;
let flushTimer: number | null = null;
let inFlight = false;
let inFlight: Promise<void> | null = null;
let quietUntil = 0;
// Which consent a batch was taken under. A batch still in flight when the level is lowered must not
// come back into the queue of a LATER consent: it was captured under the one just withdrawn.
@@ -114,9 +161,27 @@ function stopStreaming(): void {
queue = [];
}
async function flush(): Promise<void> {
if (inFlight || !queue.length || Date.now() < quietUntil) return;
inFlight = true;
function flush(): Promise<void> {
if (inFlight) return inFlight;
if (!queue.length || Date.now() < quietUntil) return Promise.resolve();
inFlight = sendBatch().finally(() => {
inFlight = null;
});
return inFlight;
}
/** Send everything waiting, now — the recorder awaits it before Stop: once the server has closed
* the session it no longer tags (or, at the base level, takes) the events of its last seconds,
* which are the ones closest to what went wrong. Gives up on a batch the server turned away. */
export async function flushNow(): Promise<void> {
for (let guard = 0; guard < 20; guard++) {
await flush();
if (inFlight) continue;
if (!queue.length || !unsubscribe || Date.now() < quietUntil) return;
}
}
async function sendBatch(): Promise<void> {
const takenIn = session;
const batch = queue.splice(0, BATCH_MAX);
try {
@@ -138,10 +203,8 @@ async function flush(): Promise<void> {
// means THIS tab does not already know — without it, the 409 answering a batch that was in
// flight when the user picked Alap writes "lapsed" into their own log for a level they
// revoked, and a lapse this tab has already announced gets announced a second time.
lapse();
serverSaysOff();
}
} finally {
inFlight = false;
}
}
+17
View File
@@ -53,6 +53,8 @@ export interface LogEvent {
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
@@ -138,6 +140,20 @@ 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). */
@@ -207,6 +223,7 @@ function emit(
};
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);