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:
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ const SLICE_MODULES = [
|
||||
["messagingApi", () => import("./messaging")],
|
||||
["downloadsApi", () => import("./downloads")],
|
||||
["diagnosticsApi", () => import("./diagnostics")],
|
||||
["debugSessionsApi", () => import("./diagnostics")],
|
||||
] as const;
|
||||
|
||||
let api: Slice;
|
||||
|
||||
Reference in New Issue
Block a user