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