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
+19 -8
View File
@@ -22,14 +22,14 @@ from datetime import datetime, timedelta, timezone
from enum import StrEnum from enum import StrEnum
from typing import Literal, get_args from typing import Literal, get_args
from sqlalchemy import delete, func, select, update from sqlalchemy import and_, delete, func, not_, select, update
from sqlalchemy.orm import Session, defer from sqlalchemy.orm import Session, defer
from app import diagnostics from app import diagnostics
from app.models import DebugScreenshot, DebugSession, LogEvent, User from app.models import DebugScreenshot, DebugSession, LogEvent, User
HEADER = "x-debug-session" HEADER = "x-debug-session"
MAX_MINUTES = 60 MAX_MINUTES = diagnostics.SESSION_MINUTES
SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024 SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024
SCREENSHOTS_MAX = 8 SCREENSHOTS_MAX = 8
# Rows an issue can carry. A 60-minute session at Teljes is a few thousand rows; past this the # Rows an issue can carry. A 60-minute session at Teljes is a few thousand rows; past this the
@@ -216,13 +216,28 @@ def screenshots(db: Session, session: DebugSession) -> list[DebugScreenshot]:
# --- the evidence -------------------------------------------------------------------------- # --- the evidence --------------------------------------------------------------------------
# The browser's own log upload (`POST` here, every few seconds while recording) is how the evidence
# arrives, not part of it: its server lines would fill the bundle with the recording's plumbing. The
# browser leaves its copies out for the same reason (frontend lib/diagnostics `isOwnRequest`).
INGEST_PATH = "/api/diagnostics/events"
def _evidence(session: DebugSession):
upload_line = and_(
LogEvent.event == "http.request", LogEvent.attrs["path"].astext == INGEST_PATH
)
# `IS TRUE`, not the bare condition: for a row with no event or no attrs it is NULL, and
# `NOT NULL` would drop exactly the rows that are evidence.
return (LogEvent.debug_session_id == session.id, not_(upload_line.is_(True)))
def bundle(db: Session, session: DebugSession) -> tuple[list[LogEvent], int]: def bundle(db: Session, session: DebugSession) -> tuple[list[LogEvent], int]:
"""The session's log rows, browser and server interleaved in time, capped at BUNDLE_MAX — and """The session's log rows, browser and server interleaved in time, capped at BUNDLE_MAX — and
how many rows it has in all, so a cut bundle can say so.""" how many rows it has in all, so a cut bundle can say so."""
rows = list( rows = list(
db.scalars( db.scalars(
select(LogEvent) select(LogEvent)
.where(LogEvent.debug_session_id == session.id) .where(*_evidence(session))
.order_by(LogEvent.ts, LogEvent.id) .order_by(LogEvent.ts, LogEvent.id)
.limit(BUNDLE_MAX) .limit(BUNDLE_MAX)
) )
@@ -230,11 +245,7 @@ def bundle(db: Session, session: DebugSession) -> tuple[list[LogEvent], int]:
total = len(rows) total = len(rows)
if total == BUNDLE_MAX: if total == BUNDLE_MAX:
total = ( total = (
db.scalar( db.scalar(select(func.count()).select_from(LogEvent).where(*_evidence(session)))
select(func.count())
.select_from(LogEvent)
.where(LogEvent.debug_session_id == session.id)
)
or total or total
) )
return rows, total return rows, total
+4
View File
@@ -27,6 +27,9 @@ DiagnosticsLevel = Literal["INFO", "DEBUG", "TRACE"]
LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel) LEVELS: tuple[str, ...] = get_args(DiagnosticsLevel)
BASE: DiagnosticsLevel = "INFO" BASE: DiagnosticsLevel = "INFO"
ELEVATION_HOURS = 24 ELEVATION_HOURS = 24
# How long a debug session records at most (E16, app/debugsession.py). Here, beside the other
# consent window, because the start dialog's consent text states it and reads it from `state`.
SESSION_MINUTES = 60
def effective_level(user: User, now: datetime | None = None) -> DiagnosticsLevel: def effective_level(user: User, now: datetime | None = None) -> DiagnosticsLevel:
@@ -47,6 +50,7 @@ def state(db: Session, user: User) -> dict:
"level": level, "level": level,
"until": user.diagnostics_until.isoformat() if level != BASE else None, "until": user.diagnostics_until.isoformat() if level != BASE else None,
"retention_days": sysconfig.get_int(db, "log_retention_days"), "retention_days": sysconfig.get_int(db, "log_retention_days"),
"session_minutes": SESSION_MINUTES,
} }
+16 -6
View File
@@ -335,15 +335,16 @@ def test_the_evidence_is_the_sessions_rows_in_time_order(db, make_user, as_user)
sid = _start(client)["id"] sid = _start(client)["id"]
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
def row(ts, source, session): def row(ts, source, session, event="x.y", attrs=None):
return LogEvent( return LogEvent(
ts=ts, ts=ts,
level="INFO", level="INFO",
sev=9, sev=9,
source=source, source=source,
logger=source, logger=source,
event="x.y", event=event,
msg=source, msg=source,
attrs=attrs,
user_id=user.id, user_id=user.id,
debug_session_id=session, debug_session_id=session,
) )
@@ -353,15 +354,20 @@ def test_the_evidence_is_the_sessions_rows_in_time_order(db, make_user, as_user)
row(now + timedelta(seconds=2), "api", sid), row(now + timedelta(seconds=2), "api", sid),
row(now + timedelta(seconds=1), "web", sid), row(now + timedelta(seconds=1), "web", sid),
row(now, "web", None), row(now, "web", None),
# The recording's own log upload is plumbing, not evidence…
row(now, "api", sid, "http.request", {"path": debugsession.INGEST_PATH}),
# …but a server line with no event code and no attrs is evidence all the same.
row(now + timedelta(seconds=3), "api", sid, None),
] ]
) )
db.commit() db.commit()
body = client.get(f"/api/debug-sessions/{sid}/events").json() body = client.get(f"/api/debug-sessions/{sid}/events").json()
assert [(i["source"], i["debug_session_id"]) for i in body["items"]] == [ assert [(i["source"], i["event"]) for i in body["items"]] == [
("web", sid), ("web", "x.y"),
("api", sid), ("api", "x.y"),
("api", None),
] ]
assert body["total"] == 2 assert body["total"] == 3
# --- screenshots ---------------------------------------------------------------------------- # --- screenshots ----------------------------------------------------------------------------
@@ -436,3 +442,7 @@ def test_a_screenshot_can_be_removed_and_is_then_gone(recording):
assert r.json() == {"ok": True, "deleted": shot} assert r.json() == {"ok": True, "deleted": shot}
r = client.get(f"/api/debug-sessions/{sid}/screenshots/{shot}") r = client.get(f"/api/debug-sessions/{sid}/screenshots/{shot}")
assert r.status_code == 404 and r.json()["detail"]["reason"] == "screenshot" assert r.status_code == 404 and r.json()["detail"]["reason"] == "screenshot"
def test_the_upload_path_left_out_of_the_evidence_is_the_real_one():
assert debugsession.INGEST_PATH in {r.path for r in diagnostics_routes.router.routes}
+2
View File
@@ -68,6 +68,7 @@ def test_raising_the_level_sets_the_servers_expiry_whatever_the_client_sends(db,
"level": "INFO", "level": "INFO",
"until": None, "until": None,
"retention_days": 7, "retention_days": 7,
"session_minutes": 60,
} }
@@ -82,6 +83,7 @@ def test_a_lapsed_level_reads_as_the_base_in_me(db, client, make_user):
"level": "INFO", "level": "INFO",
"until": None, "until": None,
"retention_days": 7, "retention_days": 7,
"session_minutes": 60,
} }
+13 -4
View File
@@ -34,6 +34,7 @@ import BackToTop from "./components/ui/BackToTop";
import PageShell from "./components/ui/PageShell"; import PageShell from "./components/ui/PageShell";
import GlassTuner from "./components/GlassTuner"; import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock"; import ChatDock from "./components/ChatDock";
import DebugRecorder from "./components/DebugRecorder";
import Toaster from "./components/ui/Toaster"; import Toaster from "./components/ui/Toaster";
import KeyboardHelp from "./components/ui/KeyboardHelp"; import KeyboardHelp from "./components/ui/KeyboardHelp";
import AppCommandPalette from "./components/AppCommandPalette"; import AppCommandPalette from "./components/AppCommandPalette";
@@ -147,12 +148,18 @@ export default function App() {
); );
// The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is // The account's consented diagnostics level (E22 S5), applied to this tab's log: raised, the log is
// kept in more detail and streamed to the server until the level lapses. A lapse (its own timer, or // kept in more detail and streamed to the server until the level lapses. A debug session that is
// the server saying it is off) refetches `me`, so Settings shows the base level again. // recording (E16) raises it for its own run and tags what it keeps. A lapse (its own timer, or the
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
const diagnostics = meQuery.data?.diagnostics; const diagnostics = meQuery.data?.diagnostics;
const debugSession = meQuery.data?.debug_session;
useEffect(() => { useEffect(() => {
applyDiagnostics(diagnostics, () => void queryClient.invalidateQueries({ queryKey: qk.me() })); applyDiagnostics(
}, [diagnostics?.level, diagnostics?.until]); // eslint-disable-line react-hooks/exhaustive-deps diagnostics,
debugSession,
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
);
}, [diagnostics?.level, diagnostics?.until, debugSession?.id, debugSession?.ended_at]); // eslint-disable-line react-hooks/exhaustive-deps
// Fetch the code of every module this account can open — and the channel page — once the first page // Fetch the code of every module this account can open — and the channel page — once the first page
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback. // is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
@@ -411,6 +418,8 @@ export default function App() {
<KeyboardHelp /> <KeyboardHelp />
<AppCommandPalette /> <AppCommandPalette />
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />} {meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
{/* The debug recorder (E16): its start dialog, and its pill docked under the toasts. */}
{meQuery.data && !meQuery.data.is_demo && <DebugRecorder />}
<Suspense fallback={null}> <Suspense fallback={null}>
{aboutOpen && ( {aboutOpen && (
<About <About
@@ -10,7 +10,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CommandGroup } from "./ui/CommandPalette"; import type { CommandGroup } from "./ui/CommandPalette";
vi.mock("react-i18next", () => ({ vi.mock("react-i18next", async (importOriginal) => ({
// The rest of the module too: the recorder's actions reach `i18n/index`, which needs it.
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
})); }));
@@ -140,4 +142,21 @@ describe("AppCommandPalette", () => {
then?.(); then?.();
expect(requestSurprise).toHaveBeenCalledTimes(1); expect(requestSurprise).toHaveBeenCalledTimes(1);
}); });
it("offers the debug recorder by its state, and not to the demo account", () => {
const recorder = () => ids("actions").filter((id) => id.startsWith("action:recorder:"));
mount();
expect(recorder()).toEqual(["action:recorder:start"]);
cleanup();
const until = new Date(Date.now() + 60_000).toISOString();
me = user({ debug_session: { id: "0123456789abcdef", until, ended_at: null } });
mount();
expect(recorder()).toEqual(["action:recorder:step", "action:recorder:stop"]);
cleanup();
me = user({ is_demo: true });
mount();
expect(recorder()).toEqual([]);
});
}); });
+33 -1
View File
@@ -1,7 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Keyboard, ListVideo, Palette, Sparkles, Tv } from "lucide-react"; import { Bug, Flag, Keyboard, ListVideo, Palette, Sparkles, Square, Tv } from "lucide-react";
import CommandPalette, { type CommandGroup } from "./ui/CommandPalette"; import CommandPalette, { type CommandGroup } from "./ui/CommandPalette";
import { api } from "../lib/api"; import { api } from "../lib/api";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
@@ -10,6 +10,8 @@ import { moduleDef, moduleOrder } from "../lib/modules";
import { SCHEMES, type Scheme } from "../lib/theme"; import { SCHEMES, type Scheme } from "../lib/theme";
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap"; import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
import { requestSurprise } from "../lib/homeIntent"; import { requestSurprise } from "../lib/homeIntent";
import { openRecorderStart } from "../lib/debugRecorder";
import { useDebugRecording } from "./useDebugRecording";
import { useNavigationActions } from "./navigationContext"; import { useNavigationActions } from "./navigationContext";
import { usePlaylistsActions } from "./playlistsContext"; import { usePlaylistsActions } from "./playlistsContext";
import { usePrefs } from "./prefsContext"; import { usePrefs } from "./prefsContext";
@@ -34,6 +36,7 @@ export default function AppCommandPalette() {
const { setSelectedId } = usePlaylistsActions(); const { setSelectedId } = usePlaylistsActions();
const { theme, setTheme } = usePrefs(); const { theme, setTheme } = usePrefs();
const savedViews = useSavedViews(); const savedViews = useSavedViews();
const recording = useDebugRecording();
// Only fetch the lists while the palette is actually open — it is a rarely-opened surface and // Only fetch the lists while the palette is actually open — it is a rarely-opened surface and
// these are otherwise the feed's and the playlist rail's own queries, shared through the cache. // these are otherwise the feed's and the playlist rail's own queries, shared through the cache.
@@ -147,6 +150,34 @@ export default function AppCommandPalette() {
icon: <Keyboard className="w-4 h-4" />, icon: <Keyboard className="w-4 h-4" />,
run: toggleKeyboardHelp, run: toggleKeyboardHelp,
}, },
// The debug recorder (E16) — not for the shared demo account, which cannot record. While
// it runs, Stop and a step marker take Start's place: the palette is reachable inside a
// player or a dialog where the pill's buttons are a long Tab away.
...(me?.is_demo
? []
: recording.session
? [
{
id: "action:recorder:step",
label: t("recorder.palette.step"),
icon: <Flag className="w-4 h-4" />,
run: () => recording.markStep(""),
},
{
id: "action:recorder:stop",
label: t("recorder.palette.stop"),
icon: <Square className="w-4 h-4" />,
run: () => recording.stop.mutate(recording.session!.id),
},
]
: [
{
id: "action:recorder:start",
label: t("recorder.palette.start"),
icon: <Bug className="w-4 h-4" />,
run: openRecorderStart,
},
]),
...SCHEMES.map((s) => ({ ...SCHEMES.map((s) => ({
id: `action:theme:${s.id}`, id: `action:theme:${s.id}`,
label: t("palette.theme", { name: s.name }), label: t("palette.theme", { name: s.name }),
@@ -166,6 +197,7 @@ export default function AppCommandPalette() {
savedViews, savedViews,
theme, theme,
setTheme, setTheme,
recording,
setPage, setPage,
openChannel, openChannel,
setSelectedId, setSelectedId,
@@ -0,0 +1,203 @@
// @vitest-environment jsdom
//
// E16 S2: the debug recorder. Its pill rides the toast hosts (lib/toastDock) and stays out of their
// live region; Start is consent-first; Stop sends what is waiting BEFORE the server closes the
// session; a step is one tagged event; Escape in the step prompt closes only the prompt, never the
// dialog the pill sits in. i18n is a key-passthrough with its params; the API is stubbed and `me`
// lives in a real query cache, as in the app.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DebugSession, Me } from "../lib/api";
import { qk } from "../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && Object.keys(opts).length ? `${key}:${Object.values(opts).join(",")}` : key,
i18n: { language: "en" },
}),
};
});
const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() => {
const order: string[] = [];
const session = (over: Partial<DebugSession> = {}): DebugSession => ({
id: "0123456789abcdef",
level: "DEBUG",
started_at: new Date().toISOString(),
until: new Date(Date.now() + 3_600_000).toISOString(),
ended_at: null,
ended_reason: null,
issue_url: null,
screenshots: 0,
...over,
});
const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") =>
Promise.resolve({ ok: true as const, session: session({ level }) }),
);
const stopDebugSession = vi.fn((id: string) => {
order.push("stop");
return Promise.resolve({
ok: true as const,
session: session({ id, ended_at: new Date().toISOString(), ended_reason: "stopped" }),
});
});
return { order, session, startDebugSession, stopDebugSession };
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
return { ...actual, api: { ...actual.api, startDebugSession, stopDebugSession } };
});
vi.mock("../lib/diagnostics", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/diagnostics")>();
return {
...actual,
flushNow: () => {
order.push("flush");
return Promise.resolve();
},
};
});
import DebugRecorder from "./DebugRecorder";
import Modal from "./ui/Modal";
import Toaster from "./ui/Toaster";
import { closeComposer, openRecorderStart, useComposerSession } from "../lib/debugRecorder";
import { events } from "../lib/log";
beforeEach(() => {
order.length = 0;
sessionStorage.clear();
});
afterEach(() => {
cleanup();
closeComposer();
vi.clearAllMocks();
vi.useRealTimers();
});
let composing: string | null = null;
function ComposerProbe() {
composing = useComposerSession();
return null;
}
function mount(me: Partial<Me>, extra: React.ReactNode = null) {
const qc = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
qc.setQueryData(qk.me(), {
id: 1,
is_demo: false,
preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
debug_session: null,
...me,
});
render(
<QueryClientProvider client={qc}>
<Toaster />
<DebugRecorder />
<ComposerProbe />
{extra}
</QueryClientProvider>,
);
return qc;
}
const pill = () => screen.queryByRole("group", { name: "recorder.pill.label" });
describe("the recorder pill", () => {
it("shows while a session records, under the toasts and outside their live region", () => {
mount({ debug_session: session() });
const p = pill()!;
expect(p).not.toBeNull();
expect(p.closest("[data-toast-region]")).not.toBeNull();
expect(p.closest("[aria-live]")).toBeNull();
});
it("is not there for a session that ended or ran past its cap", () => {
mount({ debug_session: session({ until: new Date(Date.now() - 1000).toISOString() }) });
expect(pill()).toBeNull();
cleanup();
mount({ debug_session: session({ ended_at: new Date().toISOString() }) });
expect(pill()).toBeNull();
});
it("Stop sends what is waiting first, then stops, then opens the composer", async () => {
const qc = mount({ debug_session: session() });
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.stop" }));
await waitFor(() => expect(stopDebugSession).toHaveBeenCalledWith("0123456789abcdef"));
expect(order).toEqual(["flush", "stop"]);
await waitFor(() => expect(pill()).toBeNull());
expect(qc.getQueryData<Me>(qk.me())?.debug_session).toBeNull();
expect(composing).toBe("0123456789abcdef");
});
it("a step is one tagged event, numbered, and the focus comes back to Step", () => {
mount({ debug_session: session() });
const step = within(pill()!).getByRole("button", { name: "recorder.pill.step" });
fireEvent.click(step);
const input = screen.getByLabelText("recorder.pill.stepPrompt:1");
expect(document.activeElement).toBe(input);
fireEvent.change(input, { target: { value: "opened the player" } });
fireEvent.keyDown(input, { key: "Enter" });
const marked = events().filter((e) => e.event === "debug.step");
expect(marked.slice(-1)[0]?.attrs).toMatchObject({ n: 1, label: "opened the player" });
expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull();
expect(document.activeElement).toBe(step);
expect(step.textContent).toContain("· 1");
});
it("Escape in the step prompt closes the prompt, not the dialog the pill sits in", () => {
const onClose = vi.fn();
mount(
{ debug_session: session() },
<Modal title="dialog" onClose={onClose}>
<p>inside</p>
</Modal>,
);
// The pill moved into the dialog's own toast host.
expect(pill()!.closest('[role="dialog"]')).not.toBeNull();
const marked = () => events().filter((e) => e.event === "debug.step").length;
const before = marked();
fireEvent.click(within(pill()!).getByRole("button", { name: "recorder.pill.step" }));
fireEvent.keyDown(screen.getByLabelText(/recorder.pill.stepPrompt/), { key: "Escape" });
expect(screen.queryByLabelText(/recorder.pill.stepPrompt/)).toBeNull();
expect(onClose).not.toHaveBeenCalled();
expect(marked()).toBe(before);
});
it("offers a screenshot only where the browser can capture a tab", () => {
mount({ debug_session: session() });
// jsdom has no Screen Capture API — like iOS Safari.
expect(within(pill()!).queryByRole("button", { name: "recorder.pill.screenshot" })).toBeNull();
});
it("at its cap it says so and opens the composer", () => {
vi.useFakeTimers();
mount({ debug_session: session({ until: new Date(Date.now() + 5_000).toISOString() }) });
act(() => void vi.advanceTimersByTime(5_000));
expect(screen.getByText("recorder.pill.expired:60")).not.toBeNull();
expect(composing).toBe("0123456789abcdef");
});
});
describe("the start dialog", () => {
it("states the retention and the cap, and starts at the level chosen", async () => {
const qc = mount({});
act(() => openRecorderStart());
expect(screen.getByText(/recorder.start.kept:7/)).not.toBeNull();
expect(screen.getByText(/recorder.start.cap:60/)).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "settings.diagnostics.levels.TRACE" }));
expect(screen.getByText("recorder.start.what.TRACE")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: /recorder.start.confirm/ }));
await waitFor(() => expect(startDebugSession).toHaveBeenCalledWith("TRACE"));
await waitFor(() => expect(pill()).not.toBeNull());
expect(qc.getQueryData<Me>(qk.me())?.debug_session?.level).toBe("TRACE");
expect(screen.queryByText("recorder.start.title")).toBeNull();
});
});
+296
View File
@@ -0,0 +1,296 @@
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Camera, Flag, Square } from "lucide-react";
import type { DebugSessionLevel } from "../lib/api";
import { canCaptureTab } from "../lib/captureTab";
import {
closeRecorderStart,
openComposer,
openRecorderStart,
useRecorderStartOpen,
useStepCount,
} from "../lib/debugRecorder";
import { useLayer } from "../lib/layerStack";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { dockUnderToasts } from "../lib/toastDock";
import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import SegmentedToggle from "./ui/SegmentedToggle";
import { inputCls } from "./ui/form";
import { useDebugRecording } from "./useDebugRecording";
// The debug recorder (E16): Start → reproduce → Stop.
//
// While a session records, its pill sits under the toasts (lib/toastDock), so it rides every toast
// host — the page, each dialog, both players, the fullscreen player — and is reachable wherever the
// bug is being reproduced, above a player's control band rather than on it. Mounted once, in the
// app shell, for a signed-in account other than the demo.
/** `m:ss` since `since`, re-rendered every second. */
function useElapsed(since: string): string {
const [, tick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => tick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, []);
const s = Math.max(0, Math.floor((Date.now() - Date.parse(since)) / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
export default function DebugRecorder() {
const { t } = useTranslation();
const qc = useQueryClient();
const me = useMe();
const { session } = useDebugRecording();
const startOpen = useRecorderStartOpen();
const id = session?.id;
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
// The cap: the server ends the session at `until` whether or not anyone stops it. This tab says
// so, and opens the composer for it — the recording is as useful as one stopped by hand.
const until = session?.until;
const minutes = me?.diagnostics?.session_minutes;
useEffect(() => {
if (!id || !until) return;
const wait = Math.min(Date.parse(until) - Date.now(), 2 ** 31 - 1);
const timer = window.setTimeout(() => {
notify({ level: "warning", message: t("recorder.pill.expired", { count: minutes ?? 0 }) });
openComposer(id);
void qc.invalidateQueries({ queryKey: qk.me() });
}, wait);
return () => window.clearTimeout(timer);
}, [id, until, minutes, qc, t]);
return startOpen ? <StartDialog /> : null;
}
function StartDialog() {
const { t } = useTranslation();
const me = useMe();
const { start } = useDebugRecording();
const [level, setLevel] = useState<DebugSessionLevel>("DEBUG");
const days = me.diagnostics.retention_days;
const minutes = me.diagnostics.session_minutes;
const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"];
return (
<Modal title={t("recorder.start.title")} onClose={closeRecorderStart}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted leading-relaxed">{t("recorder.start.intro")}</p>
<div className="flex flex-col items-start gap-1.5">
<span className="text-sm text-muted">{t("recorder.start.level")}</span>
<SegmentedToggle
size="md"
ariaLabel={t("recorder.start.level")}
options={levels.map((l) => ({
value: l,
label: t(`settings.diagnostics.levels.${l}`),
}))}
value={level}
onChange={setLevel}
/>
</div>
<div className="rounded-lg border border-border bg-surface/60 px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed">
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
<p>{t(`recorder.start.what.${level}`)}</p>
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
<p className="text-muted">
{t("recorder.start.kept", { count: days })}{" "}
{t("recorder.start.cap", { count: minutes })}
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={closeRecorderStart}>
{t("common.cancel")}
</Button>
<Button
variant="primary"
loading={start.isPending}
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
onClick={() => start.mutate(level)}
>
{t("recorder.start.confirm")}
</Button>
</div>
</div>
</Modal>
);
}
/** The pill: how long it has been recording, and the three things to do while it does. */
function RecorderPill() {
const { t } = useTranslation();
const { session, stop, shoot, markStep, screenshot } = useDebugRecording();
const [hidden, setHidden] = useState(false);
const [stepOpen, setStepOpen] = useState(false);
const stepButton = useRef<HTMLButtonElement>(null);
const steps = useStepCount(session?.id ?? "");
if (!session) return null;
return (
<div
role="group"
aria-label={t("recorder.pill.label")}
className={`relative glass rounded-full h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 shadow-lg ${
hidden ? "invisible" : ""
}`}
>
<span
aria-hidden
className="w-2.5 h-2.5 shrink-0 rounded-full bg-danger ring-4 ring-danger/20 motion-safe:animate-pulse"
/>
<Elapsed since={session.started_at} />
<span className="chip hidden sm:inline-flex px-2 py-0.5 text-2xs">
{t(`settings.diagnostics.levels.${session.level}`)}
</span>
<span aria-hidden className="hidden sm:block w-px h-6 bg-border" />
<Button
ref={stepButton}
variant="outline-accent"
size="sm"
className="rounded-full"
icon={<Flag className="w-4 h-4" />}
label={t("recorder.pill.step")}
aria-expanded={stepOpen}
onClick={() => setStepOpen((v) => !v)}
>
<span className="hidden sm:inline tabular-nums">
{t("recorder.pill.step")}
{steps ? ` · ${steps}` : ""}
</span>
</Button>
{canCaptureTab() && (
<Button
variant="outline"
size="sm"
className="rounded-full"
icon={<Camera className="w-4 h-4" />}
label={t("recorder.pill.screenshot")}
loading={shoot.isPending}
onClick={() => void screenshot(setHidden)}
>
<span className="hidden sm:inline tabular-nums">
{t("recorder.pill.screenshot")}
{session.screenshots ? ` · ${session.screenshots}` : ""}
</span>
</Button>
)}
<Button
variant="danger"
size="sm"
className="rounded-full"
icon={<Square className="w-3.5 h-3.5 fill-current" />}
label={t("recorder.pill.stop")}
loading={stop.isPending}
onClick={() => stop.mutate(session.id)}
>
<span className="hidden sm:inline">{t("recorder.pill.stop")}</span>
</Button>
{stepOpen && (
<StepPrompt
id={session.id}
onDone={(label) => {
if (label !== null) markStep(label);
setStepOpen(false);
stepButton.current?.focus();
}}
/>
)}
</div>
);
}
function Elapsed({ since }: { since: string }) {
const { t } = useTranslation();
const time = useElapsed(since);
return (
// On a phone only the clock: the pill has to fit the content column beside the rail, and the
// red dot already says what it is.
<span className="text-sm font-semibold tabular-nums whitespace-nowrap">
<span className="hidden sm:inline">{t("recorder.pill.recording", { time })}</span>
<span className="sm:hidden">{time}</span>
</span>
);
}
/** Settings → Diagnosztika: start a recording, or see the one running and stop it. */
export function RecorderSettings() {
const { t } = useTranslation();
const { session, stop } = useDebugRecording();
if (!session)
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted max-w-prose">{t("recorder.settings.hint")}</p>
<Button
variant="outline"
icon={<span className="w-2.5 h-2.5 rounded-full bg-danger" aria-hidden />}
onClick={openRecorderStart}
>
{t("recorder.settings.start")}
</Button>
</div>
);
return (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-danger/40 bg-danger/10 px-3 py-2 text-sm">
<span className="inline-flex items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-danger" aria-hidden />
<SettingsElapsed since={session.started_at} />
</span>
<Button
variant="danger-soft"
size="sm"
loading={stop.isPending}
onClick={() => stop.mutate(session.id)}
>
{t("recorder.settings.stop")}
</Button>
</div>
);
}
function SettingsElapsed({ since }: { since: string }) {
const { t } = useTranslation();
const time = useElapsed(since);
return <span className="tabular-nums">{t("recorder.settings.recording", { time })}</span>;
}
/** "What did you just do?" — optional; Enter marks the step, Escape leaves it unmarked. */
function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null) => void }) {
const { t } = useTranslation();
const n = useStepCount(id) + 1;
const [label, setLabel] = useState("");
// A layer of its own, so Escape closes only the prompt — not the dialog or the player the pill may
// be sitting in: the topmost layer takes Escape (lib/layerStack), and this one opened last.
useLayer(true, () => onDone(null));
const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
onDone(label);
}
};
return (
<div className="absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] glass rounded-xl p-3 flex flex-col gap-2 shadow-lg">
<label htmlFor="debug-step-label" className="text-sm text-muted">
{t("recorder.pill.stepPrompt", { n })}
</label>
<input
id="debug-step-label"
autoFocus
value={label}
onChange={(e) => setLabel(e.target.value)}
onKeyDown={onKeyDown}
maxLength={200}
className={`${inputCls} w-full`}
/>
<div className="flex items-center justify-between gap-2 text-xs text-muted">
<span>{t("recorder.pill.stepHint")}</span>
<Button variant="primary" size="sm" onClick={() => onDone(label)}>
{t("recorder.pill.mark")}
</Button>
</div>
</div>
);
}
@@ -37,6 +37,7 @@ const setLevel = vi.fn(
level, level,
until: level === "INFO" ? null : new Date(Date.now() + 86_400_000).toISOString(), until: level === "INFO" ? null : new Date(Date.now() + 86_400_000).toISOString(),
retention_days: 7, retention_days: 7,
session_minutes: 60,
}, },
}), }),
); );
@@ -61,7 +62,7 @@ function mount(me: Partial<Me>) {
id: 1, id: 1,
is_demo: false, is_demo: false,
preferences: {}, preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7 }, diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
...me, ...me,
}); });
render( render(
@@ -131,7 +132,7 @@ describe("DiagnosticsSettings", () => {
it("going back to the base level needs no consent", async () => { it("going back to the base level needs no consent", async () => {
const until = new Date(Date.now() + 3_600_000).toISOString(); const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } }); mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } });
fireEvent.click(levelButton("settings.diagnostics.levels.INFO")); fireEvent.click(levelButton("settings.diagnostics.levels.INFO"));
expect(screen.queryByTestId("diagnostics-consent")).toBeNull(); expect(screen.queryByTestId("diagnostics-consent")).toBeNull();
await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO")); await waitFor(() => expect(setLevel).toHaveBeenCalledWith("INFO"));
@@ -169,7 +170,7 @@ describe("DiagnosticsSettings", () => {
try { try {
// Raised, so the status line's countdown is mounted and its 30 s tick keeps re-rendering. // Raised, so the status line's countdown is mounted and its 30 s tick keeps re-rendering.
const until = new Date(Date.now() + 3_600_000).toISOString(); const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } }); mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } });
fireEvent.click(levelButton("settings.diagnostics.levels.TRACE")); fireEvent.click(levelButton("settings.diagnostics.levels.TRACE"));
const elsewhere = document.createElement("button"); const elsewhere = document.createElement("button");
document.body.append(elsewhere); document.body.append(elsewhere);
@@ -194,7 +195,7 @@ describe("DiagnosticsSettings", () => {
it("the ticking status line is not a live region", async () => { it("the ticking status line is not a live region", async () => {
const until = new Date(Date.now() + 3_600_000).toISOString(); const until = new Date(Date.now() + 3_600_000).toISOString();
mount({ diagnostics: { level: "DEBUG", until, retention_days: 7 } }); mount({ diagnostics: { level: "DEBUG", until, retention_days: 7, session_minutes: 60 } });
const status = screen.getByTestId("diagnostics-status"); const status = screen.getByTestId("diagnostics-status");
expect(status.closest('[role="status"], [aria-live]')).toBeNull(); expect(status.closest('[role="status"], [aria-live]')).toBeNull();
}); });
@@ -215,7 +216,7 @@ describe("DiagnosticsSettings", () => {
id: 1, id: 1,
is_demo: false, is_demo: false,
preferences: {}, preferences: {},
diagnostics: { level: "INFO", until: null, retention_days: 7 }, diagnostics: { level: "INFO", until: null, retention_days: 7, session_minutes: 60 },
}); });
await stale; await stale;
expect(qc.getQueryData<Me>(qk.me())?.diagnostics.level).toBe("TRACE"); expect(qc.getQueryData<Me>(qk.me())?.diagnostics.level).toBe("TRACE");
@@ -224,7 +225,7 @@ describe("DiagnosticsSettings", () => {
it("a lapsed level shows as the base level", () => { it("a lapsed level shows as the base level", () => {
const until = new Date(Date.now() - 60_000).toISOString(); const until = new Date(Date.now() - 60_000).toISOString();
mount({ diagnostics: { level: "TRACE", until, retention_days: 7 } }); mount({ diagnostics: { level: "TRACE", until, retention_days: 7, session_minutes: 60 } });
expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe( expect(levelButton("settings.diagnostics.levels.INFO").getAttribute("aria-pressed")).toBe(
"true", "true",
); );
@@ -10,6 +10,7 @@ import InlineNotice from "./ui/InlineNotice";
import SegmentedToggle from "./ui/SegmentedToggle"; import SegmentedToggle from "./ui/SegmentedToggle";
import { Section, SettingRow } from "./ui/form"; import { Section, SettingRow } from "./ui/form";
import MyLogViewer from "./MyLogViewer"; import MyLogViewer from "./MyLogViewer";
import { RecorderSettings } from "./DebugRecorder";
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"]; const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
@@ -181,6 +182,12 @@ export default function DiagnosticsSettings() {
)} )}
</Section> </Section>
{!me.is_demo && (
<Section title={t("recorder.settings.title")}>
<RecorderSettings />
</Section>
)}
<Section title={t("settings.myLog.title")}> <Section title={t("settings.myLog.title")}>
<MyLogViewer /> <MyLogViewer />
</Section> </Section>
@@ -0,0 +1,99 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { captureTab } from "../lib/captureTab";
import { closeRecorderStart, nextStep, openComposer } from "../lib/debugRecorder";
import { flushNow, liveSession } from "../lib/diagnostics";
import { log } from "../lib/log";
import { notify } from "../lib/notifications";
import { updateMeCache, useMe } from "../lib/useMe";
/** The debug recording's actions (E16), shared by everything that drives it: the pill, Settings →
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
* answer, so a reload, another tab or the cap all show the same thing. */
export function useDebugRecording() {
const { t } = useTranslation();
const qc = useQueryClient();
const me = useMe();
const session = liveSession(me?.debug_session);
const setSession = (next: DebugSession | null) =>
updateMeCache(qc, (prev) => ({ ...prev, debug_session: next }));
const start = useMutation({
mutationFn: (level: DebugSessionLevel) => api.startDebugSession(level),
meta: { failTitle: "recorder.start.failed" },
onSuccess: (r) => {
setSession(r.session);
closeRecorderStart();
},
});
const stop = useMutation({
mutationFn: async (id: string) => {
log.info("debug.stop", "recording stopped by the user");
// The events of the last seconds before Stop are the ones closest to what went wrong. Sent
// while the session still tags them: once it is closed the server no longer does.
await flushNow();
return api.stopDebugSession(id);
},
meta: { failTitle: "recorder.pill.stopFailed" },
onSuccess: (r) => {
setSession(null);
notify({ level: "success", message: t("recorder.pill.stopped") });
openComposer(r.session.id);
},
});
const shoot = useMutation({
mutationFn: async ({ id, image }: { id: string; image: Blob }) =>
api.addDebugScreenshot(id, image),
meta: { failTitle: "recorder.pill.shotFailed" },
onSuccess: (r) => {
updateMeCache(qc, (prev) =>
prev.debug_session
? {
...prev,
debug_session: {
...prev.debug_session,
screenshots: prev.debug_session.screenshots + 1,
},
}
: prev,
);
// In the evidence too, where the step list is drafted from: which step it belongs to.
log.info("debug.screenshot", "screenshot taken", { screenshot: r.screenshot.id });
notify({ level: "success", message: t("recorder.pill.shotSaved") });
},
});
/** Mark the next step; `label` is what the user says they just did (may be empty). */
const markStep = (label: string) => {
if (!session) return;
const n = nextStep(session.id);
const text = label.trim();
log.info("debug.step", text || `step ${n}`, { n, ...(text ? { label: text } : {}) });
notify({ level: "info", message: t("recorder.pill.stepMarked", { n }) });
};
/** Capture this tab and attach it. `hide` runs around the capture so the pill is not in the
* frame. Nothing happens when the user cancels the browser's picker. */
const screenshot = async (hide: (hidden: boolean) => void) => {
if (!session) return;
hide(true);
let image: Blob | null = null;
try {
// Two frames: the pill's `invisible` must be painted before the capture reads the tab.
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
image = await captureTab();
} catch (err) {
notifyActionError(err, t("recorder.pill.shotFailed"));
} finally {
hide(false);
}
if (image) shoot.mutate({ id: session.id, image });
};
return { session, start, stop, shoot, markStep, screenshot };
}
@@ -0,0 +1,47 @@
{
"start": {
"title": "Debug session",
"intro": "Start it, go through whatever breaks, then stop it. You get a draft bug report with the log attached, and you can review everything before it is sent.",
"level": "Detail",
"whatTitle": "What it records",
"what": {
"DEBUG": "Page changes, requests and errors, which button, filter and player control you use, and the filters' values.",
"TRACE": "Page changes, requests and errors, which button, filter and player control you use, the filters' values, and what you type into search."
},
"kept_one": "The server keeps it for {{count}} day.",
"kept_other": "The server keeps it for {{count}} days.",
"cap_one": "It runs for {{count}} minute at most, then stops on its own. When it stops, your previous log level is back.",
"cap_other": "It runs for {{count}} minutes at most, then stops on its own. When it stops, your previous log level is back.",
"confirm": "Start recording",
"failed": "Couldn't start recording"
},
"pill": {
"label": "Debug recording",
"recording": "Recording · {{time}}",
"step": "Step",
"screenshot": "Screenshot",
"stop": "Stop",
"stepPrompt": "Step {{n}} — what did you just do? (optional)",
"stepHint": "Enter: mark · Esc: cancel",
"mark": "Mark",
"stepMarked": "Step {{n}} marked",
"shotSaved": "Screenshot saved",
"shotFailed": "Couldn't save the screenshot",
"stopFailed": "Couldn't stop recording",
"stopped": "Recording stopped",
"expired_one": "The recording reached its {{count}}-minute limit and stopped.",
"expired_other": "The recording reached its {{count}}-minute limit and stopped."
},
"settings": {
"title": "Debug session",
"hint": "When something breaks: start recording, go through it again, then stop. A draft bug report is made from the log.",
"start": "Start recording",
"recording": "Recording · {{time}}",
"stop": "Stop"
},
"palette": {
"start": "Start a debug recording",
"step": "Mark a step in the recording",
"stop": "Stop the debug recording"
}
}
@@ -0,0 +1,47 @@
{
"start": {
"title": "Hibakereső munkamenet",
"intro": "Indítsd el, csináld végig, ami elromlik, aztán állítsd le. A végén hibajegy-vázlatot kapsz a naplóval együtt, és elküldés előtt mindent átnézhetsz.",
"level": "Részletesség",
"whatTitle": "Mit rögzít",
"what": {
"DEBUG": "Az oldalváltásokat, a kéréseket és a hibákat, melyik gombot, szűrőt és lejátszóvezérlőt használod, és a szűrők értékeit.",
"TRACE": "Az oldalváltásokat, a kéréseket és a hibákat, melyik gombot, szűrőt és lejátszóvezérlőt használod, a szűrők értékeit, és a keresőbe beírt szöveget is."
},
"kept_one": "A szerver {{count}} napig őrzi.",
"kept_other": "A szerver {{count}} napig őrzi.",
"cap_one": "Legfeljebb {{count}} percig fut, utána magától leáll. Leállítás után a korábbi naplózási szinted áll vissza.",
"cap_other": "Legfeljebb {{count}} percig fut, utána magától leáll. Leállítás után a korábbi naplózási szinted áll vissza.",
"confirm": "Rögzítés indítása",
"failed": "Nem sikerült elindítani a rögzítést"
},
"pill": {
"label": "Hibakereső rögzítés",
"recording": "Rögzítés · {{time}}",
"step": "Lépés",
"screenshot": "Képernyőkép",
"stop": "Leállítás",
"stepPrompt": "{{n}}. lépés — mit csináltál most? (nem kötelező)",
"stepHint": "Enter: megjelöl · Esc: mégse",
"mark": "Megjelöl",
"stepMarked": "{{n}}. lépés megjelölve",
"shotSaved": "Képernyőkép elmentve",
"shotFailed": "Nem sikerült elmenteni a képernyőképet",
"stopFailed": "Nem sikerült leállítani a rögzítést",
"stopped": "Rögzítés leállítva",
"expired_one": "A rögzítés elérte a {{count}} perces határt, és leállt.",
"expired_other": "A rögzítés elérte a {{count}} perces határt, és leállt."
},
"settings": {
"title": "Hibakereső munkamenet",
"hint": "Ha valami elromlik: indítsd el a rögzítést, csináld végig újra, aztán állítsd le. A naplóból hibajegy-vázlat készül.",
"start": "Rögzítés indítása",
"recording": "Rögzítés folyamatban · {{time}}",
"stop": "Leállítás"
},
"palette": {
"start": "Hibakereső rögzítés indítása",
"step": "Lépés megjelölése a rögzítésben",
"stop": "Hibakereső rögzítés leállítása"
}
}
+2 -1
View File
@@ -68,7 +68,7 @@ import { messagingApi } from "./api/messaging";
export type * from "./api/messaging"; export type * from "./api/messaging";
import { downloadsApi } from "./api/downloads"; import { downloadsApi } from "./api/downloads";
export type * 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 type * from "./api/diagnostics";
export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics"; export { DIAGNOSTICS_EVENTS_PATH } from "./api/diagnostics";
@@ -90,4 +90,5 @@ export const api = {
...messagingApi, ...messagingApi,
...downloadsApi, ...downloadsApi,
...diagnosticsApi, ...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, // 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. // app version, and preference save. Only touches core's req.
import { req } from "./core"; import { req } from "./core";
import type { DiagnosticsState } from "./diagnostics"; import type { DebugSession, DiagnosticsState } from "./diagnostics";
export interface Me { export interface Me {
id: number; id: number;
@@ -33,6 +33,8 @@ export interface Me {
preferences: Record<string, unknown>; preferences: Record<string, unknown>;
/** The consented diagnostics level in force and when it lapses (E22 S5). */ /** The consented diagnostics level in force and when it lapses (E22 S5). */
diagnostics: DiagnosticsState; 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` /** 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: () => {}, pinActiveAccount: () => {},
})); }));
import { setDebugSession } from "../log";
import { import {
beacon,
req, req,
uploadReq, uploadReq,
setUnauthorizedHandler, setUnauthorizedHandler,
@@ -310,3 +312,32 @@ describe("req — what it has already put in front of the user", () => {
expect(remove).not.toHaveBeenCalled(); 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 { reportError } from "../errorDialog";
import { activeAccountId, pinActiveAccount } from "../activeAccount"; import { activeAccountId, pinActiveAccount } from "../activeAccount";
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http"; 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"). /** 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
@@ -253,24 +253,35 @@ export const setupHeaders = (token: string) => ({
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default. // 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). // The storage key + reader live in storage.ts (so its per-account helpers can use them too).
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account"; 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; 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 // 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 // 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) // doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
// would be lost. // would be lost.
export function beacon(url: string, body: Record<string, unknown>): void { export function beacon(url: string, body: Record<string, unknown>): void {
const active = getActiveAccount();
try { try {
void send(url, { void send(url, {
method: "POST", method: "POST",
keepalive: true, keepalive: true,
credentials: "include", credentials: "include",
headers: { headers: { "Content-Type": "application/json", ...identityHeaders() },
"Content-Type": "application/json",
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
},
body: JSON.stringify(body), body: JSON.stringify(body),
}).catch(() => {}); }).catch(() => {});
} catch { } catch {
@@ -292,16 +303,15 @@ export async function req<T = unknown>(
for (;;) { for (;;) {
let r: Response; let r: Response;
try { try {
const active = getActiveAccount();
r = await send( r = await send(
url, url,
{ {
credentials: "include", credentials: "include",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
// Per-tab identity override (only setup calls pass their own headers, and those are // Per-tab identity override (only setup calls and raw uploads pass their own headers:
// pre-auth, so this is safe to put in the defaults that `...opts` may replace). // setup is pre-auth, and an upload spreads `identityHeaders()` into its own).
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}), ...identityHeaders(),
}, },
...opts, ...opts,
}, },
@@ -372,8 +382,8 @@ export function uploadReq<T = unknown>(
const requestId = newRequestId(); const requestId = newRequestId();
const started = performance.now(); const started = performance.now();
xhr.setRequestHeader(REQUEST_ID_HEADER, requestId); xhr.setRequestHeader(REQUEST_ID_HEADER, requestId);
const active = getActiveAccount(); for (const [name, value] of Object.entries(identityHeaders()))
if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active)); xhr.setRequestHeader(name, value);
if (cfg.onProgress) { if (cfg.onProgress) {
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100)); 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 // 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`. // beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
import type { LogEvent } from "../log"; 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. */ /** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE"; export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
@@ -12,6 +13,8 @@ export interface DiagnosticsState {
until: string | null; until: string | null;
/** How many days this instance keeps stored log lines — the consent text states it. */ /** How many days this instance keeps stored log lines — the consent text states it. */
retention_days: number; 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 /** 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). */ /** The last batch of a closing tab (keepalive, fire-and-forget). */
sendLogEventsOnUnload: (events: LogEvent[]): void => beacon(DIAGNOSTICS_EVENTS_PATH, { events }), 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")], ["messagingApi", () => import("./messaging")],
["downloadsApi", () => import("./downloads")], ["downloadsApi", () => import("./downloads")],
["diagnosticsApi", () => import("./diagnostics")], ["diagnosticsApi", () => import("./diagnostics")],
["debugSessionsApi", () => import("./diagnostics")],
] as const; ] as const;
let api: Slice; 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; let current: typeof import("./diagnostics") | null = null;
afterEach(() => { afterEach(() => {
// A module copy left streaming would still hear `pagehide` in the next test. // 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; current = null;
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -51,6 +55,7 @@ const raised = (level: "DEBUG" | "TRACE", hours = 24) => ({
level, level,
until: new Date(Date.now() + hours * 3_600_000).toISOString(), until: new Date(Date.now() + hours * 3_600_000).toISOString(),
retention_days: 7, retention_days: 7,
session_minutes: 60,
}); });
const codes = (batch: unknown[] | undefined) => const codes = (batch: unknown[] | undefined) =>
(batch ?? []).map((e) => (e as { event: string }).event); (batch ?? []).map((e) => (e as { event: string }).event);
@@ -58,7 +63,11 @@ const codes = (batch: unknown[] | undefined) =>
describe("diagnostics level", () => { describe("diagnostics level", () => {
it("the base level at boot sets INFO, streams nothing and logs nothing", async () => { it("the base level at boot sets INFO, streams nothing and logs nothing", async () => {
const d = await fresh(); 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"); d.log.info("nav.view", "x");
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
expect(sent).toEqual([]); 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 () => { it("a raised level streams only what happens after it, without its own request lines", async () => {
const d = await fresh(); const d = await fresh();
d.log.info("before.consent", "not sent"); d.log.info("before.consent", "not sent");
d.applyDiagnostics(raised("DEBUG"), () => {}); d.applyDiagnostics(raised("DEBUG"), null, () => {});
expect(d.enabled("DEBUG")).toBe(true); expect(d.enabled("DEBUG")).toBe(true);
d.log.debug("ui.click", "click"); d.log.debug("ui.click", "click");
d.log.request("INFO", "POST", { path: "/api/diagnostics/events", status: 200 }, "abcdef0123"); 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 () => { it("sends in batches of at most 100, and a full batch goes without waiting", async () => {
const d = await fresh(); 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}`); for (let i = 0; i < 250; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
expect(sent.map((b) => b.length)).toEqual([100]); 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 () => { it("stops at once when the level is lowered, and drops what was not sent yet", async () => {
const d = await fresh(); const d = await fresh();
d.applyDiagnostics(raised("TRACE"), () => {}); d.applyDiagnostics(raised("TRACE"), null, () => {});
d.log.trace("search.typed", "lofi"); 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"); d.log.info("nav.view", "after");
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
expect(sent).toEqual([]); expect(sent).toEqual([]);
@@ -106,7 +119,7 @@ describe("diagnostics level", () => {
it("lapses on its own timer and tells the app to refetch", async () => { it("lapses on its own timer and tells the app to refetch", async () => {
const d = await fresh(); const d = await fresh();
const lapsed = vi.fn(); const lapsed = vi.fn();
d.applyDiagnostics(raised("DEBUG", 1), lapsed); d.applyDiagnostics(raised("DEBUG", 1), null, lapsed);
await vi.advanceTimersByTimeAsync(3_600_000 + 1); await vi.advanceTimersByTimeAsync(3_600_000 + 1);
expect(lapsed).toHaveBeenCalledOnce(); expect(lapsed).toHaveBeenCalledOnce();
expect(d.enabled("DEBUG")).toBe(false); expect(d.enabled("DEBUG")).toBe(false);
@@ -118,14 +131,14 @@ describe("diagnostics level", () => {
it("an already-expired level is the base level", async () => { it("an already-expired level is the base level", async () => {
const d = await fresh(); const d = await fresh();
d.applyDiagnostics(raised("DEBUG", -1), () => {}); d.applyDiagnostics(raised("DEBUG", -1), null, () => {});
expect(d.enabled("DEBUG")).toBe(false); 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 () => { it("the server saying it is off stops the stream; being told to slow down keeps the batch", async () => {
const d = await fresh(); const d = await fresh();
const lapsed = vi.fn(); const lapsed = vi.fn();
d.applyDiagnostics(raised("DEBUG"), lapsed); d.applyDiagnostics(raised("DEBUG"), null, lapsed);
answer = () => Promise.reject(new d.HttpError(429, "slow down")); answer = () => Promise.reject(new d.HttpError(429, "slow down"));
d.log.debug("ui.click", "kept for later"); d.log.debug("ui.click", "kept for later");
await vi.advanceTimersByTimeAsync(5_000); 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 () => { it("after a 429 nothing is sent until the back-off is over, however full the queue", async () => {
const d = await fresh(); const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {}); d.applyDiagnostics(raised("DEBUG"), null, () => {});
answer = () => Promise.reject(new d.HttpError(429, "slow down")); answer = () => Promise.reject(new d.HttpError(429, "slow down"));
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`); for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0); 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 () => { it("past the queue cap the oldest events go, a refused batch included", async () => {
const d = await fresh(); const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {}); d.applyDiagnostics(raised("DEBUG"), null, () => {});
answer = () => Promise.reject(new d.HttpError(429, "slow down")); answer = () => Promise.reject(new d.HttpError(429, "slow down"));
for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`); for (let i = 0; i < 99; i++) d.log.debug("ui.click", `click ${i}`);
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
@@ -173,14 +186,18 @@ describe("diagnostics level", () => {
const d = await fresh(); const d = await fresh();
let refuse: (err: unknown) => void = () => {}; let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject)); answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), () => {}); d.applyDiagnostics(raised("DEBUG"), null, () => {});
d.log.debug("ui.click", "under the first consent"); d.log.debug("ui.click", "under the first consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight 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")); refuse(new d.HttpError(429, "slow down"));
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
answer = () => Promise.resolve({ ok: true, stored: 0 }); answer = () => Promise.resolve({ ok: true, stored: 0 });
d.applyDiagnostics(raised("TRACE"), () => {}); d.applyDiagnostics(raised("TRACE"), null, () => {});
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
const later = sent const later = sent
.slice(1) .slice(1)
@@ -198,12 +215,16 @@ describe("diagnostics level", () => {
const d = await fresh(); const d = await fresh();
let refuse: (err: unknown) => void = () => {}; let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject)); answer = () => new Promise((_, reject) => (refuse = reject));
d.applyDiagnostics(raised("DEBUG"), () => {}); d.applyDiagnostics(raised("DEBUG"), null, () => {});
d.log.debug("ui.click", "under the first consent"); d.log.debug("ui.click", "under the first consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight 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 }); 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 refuse(new d.HttpError(429, "slow down")); // only now does the old consent's batch come back
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
const later = sent const later = sent
@@ -219,12 +240,16 @@ describe("diagnostics level", () => {
const lapsed = vi.fn(); const lapsed = vi.fn();
let refuse: (err: unknown) => void = () => {}; let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject)); 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"); d.log.debug("ui.click", "under the first consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight 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 }); answer = () => Promise.resolve({ ok: true, stored: 0 });
d.applyDiagnostics(raised("TRACE"), lapsed); d.applyDiagnostics(raised("TRACE"), null, lapsed);
refuse(new d.HttpError(409, "off")); refuse(new d.HttpError(409, "off"));
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
expect(lapsed).not.toHaveBeenCalled(); expect(lapsed).not.toHaveBeenCalled();
@@ -238,10 +263,14 @@ describe("diagnostics level", () => {
const lapsed = vi.fn(); const lapsed = vi.fn();
let refuse: (err: unknown) => void = () => {}; let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject)); 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"); d.log.debug("ui.click", "under the consent");
await vi.advanceTimersByTimeAsync(5_000); // in flight 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")); refuse(new d.HttpError(409, "off"));
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
expect(lapsed).not.toHaveBeenCalled(); expect(lapsed).not.toHaveBeenCalled();
@@ -255,7 +284,7 @@ describe("diagnostics level", () => {
const d = await fresh(); const d = await fresh();
let refuse: (err: unknown) => void = () => {}; let refuse: (err: unknown) => void = () => {};
answer = () => new Promise((_, reject) => (refuse = reject)); 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}`); 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 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}`); 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 () => { it("a closing tab sends what is still waiting with a keepalive request", async () => {
const d = await fresh(); const d = await fresh();
d.applyDiagnostics(raised("DEBUG"), () => {}); d.applyDiagnostics(raised("DEBUG"), null, () => {});
d.log.debug("ui.click", "last click"); d.log.debug("ui.click", "last click");
window.dispatchEvent(new Event("pagehide")); window.dispatchEvent(new Event("pagehide"));
expect(codes(unloaded[0])).toEqual(["diagnostics.level", "ui.click"]); 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 // `applyDiagnostics(me.diagnostics, me.debug_session)` runs whenever `me` answers. It sets the client
// while that level is raised (Részletes / Teljes) it streams the log to the server: // 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; // * 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 // * in batches, every FLUSH_MS or once BATCH_MAX are waiting, and the rest with a keepalive
// request when the tab closes; // request when the tab closes;
// * never the lines about its own requests, or each batch would carry the previous one's; // * 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` // * 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. // 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 // 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 // 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 // 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. // 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 {
import { log, setLevel, subscribe, type LogEvent } from "./log"; 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; const FLUSH_MS = 5_000;
/** Per request. The server takes up to 200; staying under it leaves room for a burst. */ /** 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"; 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 applied: string | null = null;
let lapseTimer: number | null = null; let lapseTimer: number | null = null;
let onLapsed: (() => void) | 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 /** Apply `state` and `session` to this tab. `lapsed` is called when a raised level or a recording
* is off, so the caller can refetch `me` (and the Settings tab can show the base level again). */ * 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( export function applyDiagnostics(
state: DiagnosticsState | null | undefined, state: DiagnosticsState | null | undefined,
session: DebugSession | null | undefined,
lapsed: () => void, lapsed: () => void,
): void { ): void {
onLapsed = lapsed; onLapsed = lapsed;
const level = levelInForce(state); lastState = state;
const key = level === "INFO" ? "INFO" : `${level}@${state?.until}`; 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; if (key === applied) return;
const wasRaised = applied !== null && applied !== "INFO"; const wasRaised = applied !== null && applied !== "INFO";
applied = key; applied = key;
@@ -56,27 +88,42 @@ export function applyDiagnostics(
} }
setLevel(level); setLevel(level);
startStreaming(); // first, so the stream opens with the line saying why it exists startStreaming(); // first, so the stream opens with the line saying why it exists
log.info("diagnostics.level", "diagnostics level raised", { level, until: state?.until }); log.info("diagnostics.level", "diagnostics level raised", {
// setTimeout's delay is a signed 32-bit number; a longer wait would fire at once. The server level,
// grants 24 h, so the clamp only matters for a clock that is badly off. until,
const wait = Math.min(Date.parse(state!.until!) - Date.now(), 2 ** 31 - 1); ...(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); 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 { function lapse(): void {
lapseTimer = null; lapseTimer = null;
applied = "INFO"; const before = applied;
stopStreaming(); applied = null;
setLevel("INFO"); applyDiagnostics(lastState, lastSession, onLapsed ?? (() => {}));
log.info("diagnostics.level", "diagnostics level lapsed", { level: "INFO" }); if (applied === "INFO" && before !== "INFO")
log.info("diagnostics.level", "diagnostics level lapsed", { level: "INFO" });
onLapsed?.(); onLapsed?.();
} }
/** The server has it off: lapse everything this tab thought was in force. */
function serverSaysOff(): void {
lastState = null;
lastSession = null;
lapse();
}
// --- streaming ---------------------------------------------------------------------------------- // --- streaming ----------------------------------------------------------------------------------
let queue: LogEvent[] = []; let queue: LogEvent[] = [];
let unsubscribe: (() => void) | null = null; let unsubscribe: (() => void) | null = null;
let flushTimer: number | null = null; let flushTimer: number | null = null;
let inFlight = false; let inFlight: Promise<void> | null = null;
let quietUntil = 0; let quietUntil = 0;
// Which consent a batch was taken under. A batch still in flight when the level is lowered must not // 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. // 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 = []; queue = [];
} }
async function flush(): Promise<void> { function flush(): Promise<void> {
if (inFlight || !queue.length || Date.now() < quietUntil) return; if (inFlight) return inFlight;
inFlight = true; 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 takenIn = session;
const batch = queue.splice(0, BATCH_MAX); const batch = queue.splice(0, BATCH_MAX);
try { 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 // 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 // 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. // 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; request_id?: string;
tab_id: string; tab_id: string;
version: 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 /** 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 ----------------------------------------------------------------------------------- // --- the logger -----------------------------------------------------------------------------------
let threshold: Level = "INFO"; let threshold: Level = "INFO";
let ring: LogEvent[] = []; 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 /** The capture level. INFO is the always-on "Alap"; DEBUG and TRACE come from the user's consented
* diagnostics level (E22 S5). */ * 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 (attrs && Object.keys(attrs).length) entry.attrs = redact(attrs) as Record<string, unknown>;
if (requestId) entry.request_id = requestId; if (requestId) entry.request_id = requestId;
if (debugSession) entry.debug_session_id = debugSession;
ring.push(entry); ring.push(entry);
if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX); if (ring.length > RING_MAX) ring.splice(0, ring.length - RING_MAX);
notify(entry); notify(entry);