feat: add developer-mode startup and stack hydration timing (#1619)

* feat: add developer-mode startup and stack hydration timing

Instrument boot-to-list and detail hydration with commit-aligned milestones, truthful request stages, and destination/gateway debug duration logs so performance work is guided by measurements.

* fix: redact stack names and complete hydration request stages

Stop logging stack identifiers in containers debug timing, and record state_dispatch (plus detail fetch spans) so copied reports match the advertised stage breakdown.
This commit is contained in:
Anso
2026-07-14 17:24:25 -04:00
committed by GitHub
parent 4079cb9198
commit b70a529656
26 changed files with 2449 additions and 32 deletions
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { waitFor } from '@testing-library/react';
// Each test gets a fresh module instance so the module-scoped boot session and
// event buffer start clean. Resetting modules re-runs the boot_start init.
type Store = typeof import('../hydrationTiming');
async function loadStore(setup?: () => void): Promise<Store> {
vi.resetModules();
setup?.();
return import('../hydrationTiming');
}
/** Stub `performance` with a caller-controlled clock so durations are exact. */
function stubClock(getT: () => number): void {
vi.stubGlobal('performance', {
now: () => getT(),
mark: vi.fn(),
measure: vi.fn(),
clearMarks: vi.fn(),
clearMeasures: vi.fn(),
});
}
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('hydrationTiming store', () => {
it('records boot_start once and dedupes one-shot milestones (StrictMode)', async () => {
const store = await loadStore();
// Simulate a StrictMode double invocation plus an explicit re-mark.
store.markMilestone('boot_start');
store.markMilestone('boot_start', { oneShot: true });
store.markMilestone('auth_resolved');
store.markMilestone('auth_resolved');
const phases = store.getHydrationReport().phases.map((p) => p.phase);
expect(phases.filter((p) => p === 'boot_start')).toHaveLength(1);
expect(phases.filter((p) => p === 'auth_resolved')).toHaveLength(1);
});
it('evicts the oldest events beyond the 200-event cap (FIFO)', async () => {
const store = await loadStore();
for (let i = 0; i < 250; i++) {
const handle = store.beginSpan('fetch_headers');
store.endSpan(handle);
}
const report = store.getHydrationReport();
expect(report.phases).toHaveLength(200);
// boot_start was the very first event, so it has been evicted.
expect(report.phases.some((p) => p.phase === 'boot_start')).toBe(false);
});
it('never completes a superseded or aborted attempt', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const superseded = store.newAttemptId();
store.beginNodeSession(2); // supersedes node session 1's attempts
store.commitMilestone('list_visible', superseded);
expect(store.getHydrationReport().phases.some((p) => p.phase === 'list_visible')).toBe(false);
const aborted = store.newAttemptId();
store.abortAttempt(aborted);
store.commitMilestone('detail_hydrated', aborted);
expect(store.getHydrationReport().phases.some((p) => p.phase === 'detail_hydrated')).toBe(false);
// A live attempt for the current node session still commits.
const live = store.newAttemptId();
store.commitMilestone('list_visible', live);
expect(store.getHydrationReport().phases.some((p) => p.phase === 'list_visible')).toBe(true);
});
it('marks unknown attempts as no-ops for commit milestones', async () => {
const store = await loadStore();
store.beginNodeSession(1);
store.commitMilestone('list_visible', 'attempt-does-not-exist');
expect(store.getHydrationReport().phases.some((p) => p.phase === 'list_visible')).toBe(false);
});
it('falls back to Date.now when performance is unavailable', async () => {
const store = await loadStore(() => vi.stubGlobal('performance', undefined));
expect(() => store.markMilestone('auth_resolved')).not.toThrow();
expect(store.getHydrationReport().clock).toBe('date.now-fallback');
});
it('tolerates a performance object missing mark and measure', async () => {
const store = await loadStore(() => vi.stubGlobal('performance', { now: () => 5 }));
expect(() => {
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
const handle = store.beginSpan('body_decode', { attemptId: a });
store.endSpan(handle);
}).not.toThrow();
expect(store.getHydrationReport().clock).toBe('performance.now');
});
it('clears node session events but keeps boot markers', async () => {
const store = await loadStore();
store.markMilestone('auth_resolved');
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
store.clearReport();
const phases = store.getHydrationReport().phases.map((p) => p.phase);
expect(phases).toContain('boot_start');
expect(phases).toContain('auth_resolved');
expect(phases).not.toContain('list_visible');
});
it('reports list_visible elapsed from boot_start', async () => {
let t = 0;
const store = await loadStore(() => stubClock(() => t));
t = 1200;
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a);
expect(store.getListVisibleMs()).toBe(1200);
expect(store.getHydrationReport().listVisibleMs).toBe(1200);
});
it('returns null list_visible timing before it commits', async () => {
const store = await loadStore();
expect(store.getListVisibleMs()).toBeNull();
});
it('records span duration between begin and end', async () => {
let t = 0;
const store = await loadStore(() => stubClock(() => t));
store.beginNodeSession(1);
const a = store.newAttemptId();
t = 10;
const handle = store.beginSpan('fetch_headers', { attemptId: a });
t = 35;
store.endSpan(handle);
const span = store.getHydrationReport().phases.find((p) => p.phase === 'fetch_headers');
expect(span?.durationMs).toBe(25);
});
it('fires an empty->empty commit once per attempt via completion token', async () => {
const store = await loadStore();
store.beginNodeSession(1);
const a = store.newAttemptId();
store.commitMilestone('list_visible', a, { completionToken: 'empty' });
store.commitMilestone('list_visible', a, { completionToken: 'empty' });
const listVisible = store.getHydrationReport().phases.filter((p) => p.phase === 'list_visible');
expect(listVisible).toHaveLength(1);
expect(listVisible[0].detail?.completionToken).toBe('empty');
// Without a token, a repeat commit for the same attempt still dedupes.
const b = store.newAttemptId();
store.commitMilestone('list_hydrated', b);
store.commitMilestone('list_hydrated', b);
expect(store.getHydrationReport().phases.filter((p) => p.phase === 'list_hydrated')).toHaveLength(1);
});
it('classifies background phases as non-critical', async () => {
const store = await loadStore();
expect(store.classifyCritical('list_visible')).toBe(true);
expect(store.classifyCritical('notifications_ready')).toBe(false);
expect(store.classifyCritical('image_updates_ready')).toBe(false);
});
it('keeps snapshots referentially stable until an emit fires', async () => {
const store = await loadStore();
const before = store.getSnapshot();
expect(store.getSnapshot()).toBe(before);
const listener = vi.fn();
const unsubscribe = store.subscribe(listener);
store.markMilestone('auth_resolved');
await waitFor(() => expect(listener).toHaveBeenCalled());
const after = store.getSnapshot();
expect(after).not.toBe(before);
expect(after.events.some((e) => e.phase === 'auth_resolved')).toBe(true);
unsubscribe();
});
});
+639
View File
@@ -0,0 +1,639 @@
/**
* Session-scoped startup and stack-hydration timing store.
*
* The store lives for the lifetime of the page (one boot session) and tracks
* the latest node session on top of that. It is consumed through
* `useSyncExternalStore` (see `useHydrationTiming`) and copied out as a
* versioned JSON report from the developer-mode overlay.
*
* Design constraints:
* - Instrumentation only. Recording an event must never throw into a caller
* and must never perturb the timing it measures, so emits are coalesced.
* - Truthful ownership. A milestone committed from a React effect carries the
* owning attempt id; a late commit for a superseded or aborted attempt is a
* no-op so a stale render can never complete a session it no longer owns.
* - Degrade safely. `performance` and the User Timing API are optional; a
* `Date.now()` fallback keeps durations flowing when they are absent.
*/
/** Commit-aligned lifecycle phases surfaced in the chip, panel, and report. */
export type HydrationPhase =
| 'boot_start'
| 'auth_resolved'
| 'nodes_resolved'
| 'shell_committed'
| 'list_visible'
| 'list_hydrated'
| 'detail_visible'
| 'detail_containers_ready'
| 'detail_hydrated'
| 'notifications_ready'
| 'image_updates_ready';
/** Immediate post-setter debug marks, kept distinct from commit-aligned phases. */
export type StateDispatchedMark = `${string}_state_dispatched`;
/** Anything `markMilestone` accepts: a known phase or a debug dispatch mark. */
export type HydrationMark = HydrationPhase | StateDispatchedMark;
/** The subset of phases that are committed via `commitMilestone` (effect-observed). */
export type UiCommitPhase =
| 'list_visible'
| 'list_hydrated'
| 'detail_visible'
| 'detail_containers_ready'
| 'detail_hydrated';
/** Instrumented request stages at each `apiFetch` call site. */
export type HydrationStage = 'fetch_headers' | 'body_decode' | 'state_dispatch';
export type HydrationEventKind = 'milestone' | 'span' | 'background';
export type HydrationOutcome = 'ok' | 'error' | 'aborted' | 'superseded';
export type HydrationClock = 'performance.now' | 'date.now-fallback';
export type HydrationDetail = Record<string, unknown>;
export interface HydrationEvent {
/** Monotonic sequence id, stable for the lifetime of the event. */
id: number;
/** The boot or node session that owns this event. */
sessionId: string;
attemptId?: string;
/** Phase name (milestone) or stage name (span). */
phase: string;
kind: HydrationEventKind;
/** Clock start (milestone mark time, or span begin). */
t0: number;
/** Clock end for spans; absent for point milestones. */
t1?: number;
outcome?: HydrationOutcome;
detail?: HydrationDetail;
/** True when the underlying request crossed the remote-node proxy. */
proxied?: boolean;
/** True for milestones committed from an effect that observed committed state. */
commit?: boolean;
nodeId?: number | null;
}
export interface HydrationSnapshot {
schemaVersion: 1;
clock: HydrationClock;
bootSessionId: string;
bootStartAt: number | null;
nodeSessionId: string | null;
nodeId: number | null;
events: readonly HydrationEvent[];
}
export interface HydrationReportPhase {
phase: string;
kind: HydrationEventKind;
outcome?: HydrationOutcome;
/** Elapsed ms from `boot_start` to this event, or null if boot is unknown. */
offsetMs: number | null;
/** Span duration in ms (t1 - t0). */
durationMs?: number;
/** Elapsed ms from `boot_start` for commit-aligned milestones. */
uiCommitMs?: number;
critical: boolean;
proxied?: boolean;
attemptId?: string;
detail?: HydrationDetail;
}
export interface HydrationReport {
schemaVersion: 1;
/** Wall-clock capture time (Date.now), only for human reference. */
capturedAt: number;
clock: HydrationClock;
appVersion?: string;
bootSessionId: string;
nodeSessionId: string | null;
nodeId: number | null;
listVisibleMs: number | null;
anyProxied: boolean;
phases: HydrationReportPhase[];
}
export interface MilestoneOptions {
attemptId?: string;
outcome?: HydrationOutcome;
oneShot?: boolean;
detail?: HydrationDetail;
proxied?: boolean;
}
export interface CommitOptions {
outcome?: HydrationOutcome;
detail?: HydrationDetail;
proxied?: boolean;
/** Lets an empty->empty commit still fire once per attempt when committed
* state may be referentially equal to the previous render. */
completionToken?: string;
}
/** Armed by a fetch, flushed from a React effect once committed state is observed. */
export interface PendingCommit {
attemptId: string;
token: string;
proxied: boolean;
}
export interface SpanOptions {
attemptId?: string;
detail?: HydrationDetail;
proxied?: boolean;
/** Marks the span as belonging to a background refresh rather than the
* critical hydration path. */
background?: boolean;
}
export interface EndSpanOptions {
outcome?: HydrationOutcome;
detail?: HydrationDetail;
proxied?: boolean;
}
/** Opaque handle returned by `beginSpan` and passed back to `endSpan`. */
export type SpanHandle = number;
const MAX_EVENTS = 200;
const MAX_ATTEMPTS = 200;
/** Coalesce emits to at most 4 Hz so the store never perturbs the timings. */
const MIN_EMIT_INTERVAL_MS = 250;
const USER_TIMING_PREFIX = 'sn-hyd';
/** Phases recorded exactly once per boot session (deduped under StrictMode). */
const ONE_SHOT_PHASES: ReadonlySet<string> = new Set([
'boot_start',
'auth_resolved',
'nodes_resolved',
'shell_committed',
]);
/** Phases that hydrate off the critical path (reported as non-critical). */
const BACKGROUND_PHASES: ReadonlySet<string> = new Set([
'notifications_ready',
'image_updates_ready',
]);
const appVersion: string | undefined =
typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined;
const clockKind: HydrationClock =
typeof performance !== 'undefined' && typeof performance.now === 'function'
? 'performance.now'
: 'date.now-fallback';
function now(): number {
return clockKind === 'performance.now' ? performance.now() : Date.now();
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
interface AttemptRecord {
sessionId: string;
aborted: boolean;
superseded: boolean;
}
interface OpenSpan {
stage: HydrationStage;
sessionId: string;
attemptId?: string;
t0: number;
kind: HydrationEventKind;
detail?: HydrationDetail;
proxied?: boolean;
}
let seq = 0;
function nextId(): number {
return ++seq;
}
function newSessionId(prefix: string): string {
return `${prefix}-${nextId()}`;
}
const bootSessionId = newSessionId('boot');
let bootStartAt: number | null = null;
let nodeSessionId: string | null = null;
let currentNodeId: number | null = null;
let events: HydrationEvent[] = [];
const attempts = new Map<string, AttemptRecord>();
const openSpans = new Map<SpanHandle, OpenSpan>();
/** Boot one-shot dedupe keys (`phase::bootSessionId`). */
const oneShotKeys = new Set<string>();
/** Commit dedupe keys (`phase::attemptId::completionToken`). */
const committedKeys = new Set<string>();
// User Timing helpers. Every entry point tolerates a missing API surface so a
// browser or test environment without `performance`, `mark`, or `measure`
// records durations off the clock fallback without throwing.
function hasPerformance(): boolean {
return typeof performance !== 'undefined';
}
const userTiming = {
mark(name: string): void {
if (!hasPerformance() || typeof performance.mark !== 'function') return;
try {
performance.mark(name);
} catch {
// User Timing is best-effort diagnostics; never surface a failure.
}
},
measure(name: string, startMark: string, endMark: string): void {
if (!hasPerformance() || typeof performance.measure !== 'function') return;
try {
performance.measure(name, startMark, endMark);
} catch {
// A missing start/end mark must not break timing capture.
}
},
clear(name: string): void {
if (!hasPerformance()) return;
try {
performance.clearMarks?.(name);
performance.clearMarks?.(`${name}:start`);
performance.clearMarks?.(`${name}:end`);
performance.clearMeasures?.(name);
} catch {
// Clearing stale entries is best-effort.
}
},
};
function markName(sessionId: string, attemptId: string | undefined, phase: string): string {
return `${USER_TIMING_PREFIX}:${sessionId}:${attemptId ?? '-'}:${phase}`;
}
function milestoneKind(phase: string): HydrationEventKind {
return BACKGROUND_PHASES.has(phase) ? 'background' : 'milestone';
}
/** Boot-scoped one-shots belong to the boot session; everything else to the
* active node session (falling back to boot before the first node resolves). */
function phaseSessionId(phase: string): string {
return ONE_SHOT_PHASES.has(phase) ? bootSessionId : nodeSessionId ?? bootSessionId;
}
function sessionNodeId(sessionId: string): number | null {
return sessionId === bootSessionId ? null : currentNodeId;
}
function currentSessionId(): string {
return nodeSessionId ?? bootSessionId;
}
function mergeDetail(a?: HydrationDetail, b?: HydrationDetail): HydrationDetail | undefined {
if (!a && !b) return undefined;
return { ...a, ...b };
}
// ---------------------------------------------------------------------------
// Snapshot + emit scheduling (useSyncExternalStore contract)
// ---------------------------------------------------------------------------
let snapshot: HydrationSnapshot;
const listeners = new Set<() => void>();
let flushScheduled = false;
let lastEmitAt = 0;
function rebuildSnapshot(): void {
snapshot = {
schemaVersion: 1,
clock: clockKind,
bootSessionId,
bootStartAt,
nodeSessionId,
nodeId: currentNodeId,
events: events.slice(),
};
}
function doEmit(): void {
flushScheduled = false;
lastEmitAt = now();
rebuildSnapshot();
for (const listener of listeners) listener();
}
function scheduleEmit(): void {
if (flushScheduled) return;
flushScheduled = true;
const run = (): void => {
const elapsed = now() - lastEmitAt;
if (elapsed >= MIN_EMIT_INTERVAL_MS) {
doEmit();
} else {
setTimeout(doEmit, MIN_EMIT_INTERVAL_MS - elapsed);
}
};
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(run);
} else {
setTimeout(run, 16);
}
}
function pushEvent(event: HydrationEvent): void {
events.push(event);
if (events.length > MAX_EVENTS) {
events.splice(0, events.length - MAX_EVENTS);
}
scheduleEmit();
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function getSnapshot(): HydrationSnapshot {
return snapshot;
}
/** Start a fresh node session, superseding the prior one and its attempts. */
export function beginNodeSession(nodeId: number): string {
if (nodeSessionId) {
const prior = nodeSessionId;
for (const attempt of attempts.values()) {
if (attempt.sessionId === prior && !attempt.aborted) attempt.superseded = true;
}
for (const [handle, span] of openSpans) {
if (span.sessionId === prior) openSpans.delete(handle);
}
// Retain only boot markers plus the incoming node session's events.
events = events.filter((e) => e.sessionId === bootSessionId);
committedKeys.clear();
}
nodeSessionId = newSessionId('node');
currentNodeId = nodeId;
scheduleEmit();
return nodeSessionId;
}
export function newAttemptId(): string {
const id = newSessionId('attempt');
attempts.set(id, { sessionId: currentSessionId(), aborted: false, superseded: false });
if (attempts.size > MAX_ATTEMPTS) {
const oldest = attempts.keys().next().value;
if (oldest !== undefined) attempts.delete(oldest);
}
return id;
}
/** Mark an attempt aborted; its in-flight spans finalize as aborted and later
* commit milestones for it become no-ops. */
export function abortAttempt(attemptId: string): void {
const attempt = attempts.get(attemptId);
if (!attempt) return;
attempt.aborted = true;
for (const [handle, span] of openSpans) {
if (span.attemptId !== attemptId) continue;
openSpans.delete(handle);
pushEvent({
id: handle,
sessionId: span.sessionId,
attemptId,
phase: span.stage,
kind: span.kind,
t0: span.t0,
t1: now(),
outcome: 'aborted',
detail: span.detail,
proxied: span.proxied,
nodeId: sessionNodeId(span.sessionId),
});
}
}
export function markMilestone(phase: HydrationMark, opts: MilestoneOptions = {}): void {
const oneShot = opts.oneShot ?? ONE_SHOT_PHASES.has(phase);
if (oneShot) {
const key = `${phase}::${bootSessionId}`;
if (oneShotKeys.has(key)) return;
oneShotKeys.add(key);
}
const sessionId = phaseSessionId(phase);
const t = now();
pushEvent({
id: nextId(),
sessionId,
attemptId: opts.attemptId,
phase,
kind: milestoneKind(phase),
t0: t,
outcome: opts.outcome ?? 'ok',
detail: opts.detail,
proxied: opts.proxied,
nodeId: sessionNodeId(sessionId),
});
userTiming.mark(markName(sessionId, opts.attemptId, phase));
}
/** Record a commit-aligned UI milestone. No-op unless the attempt is still the
* current, non-superseded, non-aborted attempt for the active node session. */
export function commitMilestone(
phase: UiCommitPhase,
attemptId: string,
opts: CommitOptions = {},
): void {
const attempt = attempts.get(attemptId);
if (!attempt) return;
if (attempt.aborted || attempt.superseded) return;
if (attempt.sessionId !== nodeSessionId) return;
const key = `${phase}::${attemptId}::${opts.completionToken ?? ''}`;
if (committedKeys.has(key)) return;
committedKeys.add(key);
const t = now();
const detail = opts.completionToken
? { ...(opts.detail ?? {}), completionToken: opts.completionToken }
: opts.detail;
pushEvent({
id: nextId(),
sessionId: attempt.sessionId,
attemptId,
phase,
kind: milestoneKind(phase),
t0: t,
outcome: opts.outcome ?? 'ok',
detail,
proxied: opts.proxied,
commit: true,
nodeId: currentNodeId,
});
userTiming.mark(markName(attempt.sessionId, attemptId, phase));
}
/** Commit and clear a pending UI milestone ref. No-op when the ref is empty;
* callers still own the readiness guards (selected file, load status, etc.). */
export function flushPendingCommit(
pendingRef: { current: PendingCommit | null },
phase: UiCommitPhase,
): void {
const pending = pendingRef.current;
if (!pending) return;
commitMilestone(phase, pending.attemptId, {
completionToken: pending.token,
proxied: pending.proxied,
});
pendingRef.current = null;
}
export function beginSpan(stage: HydrationStage, opts: SpanOptions = {}): SpanHandle {
const handle = nextId();
const sessionId = currentSessionId();
openSpans.set(handle, {
stage,
sessionId,
attemptId: opts.attemptId,
t0: now(),
kind: opts.background ? 'background' : 'span',
detail: opts.detail,
proxied: opts.proxied,
});
const name = markName(sessionId, opts.attemptId, stage);
userTiming.clear(name);
userTiming.mark(`${name}:start`);
return handle;
}
export function endSpan(handle: SpanHandle, opts: EndSpanOptions = {}): void {
const span = openSpans.get(handle);
if (!span) return;
openSpans.delete(handle);
const t1 = now();
const outcome = resolveSpanOutcome(span.attemptId, opts.outcome);
const name = markName(span.sessionId, span.attemptId, span.stage);
userTiming.mark(`${name}:end`);
userTiming.measure(name, `${name}:start`, `${name}:end`);
pushEvent({
id: handle,
sessionId: span.sessionId,
attemptId: span.attemptId,
phase: span.stage,
kind: span.kind,
t0: span.t0,
t1,
outcome,
detail: mergeDetail(span.detail, opts.detail),
proxied: opts.proxied ?? span.proxied,
nodeId: sessionNodeId(span.sessionId),
});
}
function resolveSpanOutcome(
attemptId: string | undefined,
requested: HydrationOutcome | undefined,
): HydrationOutcome {
if (attemptId) {
const attempt = attempts.get(attemptId);
if (attempt?.aborted) return 'aborted';
if (attempt?.superseded) return 'superseded';
}
return requested ?? 'ok';
}
/** True when a phase is on the critical hydration path (not a background fill). */
export function classifyCritical(phase: string): boolean {
return !BACKGROUND_PHASES.has(phase);
}
/** Elapsed ms from `boot_start` to the most recent `list_visible` in `events`. */
export function listVisibleMsFrom(
eventList: readonly HydrationEvent[],
bootAt: number | null,
): number | null {
if (bootAt == null) return null;
for (let i = eventList.length - 1; i >= 0; i--) {
if (eventList[i].phase === 'list_visible') {
return round(eventList[i].t0 - bootAt);
}
}
return null;
}
/** Elapsed ms from `boot_start` to the most recent `list_visible`, or null. */
export function getListVisibleMs(): number | null {
return listVisibleMsFrom(events, bootStartAt);
}
function toReportPhase(event: HydrationEvent): HydrationReportPhase {
const offsetMs = bootStartAt == null ? null : round(event.t0 - bootStartAt);
const durationMs = event.t1 != null ? round(event.t1 - event.t0) : undefined;
const uiCommitMs = event.commit && offsetMs != null ? offsetMs : undefined;
return {
phase: event.phase,
kind: event.kind,
outcome: event.outcome,
offsetMs,
durationMs,
uiCommitMs,
// Background fills already use kind 'background' (see milestoneKind / beginSpan).
critical: event.kind !== 'background',
proxied: event.proxied,
attemptId: event.attemptId,
detail: event.detail,
};
}
export function getHydrationReport(): HydrationReport {
return {
schemaVersion: 1,
capturedAt: Date.now(),
clock: clockKind,
...(appVersion ? { appVersion } : {}),
bootSessionId,
nodeSessionId,
nodeId: currentNodeId,
listVisibleMs: getListVisibleMs(),
anyProxied: events.some((e) => e.proxied === true),
phases: events.map(toReportPhase),
};
}
/** Clear the current node session's events (and commit dedupe), keeping boot
* markers so the boot timeline survives a manual clear. */
export function clearReport(): void {
events = events.filter((e) => e.sessionId === bootSessionId);
committedKeys.clear();
for (const [handle, span] of openSpans) {
if (span.sessionId !== bootSessionId) openSpans.delete(handle);
}
scheduleEmit();
}
// ---------------------------------------------------------------------------
// Boot session init: record boot_start once and seed the initial snapshot
// synchronously so the first `getSnapshot()` read already carries it.
// ---------------------------------------------------------------------------
bootStartAt = now();
oneShotKeys.add(`boot_start::${bootSessionId}`);
events.push({
id: nextId(),
sessionId: bootSessionId,
phase: 'boot_start',
kind: 'milestone',
t0: bootStartAt,
outcome: 'ok',
nodeId: null,
});
userTiming.mark(markName(bootSessionId, undefined, 'boot_start'));
rebuildSnapshot();