diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 761bee9cd..c4ac66121 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -46,6 +46,7 @@ pub mod pairing; mod personas; mod prevent_sleep; mod profile; +mod profiling_log; mod project_git; mod project_git_branches; mod project_git_diff; @@ -104,6 +105,7 @@ pub use pairing::*; pub use personas::*; pub use prevent_sleep::*; pub use profile::*; +pub use profiling_log::*; pub use project_git::*; pub use project_git_branches::*; pub use project_git_diff::*; diff --git a/desktop/src-tauri/src/commands/profiling_log.rs b/desktop/src-tauri/src/commands/profiling_log.rs new file mode 100644 index 000000000..3141bcb76 --- /dev/null +++ b/desktop/src-tauri/src/commands/profiling_log.rs @@ -0,0 +1,96 @@ +//! Local sink for the temporary renderer profiling harness (never merges). +//! +//! The renderer ring-buffers timing records and flushes JSONL lines here every +//! ~30s and on `pagehide`. Records are appended to +//! `/profiling/.jsonl`. Nothing is transmitted; this file +//! is the only output. When a file crosses `MAX_FILE_BYTES` it is rotated aside +//! with a millisecond suffix so a long session cannot grow unbounded -- the +//! analyzer globs `*.jsonl` and concatenates. +use std::fs::{self, OpenOptions}; +use std::io::Write; + +use tauri::{AppHandle, Manager}; + +/// Rotate the active file aside once it reaches ~50 MiB. +const MAX_FILE_BYTES: u64 = 50 * 1024 * 1024; + +/// Append profiling records (one JSONL string per element) to the session file. +/// +/// `file_stem` is caller-supplied but strictly validated to +/// `session-` so the argument can never escape the profiling dir. +#[tauri::command] +pub async fn append_profiling_log( + file_stem: String, + lines: Vec, + app: AppHandle, +) -> Result<(), String> { + if lines.is_empty() { + return Ok(()); + } + if !is_valid_stem(&file_stem) { + return Err(format!("invalid profiling file stem: {file_stem}")); + } + + tokio::task::spawn_blocking(move || { + let dir = app + .path() + .app_data_dir() + .map_err(|error| error.to_string())? + .join("profiling"); + fs::create_dir_all(&dir).map_err(|error| error.to_string())?; + + let path = dir.join(format!("{file_stem}.jsonl")); + if let Ok(meta) = fs::metadata(&path) { + if meta.len() >= MAX_FILE_BYTES { + let rolled = dir.join(format!( + "{file_stem}.{}.jsonl", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + )); + fs::rename(&path, rolled).map_err(|error| error.to_string())?; + } + } + + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| error.to_string())?; + let mut buffer = String::new(); + for line in &lines { + buffer.push_str(line); + buffer.push('\n'); + } + file.write_all(buffer.as_bytes()) + .map_err(|error| error.to_string())?; + Ok(()) + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? +} + +/// Accept only `session-` to keep the argument inside the sink dir. +fn is_valid_stem(stem: &str) -> bool { + stem.strip_prefix("session-") + .is_some_and(|rest| !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit())) +} + +#[cfg(test)] +mod tests { + use super::is_valid_stem; + + #[test] + fn accepts_session_timestamp_stem() { + assert!(is_valid_stem("session-1700000000000")); + } + + #[test] + fn rejects_traversal_and_malformed_stems() { + assert!(!is_valid_stem("session-../etc/passwd")); + assert!(!is_valid_stem("session-")); + assert!(!is_valid_stem("other-123")); + assert!(!is_valid_stem("session-12a")); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b00d61521..0335d9842 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -148,8 +148,7 @@ pub fn run() { } // Linux/WebKitGTK needs media-stream settings and a - // permission-request handler for getUserMedia; no-op - // on macOS/Windows. + // permission-request handler for getUserMedia; no-op on macOS/Windows. linux_media::enable_media_capture(&webview); // macOS applies the restored geometry asynchronously. Wait @@ -670,6 +669,7 @@ pub fn run() { search_users, get_presence, get_os_idle_seconds, + append_profiling_log, get_default_relay_url, auto_connect_default_relay_enabled, get_legacy_workspace_storage, diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 0f311f3a6..e0b7ce0a4 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -61,6 +61,7 @@ import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChang import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; +import { startProfilingHarness } from "@/shared/profiling/harness"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; import { @@ -215,6 +216,10 @@ function CommunityQueryProvider({ children }: { children: ReactNode }) { useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]); + // Temporary profiling harness (never merges): start once with a live + // QueryClient so the accumulator census can read the query cache. + useEffect(() => startProfilingHarness(queryClient), [queryClient]); + useEffect(() => { const e2eWindow = window as Window & { __BUZZ_E2E__?: unknown; diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index 7ae4d0bfc..ffd2c82bc 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -924,3 +924,38 @@ export function _testGetArchivedChannelEvents( archiveEventsByChannel.get(archiveChannelKey(agentPubkey, channelId)) ?? [] ); } + +/** + * Profiling-harness accessor (temporary branch): cheap size census of the + * module-level accumulator maps. Reads `.length`/`.size` only — no allocation, + * no copy — so it is safe to sample every couple of minutes. Feeds the + * `census` profiling record that adjudicates whether accumulator growth + * correlates with felt lag. + */ +export function getObserverStoreCensus(): { + observerEvents: number; + observerAgents: number; + observerMaxPerAgent: number; + archiveEvents: number; + transcripts: number; +} { + let observerEvents = 0; + let observerMaxPerAgent = 0; + for (const events of eventsByAgent.values()) { + observerEvents += events.length; + if (events.length > observerMaxPerAgent) { + observerMaxPerAgent = events.length; + } + } + let archiveEvents = 0; + for (const events of archiveEventsByChannel.values()) { + archiveEvents += events.length; + } + return { + observerEvents, + observerAgents: eventsByAgent.size, + observerMaxPerAgent, + archiveEvents, + transcripts: transcriptByAgent.size, + }; +} diff --git a/desktop/src/shared/profiling/drift.ts b/desktop/src/shared/profiling/drift.ts new file mode 100644 index 000000000..098874b0a --- /dev/null +++ b/desktop/src/shared/profiling/drift.ts @@ -0,0 +1,26 @@ +// Temporary renderer profiling harness (never merges) — event-loop drift math. +// +// WebKit exposes no Long Tasks API, so main-thread stalls are inferred from +// timer drift: a timer armed for `intervalMs` that fires late by more than +// `thresholdMs` means the main thread was blocked for roughly that overage. + +/** Default sampling interval for the drift timer. */ +export const DRIFT_INTERVAL_MS = 500; +/** Report a stall only when drift exceeds this — filters normal scheduler jitter. */ +export const DRIFT_THRESHOLD_MS = 50; + +/** + * Overage of an observed timer fire versus its expected fire time. + * + * `expected` is `armedAt + intervalMs`; `observed` is the actual fire time. + * Returns the non-negative main-thread block estimate, or `null` when the + * fire is within threshold (no stall worth recording). + */ +export function computeDrift( + observed: number, + expected: number, + thresholdMs: number = DRIFT_THRESHOLD_MS, +): number | null { + const drift = observed - expected; + return drift > thresholdMs ? drift : null; +} diff --git a/desktop/src/shared/profiling/harness.test.mjs b/desktop/src/shared/profiling/harness.test.mjs new file mode 100644 index 000000000..ecb8e3f89 --- /dev/null +++ b/desktop/src/shared/profiling/harness.test.mjs @@ -0,0 +1,111 @@ +/** + * Harness-core tests for the temporary renderer profiling branch (never merges). + * + * Scope is the pure, deterministic core only: ring-buffer bounds + flush + * draining, drift-sampler math, and the JSONL record schema. The DOM/timer/IPC + * probes are integration glue exercised by Will's day of driving, not unit + * tests. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { computeDrift, DRIFT_THRESHOLD_MS } from "@/shared/profiling/drift.ts"; +import { ProfileRecorder, RING_CAPACITY } from "@/shared/profiling/recorder.ts"; + +function fixedClock() { + let t = 1000; + return { + now: () => (t += 1), + wall: () => 1_700_000_000_000, + }; +} + +describe("computeDrift", () => { + it("returns null when the fire is within threshold", () => { + assert.equal(computeDrift(1050, 1000, 100), null); + // Exactly at threshold is not a stall (strictly greater). + assert.equal(computeDrift(1000 + DRIFT_THRESHOLD_MS, 1000), null); + }); + + it("returns the overage when the timer fires late past threshold", () => { + assert.equal(computeDrift(1200, 1000, 50), 200); + }); + + it("treats early fires as no stall", () => { + assert.equal(computeDrift(980, 1000, 50), null); + }); +}); + +describe("ProfileRecorder ring buffer", () => { + it("bounds the buffer at RING_CAPACITY, dropping oldest", async () => { + const rec = new ProfileRecorder("session-1", async () => {}, fixedClock()); + for (let i = 0; i < RING_CAPACITY + 50; i++) { + rec.record({ type: "stall", dur: i }); + } + assert.equal(rec.size(), RING_CAPACITY); + }); + + it("flushes serialized JSONL lines and drains the buffer", async () => { + const captured = []; + const rec = new ProfileRecorder( + "session-1", + async (lines) => { + captured.push(...lines); + }, + fixedClock(), + ); + rec.record({ type: "stall", dur: 42 }); + rec.record({ + type: "census", + observerEvents: 5, + observerAgents: 1, + observerMaxPerAgent: 5, + archiveEvents: 0, + transcripts: 1, + queryCache: 3, + domNodes: 100, + }); + await rec.flush(); + + assert.equal(rec.size(), 0); + assert.equal(captured.length, 2); + const first = JSON.parse(captured[0]); + assert.equal(first.type, "stall"); + assert.equal(first.dur, 42); + assert.equal(first.sid, "session-1"); + assert.equal(typeof first.t, "number"); + assert.equal(typeof first.wall, "number"); + assert.equal(typeof first.up, "number"); + }); + + it("stamps the drop count onto the first line after overflow", async () => { + const captured = []; + const rec = new ProfileRecorder( + "session-1", + async (lines) => { + captured.push(...lines); + }, + fixedClock(), + ); + for (let i = 0; i < RING_CAPACITY + 10; i++) { + rec.record({ type: "stall", dur: i }); + } + await rec.flush(); + const first = JSON.parse(captured[0]); + assert.equal(first.dropped, 10); + }); + + it("does not throw when the sink rejects, and still drains", async () => { + const rec = new ProfileRecorder( + "session-1", + async () => { + throw new Error("sink down"); + }, + fixedClock(), + ); + rec.record({ type: "stall", dur: 1 }); + await rec.flush(); + assert.equal(rec.size(), 0); + }); +}); diff --git a/desktop/src/shared/profiling/harness.ts b/desktop/src/shared/profiling/harness.ts new file mode 100644 index 000000000..7bf287ece --- /dev/null +++ b/desktop/src/shared/profiling/harness.ts @@ -0,0 +1,227 @@ +// Temporary renderer profiling harness (never merges) — bootstrap + probes. +// +// One always-on harness that ring-buffers timing records and flushes JSONL to +// `/profiling/session-.jsonl`. Four probes: +// 1. main-thread stalls (timer drift) + input latency (down → next paint) +// 2. Tauri IPC duration/outcome + a >10s pending-invoke watchdog (deadlocks) +// 3. relay REQ→EOSE (history fetch) and publish send→ack round-trips +// 4. periodic accumulator census (observer store, query cache, DOM nodes) +// +// The relay probe wraps the `relayClient` singleton's public methods (below), +// so `relayClientSession.ts` is untouched and no probe lines land in request +// code. + +import type { QueryClient } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; + +import { getObserverStoreCensus } from "@/features/agents/observerRelayStore"; +import { relayClient } from "@/shared/api/relayClient"; +import { + DRIFT_INTERVAL_MS, + DRIFT_THRESHOLD_MS, + computeDrift, +} from "@/shared/profiling/drift"; +import { + FLUSH_INTERVAL_MS, + ProfileRecorder, +} from "@/shared/profiling/recorder"; + +const CENSUS_INTERVAL_MS = 120_000; +const PENDING_INVOKE_MS = 10_000; +const PENDING_SCAN_MS = 5_000; +const INPUT_LATENCY_MIN_MS = 100; + +// RelayClient methods whose resolution marks a REQ→EOSE round-trip (history +// fetches resolve when the relay sends EOSE) versus a publish send→ack +// round-trip (resolves on the relay's OK). All publishes funnel through +// `publishEvent`, and the instance-property wrap shadows the prototype so +// internal `this.publishEvent` calls are captured too — wrapping the public +// send* wrappers as well would double-count. Wrapping the singleton keeps the +// probe out of the grandfathered relayClientSession.ts and adds zero lines to +// production request code. +const RELAY_FETCH_METHODS = [ + "fetchChannelHistory", + "fetchChannelHistoryBefore", + "fetchAuxEventsByReference", + "fetchAuxDeletionEventsForAuxEvents", + "fetchEvents", + "fetchFirstEvent", +] as const; +const RELAY_PUBLISH_METHODS = ["publishEvent"] as const; + +let started = false; + +type AsyncMethod = (...args: unknown[]) => Promise; + +function wrapRelayMethod( + rec: ProfileRecorder, + method: string, + type: "relay_req" | "relay_pub", +): void { + const target = relayClient as unknown as Record; + const original = target[method]; + if (typeof original !== "function") { + return; + } + const bound = (original as AsyncMethod).bind(relayClient); + target[method] = (...args: unknown[]) => { + const startedAt = performance.now(); + const done = (ok: boolean) => + rec.record({ type, op: method, dur: performance.now() - startedAt, ok }); + return bound(...args).then( + (value) => { + done(true); + return value; + }, + (error) => { + done(false); + throw error; + }, + ); + }; +} + +function installRelayProbe(rec: ProfileRecorder): void { + for (const method of RELAY_FETCH_METHODS) { + wrapRelayMethod(rec, method, "relay_req"); + } + for (const method of RELAY_PUBLISH_METHODS) { + wrapRelayMethod(rec, method, "relay_pub"); + } +} + +type TauriInternals = { + invoke: (cmd: string, args?: unknown, opts?: unknown) => Promise; +}; + +function installIpcProbe(rec: ProfileRecorder): void { + const internals = ( + window as unknown as { __TAURI_INTERNALS__?: TauriInternals } + ).__TAURI_INTERNALS__; + if (!internals || typeof internals.invoke !== "function") { + return; + } + const original = internals.invoke.bind(internals); + // Track outstanding invokes so a hung command surfaces via the watchdog. + const pending = new Map(); + let seq = 0; + + internals.invoke = (cmd: string, args?: unknown, opts?: unknown) => { + const id = seq++; + const startedAt = performance.now(); + pending.set(id, { cmd, startedAt }); + const settle = (ok: boolean) => { + pending.delete(id); + rec.record({ type: "ipc", cmd, dur: performance.now() - startedAt, ok }); + }; + return original(cmd, args, opts).then( + (value) => { + settle(true); + return value; + }, + (error) => { + settle(false); + throw error; + }, + ); + }; + + window.setInterval(() => { + const now = performance.now(); + for (const { cmd, startedAt } of pending.values()) { + const age = now - startedAt; + if (age > PENDING_INVOKE_MS) { + rec.record({ type: "ipc_pending", cmd, age }); + } + } + }, PENDING_SCAN_MS); +} + +function installStallProbe(rec: ProfileRecorder): void { + let armedAt = performance.now(); + window.setInterval(() => { + const observed = performance.now(); + const drift = computeDrift( + observed, + armedAt + DRIFT_INTERVAL_MS, + DRIFT_THRESHOLD_MS, + ); + if (drift !== null) { + rec.record({ type: "stall", dur: drift }); + } + armedAt = observed; + }, DRIFT_INTERVAL_MS); +} + +function installInputProbe(rec: ProfileRecorder): void { + const onInput = (kind: "keydown" | "pointerdown") => (event: Event) => { + const start = event.timeStamp; + requestAnimationFrame(() => + requestAnimationFrame(() => { + const latency = performance.now() - start; + if (latency >= INPUT_LATENCY_MIN_MS) { + rec.record({ type: "input", kind, latency }); + } + }), + ); + }; + // Capture phase so the timestamp precedes app handlers. + window.addEventListener("keydown", onInput("keydown"), { + capture: true, + passive: true, + }); + window.addEventListener("pointerdown", onInput("pointerdown"), { + capture: true, + passive: true, + }); +} + +function installCensusProbe( + rec: ProfileRecorder, + queryClient: QueryClient, +): void { + const sample = () => { + const observer = getObserverStoreCensus(); + rec.record({ + type: "census", + observerEvents: observer.observerEvents, + observerAgents: observer.observerAgents, + observerMaxPerAgent: observer.observerMaxPerAgent, + archiveEvents: observer.archiveEvents, + transcripts: observer.transcripts, + queryCache: queryClient.getQueryCache().getAll().length, + domNodes: document.getElementsByTagName("*").length, + }); + }; + sample(); + window.setInterval(sample, CENSUS_INTERVAL_MS); +} + +/** + * Start the harness once, wiring all four probes and the JSONL sink. Idempotent + * and non-throwing: profiling must never take down the app it measures. + */ +export function startProfilingHarness(queryClient: QueryClient): void { + if (started) { + return; + } + started = true; + + try { + const sid = `session-${Date.now()}`; + const rec = new ProfileRecorder(sid, async (lines) => { + await invoke("append_profiling_log", { fileStem: sid, lines }); + }); + + installIpcProbe(rec); + installRelayProbe(rec); + installStallProbe(rec); + installInputProbe(rec); + installCensusProbe(rec, queryClient); + + window.setInterval(() => void rec.flush(), FLUSH_INTERVAL_MS); + window.addEventListener("pagehide", () => void rec.flush()); + } catch { + // Never let harness setup break the app being profiled. + } +} diff --git a/desktop/src/shared/profiling/recorder.ts b/desktop/src/shared/profiling/recorder.ts new file mode 100644 index 000000000..b13d04a22 --- /dev/null +++ b/desktop/src/shared/profiling/recorder.ts @@ -0,0 +1,98 @@ +// Temporary renderer profiling harness (never merges) — ring-buffered recorder. +// +// Records are appended to a bounded in-memory ring buffer and flushed as JSONL +// to a local file (via the `append_profiling_log` Tauri command) every +// `FLUSH_INTERVAL_MS` and on `pagehide`. The buffer is bounded so an unflushed +// or failed sink can never grow the harness into the thing it measures. + +import type { ProfileEvent, ProfileRecord } from "@/shared/profiling/types"; + +/** Max records held between flushes. Oldest are dropped once exceeded. */ +export const RING_CAPACITY = 4096; +/** Flush cadence. */ +export const FLUSH_INTERVAL_MS = 30_000; + +/** Sink receives already-serialized JSONL lines; returns once persisted. */ +export type ProfileSink = (lines: string[]) => Promise; + +export type RecorderClock = { + now: () => number; + wall: () => number; +}; + +/** + * Bounded recorder. `record()` stamps the shared envelope and pushes into the + * ring; `flush()` drains the ring through the sink. Drops on overflow are + * counted and surfaced as a synthetic field on the next flush's first line so + * the analyzer can detect (rare) self-pressure. + */ +export class ProfileRecorder { + private readonly buffer: ProfileRecord[] = []; + private droppedSinceFlush = 0; + private flushing = false; + private readonly startedAt: number; + private readonly sid: string; + private readonly sink: ProfileSink; + private readonly clock: RecorderClock; + + constructor( + sid: string, + sink: ProfileSink, + clock: RecorderClock = { + now: () => performance.now(), + wall: () => Date.now(), + }, + ) { + this.sid = sid; + this.sink = sink; + this.clock = clock; + this.startedAt = clock.now(); + } + + record(event: ProfileEvent): void { + const now = this.clock.now(); + const full = { + ...event, + t: now, + wall: this.clock.wall(), + sid: this.sid, + up: now - this.startedAt, + } as ProfileRecord; + + this.buffer.push(full); + if (this.buffer.length > RING_CAPACITY) { + this.buffer.shift(); + this.droppedSinceFlush += 1; + } + } + + /** Current buffered record count — used by tests and the pending watchdog. */ + size(): number { + return this.buffer.length; + } + + async flush(): Promise { + // Serialize flushes; a slow sink must not let two drains race the buffer. + if (this.flushing || this.buffer.length === 0) { + return; + } + this.flushing = true; + const drained = this.buffer.splice(0, this.buffer.length); + const dropped = this.droppedSinceFlush; + this.droppedSinceFlush = 0; + + try { + const lines = drained.map((record, index) => + index === 0 && dropped > 0 + ? JSON.stringify({ ...record, dropped }) + : JSON.stringify(record), + ); + await this.sink(lines); + } catch { + // Sink failure must not crash the app being profiled. The dropped count + // is intentionally not restored — the records are already gone. + } finally { + this.flushing = false; + } + } +} diff --git a/desktop/src/shared/profiling/types.ts b/desktop/src/shared/profiling/types.ts new file mode 100644 index 000000000..54d789ff0 --- /dev/null +++ b/desktop/src/shared/profiling/types.ts @@ -0,0 +1,94 @@ +// Temporary renderer profiling harness (never merges) — record schema. +// +// Every record carries a shared envelope plus a `type` discriminant. One JSONL +// line is written per record; the analyzer keys off `type`. + +/** Fields present on every profiling record. */ +export type ProfileEnvelope = { + /** `performance.now()` at record time — monotonic, sub-ms, for ordering/deltas. */ + t: number; + /** `Date.now()` wall clock — for correlating with Will's felt lag. */ + wall: number; + /** Per-launch session id so multiple runs never interleave. */ + sid: string; + /** Milliseconds since the harness started this session. */ + up: number; +}; + +export type StallRecord = ProfileEnvelope & { + type: "stall"; + /** Observed-minus-expected timer fire, ms (the main-thread block). */ + dur: number; +}; + +export type InputRecord = ProfileEnvelope & { + type: "input"; + kind: "keydown" | "pointerdown"; + /** Event timestamp → next painted frame, ms. */ + latency: number; +}; + +export type IpcRecord = ProfileEnvelope & { + type: "ipc"; + cmd: string; + /** Invoke call → resolve/reject, ms. */ + dur: number; + ok: boolean; +}; + +export type IpcPendingRecord = ProfileEnvelope & { + type: "ipc_pending"; + cmd: string; + /** Age of a still-outstanding invoke, ms — the deadlock detector. */ + age: number; +}; + +export type RelayReqRecord = ProfileEnvelope & { + type: "relay_req"; + /** RelayClient fetch method name — the REQ→EOSE round-trip label. */ + op: string; + /** Method call → resolve (EOSE), ms. */ + dur: number; + ok: boolean; +}; + +export type RelayPubRecord = ProfileEnvelope & { + type: "relay_pub"; + /** RelayClient publish method name — the send→ack round-trip label. */ + op: string; + /** Method call → resolve (relay OK), ms. */ + dur: number; + ok: boolean; +}; + +export type CensusRecord = ProfileEnvelope & { + type: "census"; + /** Total observer events retained across all agents. */ + observerEvents: number; + observerAgents: number; + /** Largest single-agent retained event count. */ + observerMaxPerAgent: number; + /** Total archived observer events across all channels. */ + archiveEvents: number; + transcripts: number; + /** React Query cache entry count. */ + queryCache: number; + /** Live DOM node count. */ + domNodes: number; +}; + +export type ProfileRecord = + | StallRecord + | InputRecord + | IpcRecord + | IpcPendingRecord + | RelayReqRecord + | RelayPubRecord + | CensusRecord; + +/** Payload the recorder builds internally, before the envelope is stamped. */ +export type ProfileEvent = ProfileRecord extends infer R + ? R extends ProfileRecord + ? Omit + : never + : never;