feat(diagnostics): the debug recorder — start, the pill, steps, screenshots, stop (E16 S2)
- the start dialog is the consent: level (Részletes/Teljes), what is recorded, retention and the cap (now served as diagnostics.session_minutes) - the pill docks under the toasts: elapsed time, Step (an optional label, its own Escape layer), Screenshot (tab capture where the browser has it), Stop; compact on a phone - while recording, lib/log tags every event and every request path (req, beacon, uploadReq via one identityHeaders) names the session; applyDiagnostics raises the level for the session's run and never lowers a raised one, and lapses at whichever ends first - Stop sends what is waiting before the server closes the session; the cap says so and opens the composer - Settings → Diagnosztika and the command palette start/step/stop it - the evidence leaves the log upload's own request lines out - the step shortcut is deliberately palette-only: the keymap's D1 rule bars a new modifier chord, and a bare letter collides with the players'
This commit is contained in:
@@ -1,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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user