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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// The signed-in account & session: the bootstrap `me` probe, multi-account switch/list, sign-out,
|
||||
// app version, and preference save. Only touches core's req.
|
||||
import { req } from "./core";
|
||||
import type { DiagnosticsState } from "./diagnostics";
|
||||
import type { DebugSession, DiagnosticsState } from "./diagnostics";
|
||||
|
||||
export interface Me {
|
||||
id: number;
|
||||
@@ -33,6 +33,8 @@ export interface Me {
|
||||
preferences: Record<string, unknown>;
|
||||
/** The consented diagnostics level in force and when it lapses (E22 S5). */
|
||||
diagnostics: DiagnosticsState;
|
||||
/** The debug session recording right now, if any (E16) — a reload resumes it. */
|
||||
debug_session: DebugSession | null;
|
||||
}
|
||||
|
||||
/** The `nav.role.*` key an account is shown under. Demo isn't a real role (it's a flag on a `user`
|
||||
|
||||
@@ -22,7 +22,9 @@ vi.mock("../activeAccount", () => ({
|
||||
pinActiveAccount: () => {},
|
||||
}));
|
||||
|
||||
import { setDebugSession } from "../log";
|
||||
import {
|
||||
beacon,
|
||||
req,
|
||||
uploadReq,
|
||||
setUnauthorizedHandler,
|
||||
@@ -310,3 +312,32 @@ describe("req — what it has already put in front of the user", () => {
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// E16: while a debug session records, every request path names it — the server tags ITS lines of
|
||||
// that request with it. A path that forgot the header would leave holes in the evidence exactly
|
||||
// where the upload (XHR) or the unload beacon happened.
|
||||
describe("the debug session a tab records into", () => {
|
||||
const sentHeaders = () => {
|
||||
const init = vi.mocked(globalThis.fetch).mock.lastCall?.[1] as RequestInit;
|
||||
return new Headers(init.headers as HeadersInit);
|
||||
};
|
||||
|
||||
it("rides every request path while it records, and none once it stops", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(() =>
|
||||
Promise.resolve(new Response("{}", { status: 200 })),
|
||||
);
|
||||
setDebugSession("0123456789abcdef");
|
||||
try {
|
||||
await req("/api/x");
|
||||
expect(sentHeaders().get("X-Debug-Session")).toBe("0123456789abcdef");
|
||||
beacon("/api/y", {});
|
||||
expect(sentHeaders().get("X-Debug-Session")).toBe("0123456789abcdef");
|
||||
void uploadReq("/u", form());
|
||||
expect(FakeXHR.last.headers["X-Debug-Session"]).toBe("0123456789abcdef");
|
||||
} finally {
|
||||
setDebugSession(null);
|
||||
}
|
||||
await req("/api/x");
|
||||
expect(sentHeaders().has("X-Debug-Session")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { formatBytes } from "../format";
|
||||
import { reportError } from "../errorDialog";
|
||||
import { activeAccountId, pinActiveAccount } from "../activeAccount";
|
||||
import { logRequest, QUIET_HEADER, REQUEST_ID_HEADER, ROUTE_HEADER, send } from "../http";
|
||||
import { newRequestId } from "../log";
|
||||
import { currentDebugSession, newRequestId } from "../log";
|
||||
|
||||
/** The active UI language, as a bare code the server understands ("en"/"hu" — never "en-GB").
|
||||
* Lives here so the request builders can stamp it themselves: a signal that has to be passed in by
|
||||
@@ -253,24 +253,35 @@ export const setupHeaders = (token: string) => ({
|
||||
// sessionStorage (per-tab, survives F5, not shared across tabs). null = use the session default.
|
||||
// The storage key + reader live in storage.ts (so its per-account helpers can use them too).
|
||||
const ACTIVE_ACCOUNT_HEADER = "X-Siftlode-Account";
|
||||
// The debug session this tab is recording (E16): the server tags its own lines of the request with
|
||||
// it, and logs them at the session's level. Ignored by the server unless it is the caller's own.
|
||||
const DEBUG_SESSION_HEADER = "X-Debug-Session";
|
||||
|
||||
export const getActiveAccount = activeAccountId;
|
||||
|
||||
/** Who this request is from — the tab's account and the debug session it records into — as the
|
||||
* headers every request path sends (`req`, `beacon`, `uploadReq`): one definition, so a path
|
||||
* cannot quietly go out without them. */
|
||||
export function identityHeaders(): Record<string, string> {
|
||||
const active = getActiveAccount();
|
||||
const session = currentDebugSession();
|
||||
return {
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
...(session ? { [DEBUG_SESSION_HEADER]: session } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// Fire-and-forget POST that survives page unload (keepalive) — shared by the resume-position
|
||||
// beacons (YT + Plex players). A normal fetch is cancelled on unload and React effect cleanup
|
||||
// doesn't run on a full reload, so without keepalive the last position (esp. right after a seek)
|
||||
// would be lost.
|
||||
export function beacon(url: string, body: Record<string, unknown>): void {
|
||||
const active = getActiveAccount();
|
||||
try {
|
||||
void send(url, {
|
||||
method: "POST",
|
||||
keepalive: true,
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
},
|
||||
headers: { "Content-Type": "application/json", ...identityHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
@@ -292,16 +303,15 @@ export async function req<T = unknown>(
|
||||
for (;;) {
|
||||
let r: Response;
|
||||
try {
|
||||
const active = getActiveAccount();
|
||||
r = await send(
|
||||
url,
|
||||
{
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Per-tab identity override (only setup calls pass their own headers, and those are
|
||||
// pre-auth, so this is safe to put in the defaults that `...opts` may replace).
|
||||
...(active != null ? { [ACTIVE_ACCOUNT_HEADER]: String(active) } : {}),
|
||||
// Per-tab identity override (only setup calls and raw uploads pass their own headers:
|
||||
// setup is pre-auth, and an upload spreads `identityHeaders()` into its own).
|
||||
...identityHeaders(),
|
||||
},
|
||||
...opts,
|
||||
},
|
||||
@@ -372,8 +382,8 @@ export function uploadReq<T = unknown>(
|
||||
const requestId = newRequestId();
|
||||
const started = performance.now();
|
||||
xhr.setRequestHeader(REQUEST_ID_HEADER, requestId);
|
||||
const active = getActiveAccount();
|
||||
if (active != null) xhr.setRequestHeader(ACTIVE_ACCOUNT_HEADER, String(active));
|
||||
for (const [name, value] of Object.entries(identityHeaders()))
|
||||
xhr.setRequestHeader(name, value);
|
||||
if (cfg.onProgress) {
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) cfg.onProgress?.(Math.round((e.loaded / e.total) * 100));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// The user's diagnostics level (E22 S5) and the browser log it lets out. Only touches core's req and
|
||||
// beacon. The streaming itself — what is sent, when, and when it stops — is `lib/diagnostics.ts`.
|
||||
import type { LogEvent } from "../log";
|
||||
import { beacon, req } from "./core";
|
||||
import type { ServerLogRow } from "./admin";
|
||||
import { beacon, getActiveAccount, identityHeaders, req } from "./core";
|
||||
|
||||
/** Alap / Részletes / Teljes. The server decides how long a raised level lasts. */
|
||||
export type DiagnosticsLevel = "INFO" | "DEBUG" | "TRACE";
|
||||
@@ -12,6 +13,8 @@ export interface DiagnosticsState {
|
||||
until: string | null;
|
||||
/** How many days this instance keeps stored log lines — the consent text states it. */
|
||||
retention_days: number;
|
||||
/** How long a debug session records at most — the start dialog's consent text states it. */
|
||||
session_minutes: number;
|
||||
}
|
||||
|
||||
/** Where batches go. The shipper leaves the log lines ABOUT this request out of what it sends, or
|
||||
@@ -33,3 +36,65 @@ export const diagnosticsApi = {
|
||||
/** The last batch of a closing tab (keepalive, fire-and-forget). */
|
||||
sendLogEventsOnUnload: (events: LogEvent[]): void => beacon(DIAGNOSTICS_EVENTS_PATH, { events }),
|
||||
};
|
||||
|
||||
// --- debug sessions (E16) ----------------------------------------------------------------------
|
||||
|
||||
/** The two levels a recording can run at — it always records more than the base. */
|
||||
export type DebugSessionLevel = "DEBUG" | "TRACE";
|
||||
|
||||
/** One recording: Start → reproduce → Stop. `until` is the server's cap; past it the session is
|
||||
* over whether or not it was stopped. */
|
||||
export interface DebugSession {
|
||||
id: string;
|
||||
level: DebugSessionLevel;
|
||||
started_at: string;
|
||||
until: string;
|
||||
ended_at: string | null;
|
||||
ended_reason: "stopped" | "expired" | null;
|
||||
/** The issue it was sent as, once sent. */
|
||||
issue_url: string | null;
|
||||
screenshots: number;
|
||||
}
|
||||
|
||||
export interface DebugScreenshot {
|
||||
id: number;
|
||||
mime: string;
|
||||
size: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const SESSIONS = "/api/debug-sessions";
|
||||
|
||||
export const debugSessionsApi = {
|
||||
startDebugSession: (level: DebugSessionLevel): Promise<{ ok: true; session: DebugSession }> =>
|
||||
req(SESSIONS, { method: "POST", body: JSON.stringify({ level }) }),
|
||||
stopDebugSession: (id: string): Promise<{ ok: true; session: DebugSession }> =>
|
||||
req(`${SESSIONS}/${id}/stop`, { method: "POST" }),
|
||||
debugSessions: (): Promise<{ items: DebugSession[] }> => req(SESSIONS),
|
||||
debugSession: (id: string): Promise<DebugSession> => req(`${SESSIONS}/${id}`),
|
||||
/** The rows an issue from this session would carry, browser and server, in time order. */
|
||||
debugSessionEvents: (id: string): Promise<{ items: ServerLogRow[]; total: number }> =>
|
||||
req(`${SESSIONS}/${id}/events`),
|
||||
debugScreenshots: (id: string): Promise<{ items: DebugScreenshot[] }> =>
|
||||
req(`${SESSIONS}/${id}/screenshots`),
|
||||
/** One image as the raw body — the server checks what it really is, whatever `type` says. */
|
||||
addDebugScreenshot: (
|
||||
id: string,
|
||||
image: Blob,
|
||||
): Promise<{ ok: true; screenshot: DebugScreenshot }> =>
|
||||
req(`${SESSIONS}/${id}/screenshots`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": image.type || "application/octet-stream", ...identityHeaders() },
|
||||
body: image,
|
||||
}),
|
||||
removeDebugScreenshot: (id: string, shotId: number): Promise<{ ok: true; deleted: number }> =>
|
||||
req(`${SESSIONS}/${id}/screenshots/${shotId}`, { method: "DELETE" }),
|
||||
/** Where an `<img>` loads a screenshot from. An `<img>` cannot send the account header, so the
|
||||
* tab's account rides as `?account=` (the wallet-gated selection downloads use); without it a
|
||||
* per-tab account's screenshot resolves to the session default and 404s. */
|
||||
debugScreenshotUrl: (id: string, shotId: number): string => {
|
||||
const a = getActiveAccount();
|
||||
const url = `${SESSIONS}/${id}/screenshots/${shotId}`;
|
||||
return a != null ? `${url}?account=${a}` : url;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ const SLICE_MODULES = [
|
||||
["messagingApi", () => import("./messaging")],
|
||||
["downloadsApi", () => import("./downloads")],
|
||||
["diagnosticsApi", () => import("./diagnostics")],
|
||||
["debugSessionsApi", () => import("./diagnostics")],
|
||||
] as const;
|
||||
|
||||
let api: Slice;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user