diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5d3ab72e..62124159 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -63,7 +63,7 @@ Read this first; then `docs/design/sdk-design.md` (Draft v3) for the full archit - `buildNodeEnvironment(input, probe)` — §8.6 envelope via injectable `SystemProbe`. Applies `optionsToWire` to `sdk.options` (dots → colons; server treats dots as nested-document paths). - `createUncaughtExceptionProvider` / `createUnhandledRejectionProvider` — process-event detection, V8 stack parsed + scrubbed. - `createNodeHttpInterceptor` — wraps `node:http(s)` `request` + `get` (mechanism `'http'`), captures axios/got/node-fetch that bypass global fetch. **Transparent error handling**: re-raises the original error when it is the sole `'error'` listener so an otherwise-uncaught request error still crashes the app. -- `createNodeSystemMetricsSampler` — process memory (rss/heapTotal/heapUsed/external/**arrayBuffers**), **system memory (`ram_system_total`/`ram_system_free` — Android parity)**, CPU user/system per-sample deltas + **normalized `cpu_usage_process` %** (over wall-time/cores), event-loop lag **mean/max/p99** + **utilization** (perf_hooks `monitorEventLoopDelay` + `eventLoopUtilization`). All readers injectable. +- `createNodeSystemTraceProducers` (the DEFAULT since ST0′; the legacy `createNodeSystemMetricsSampler` below is now only the `systemMetricsSampler` override) / `createNodeSystemMetricsSampler` — process memory (rss/heapTotal/heapUsed/external/**arrayBuffers**), **system memory (`ram_system_total`/`ram_system_free` — Android parity)**, CPU user/system per-sample deltas + **normalized `cpu_usage_process` %** (over wall-time/cores), event-loop lag **mean/max/p99** + **utilization** (perf_hooks `monitorEventLoopDelay` + `eventLoopUtilization`). All readers injectable. - `createNodeSystemEventsSource` — process lifecycle (`process_started` on activate, `process_exiting`, **`process_before_exit`** clean-drain, `process_warning`, and **`process_signal`** for SIGTERM/SIGINT). Signal capture is PASSIVE: it emits the event, and only when the SDK is the sole handler does it remove itself and **re-raise** (`process.kill`) to restore Node's default termination — so it never hangs a process or hijacks an app's own signal handler. - **`launch(appToken, options)`** — the composition root: builds the transport (internal-tagged), api/uploader/upload pipeline, durable wrapper (when `dataDir`/`bundleStore` present), env builder, store (in-memory / file-backed), resolves the friendly options once via `NODE_OPTION_DEFINITIONS`, registers gated capture providers (console→log; network umbrella with `node:http` folded in; system traces/events) + detection providers, calls `client.launch()`, runs durable `recover()` on start, installs the uncaughtException → `flush(timeout)` → `proc.exit(1)` policy (opt-out `exitOnUncaught`, default `shutdownTimeoutMs: 3000`), and augments `client.stop()` to remove its process listener. Returns the started `BugseeClient`. @@ -77,7 +77,7 @@ Read this first; then `docs/design/sdk-design.md` (Draft v3) for the full archit **`@bugsee/browser`**: - `buildBrowserEnvironment(input, probe)` — §8.6 envelope (`platform.type: 'web'`) via injectable `BrowserProbe` (navigator/screen/Intl; raw UA as `platform.version` — backend parses; deviceMemory/hardwareConcurrency optional). `optionsToWire` on `sdk.options`. - `createWindowErrorProvider` / `createUnhandledRejectionProvider` — window `error` → crash / `unhandledrejection` → error; `parseStack` dispatches V8 (`at fn (loc)`) vs SpiderMonkey/JSC (`fn@loc`) dialects (core's `parseLocation` reused). -- `createBrowserSystemTracesSampler` (traces: `ram_js_heap_*` (grouped, Android `ram_jvm_heap` parity) + `connection` (transport name from the viewer's CONNECTION_STATES vocabulary, `navigator.onLine` first) + `orientation` (the Android/iOS `Orientation` int, never the browser's `{type, angle}`) + `battery`/`charging`, each degrading where its API is absent) + `createBrowserSystemEventsSource` (events: `process_started`, `pagehide`→`process_exiting`, `visibilitychange`→`process_foreground`/`process_background`, `online`/`offline`, `orientationchange`→`orientation_changed`). See the capture-completeness milestone in §7. +- `createBrowserSystemTracesSampler` (traces: `ram_js_heap_*` (grouped, Android `ram_jvm_heap` parity) + `connection` (transport name from the viewer's CONNECTION_STATES vocabulary, `navigator.onLine` first) + `orientation` (the Android/iOS `Orientation` int, never the browser's `{type, angle}`), each degrading where its API is absent; `battery`/`charging` moved OUT of the sampler in ST0′ — they come from `BatteryProducer` over `BatteryTracker`, see `createBrowserSystemTraceProducers`) + `createBrowserSystemEventsSource` (events: `process_started`, `pagehide`→`process_exiting`, `visibilitychange`→`process_foreground`/`process_background`, `online`/`offline`, `orientationchange`→`orientation_changed`). See the capture-completeness milestone in §7. - `createBrowserInputSource` (input: capture-phase/passive DOM listeners → the dedicated **`input`** stream (`input.json`) via the runtime-agnostic `createInputProvider`) — **pointerdown/pointerup/pointercancel** (Pointer Events, so `pointerType` gives the wire `tool` for free and a touchpad correctly reports Mouse; `pointerId` keys the gesture; `button` covers secondary/middle) + **keydown**, emitting the viewer's `RecordingTouchEvent` shape. (`change`/`submit`/`focus` left this stream on 2026-08-31 — they are state-change signals, not device input; see `createUiBreadcrumbSource` below.) PII-safe `describeTarget` (tag/id/class/type/text/selector) masks anything matching the SHARED sensitive-input definition or `[data-bugsee-hidden]`; typed text never captured (AltGr/emoji/IME-robust); **every keystroke aimed at a sensitive/masked field is dropped outright**; throw-isolated. **It does NOT write to `events.user`** — see the `*.user` rule below. See the capture-completeness milestone in §7. - `createUiBreadcrumbSource` / `createUiBreadcrumbProvider` (**UI breadcrumbs**, 2026-08-31): the state-change half of the DOM interaction split — capture-phase/passive listeners for `change`/`submit`/`focusin` → `client.addBreadcrumb` (so the app's `breadcrumbFilter` runs) in Android `BreadcrumbInputGesture`'s shape: `{type:'user', category:'ui.change'|'ui.submit'|'ui.focus', level:'info', data:{'view.id','view.class','view.tag'}, timestamp: the DOM event's own moment (`performance.timeOrigin + Event.timeStamp`, clamped to now)}`. `type:'user'` is Android's literal for a user-ORIGINATED breadcrumb — **not** the `*.user` stream (these are `breadcrumbs` entries). Why not `input`: `InputEvent.type` is Android's `InputEventStage` (`unknown|begin|move|end|scroll|keydown|keyup`), which a DOM-only value cannot join, and Android already splits its input dispatcher (→ `input.json`) from its gesture dispatcher (→ breadcrumbs). Reuses `describeTarget`; a sensitive/app-masked target **drops the breadcrumb outright** (the focus/change rhythm on a masked field is itself a side channel). Gated by `captureInteractions` (the option means "do not watch what I click and type", whatever stream it lands on). Wired into `@bugsee/browser` **and** `@bugsee/webview` (which also now declares `breadcrumbs` in `hello.caps`). - `createDomSnapshot` / `createViewtreeSnapshotSource` (view hierarchy: an at-report DOM-tree snapshot → `viewtree`, via the core `reportSnapshots` pull-seam) — reuses `describeTarget` per node + rounded `getBoundingClientRect`; masked subtrees collapse to `{tag, masked, rect}`; bounded (maxNodes/maxDepth) + per-node throw-isolated. Gated by `captureViewHierarchy`. See §7. @@ -125,7 +125,7 @@ Pluggable extensions (APM/replay/etc.) remain the contract for non-core features | Durable bundle queue | `BundleStore` (type) + `createDurableUploadPipeline` | `createNodeBundleStore` (fs files) | IndexedDB-backed | | Capture sources | `console`, `fetch`, `xhr`, `sendBeacon`, `ws`, `sse`, `webtransport` (self-skip if absent) | `node:http` added via `installNetworkCapture({ additionalSources })` | DOM-specific sources | | Process events | — | `process` (`uncaughtException`, `unhandledRejection`, `exit`, `warning`) | `window.onerror`, `unhandledrejection` | -| System metrics | `TraceSample[]` sampler injected | `createNodeSystemMetricsSampler` | `performance.memory` etc. | +| System metrics | trace producers over shared trackers (`TraceProducer`); legacy `TraceSample[]` sampler still accepted | `createNodeSystemTraceProducers` (legacy `createNodeSystemMetricsSampler` only as an override) | `createBrowserSystemTracesSampler` (`performance.memory` etc.) + `createBrowserSystemTraceProducers` (battery) | | Clock / Scheduler / SystemProbe | injectable interfaces in core | real defaults (`Date.now`, `setInterval`, `os.*`) | platform defaults | Cross-runtime capture interceptors **self-skip when their global is absent**, so `@bugsee/capture` can be imported safely on any runtime; only the runtime-specific *additionalSources* differ. @@ -389,8 +389,24 @@ solid), in slices: `orientation_changed`, alongside `process_started`/`pagehide`. Injected env (window/document/screen), graceful degradation. - **CE2 — system TRACES breadth (DONE, `main`):** `createBrowserSystemTracesSampler` adds `connection` - (navigator.connection), `orientation` (screen.orientation), `battery`/`charging` (cached BatteryManager) - to the existing `ram_js_heap_*`. Android trace-name parity; degrades per-API. + (navigator.connection), `orientation` (screen.orientation), `battery`/`charging` (since ST0′ via the + event-driven `BatteryTracker`, not the sampler) to the existing `ram_js_heap_*`. Android trace-name parity; degrades per-API. +- **ST0′ (DONE, `feat/trace-producers`):** `traces.system` is now producers over shared trackers. + `@bugsee/capture` has `TraceProducer`/`TraceProducerBase`; the traces provider is a producer container + (per-producer start/stop isolation, last-value refresh so a rolling window never loses a rarely-changing + trace, legacy `sample` kept). Node: `ProcessStatsTracker` (one timer; memory/system RAM/CPU/event-loop) + + `createNodeSystemTraceProducers` with the Bun/Deno honesty flags on its deps (Deno omits event-loop lag). + Browser: `BatteryTracker` (`getBattery` only on activation, abortable) + `BatteryProducer`; the `charging` + trace is `{ state: 1|2|3 }` (the viewer's table keys), not a boolean. **Bun/Deno** wire the same + `createNodeSystemTraceProducers` from their launches (forwarding the launch `scheduler`/`onError`; Deno adds + `measuresEventLoopDelay: false`); **webview** adopts `createBrowserSystemTraceProducers` next to its sampler. + New launch option **`systemTraceProducers`** (node/bun/deno) — RESET on relaunch, because producer instances + are stateful (a reused instance's `start()` is a no-op). **Refresh applies only to HELD values**: + `TraceSink.emit(…, { hold })` — `emitIfChanged` holds (battery/charging) and is re-recorded every + `refreshMs` = `deriveRefreshMs(window)` (window/4 clamped to [2 s, 10 s]); polled values (cpu/memory/lag/ELU) + and legacy `sample()` rows are never refreshed, so a failed poll leaves a gap instead of a frozen line. Event + -loop lag is omitted until the delay histogram has a sample (no fabricated 0 ms baseline). Legacy samplers + remain until a cleanup slice; remaining browser traces migrate in follow-on slices. - **CE3 — input capture (DONE, `main`; re-based onto the `input` stream 2026-08-27; the state-change signals split off to breadcrumbs 2026-08-31):** `createBrowserInputSource` (browser) — capture-phase, passive, observe-only DOM listeners for @@ -676,9 +692,11 @@ Profiler all present). Deno 2 is node-API-compatible, so `@bugsee/deno` mirrors `@bugsee/bun`: re-exports the ENTIRE `@bugsee/node` composition (transport, fs storage, node:http capture, crash detection, durable queue + capture recovery, **CPU profiling, ANR/hang detection**) and overrides only the Deno identity probe (`platform.type: 'deno'`, -version from `Deno.version.deno` with a node-compat fallback) + the shared guarded `perf_hooks` sampler. +version from `Deno.version.deno` with a node-compat fallback) + the guarded `perf_hooks` trace producers. - **Shared sampler refactor:** the guarded perf_hooks sampler moved from `@bugsee/bun` to `@bugsee/node` as - `createGuardedSystemMetricsSampler` (runtime-agnostic; Bun + Deno both use it; bun's local copy removed). + `createGuardedSystemMetricsSampler` (bun's local copy removed). Since ST0′ neither launch uses it: Bun + Deno + both pass `createNodeSystemTraceProducers` (whose lazy guarded event-loop sources replace it); it remains + exported as a legacy `systemMetricsSampler`. - **Verified on REAL Deno 2.8.3:** `Deno.version.deno` = '2.8.3' (note: `process.version` is the *node-compat* version, so the probe reads the Deno global, not `process.versions`); and ALL diagnostics primitives work — `node:inspector` Profiler (CPU profiling), `worker_threads` + `SharedArrayBuffer` + `Worker#unref` (ANR), diff --git a/docs/design/system-traces-st0-plan.md b/docs/design/system-traces-st0-plan.md new file mode 100644 index 00000000..5d543a0b --- /dev/null +++ b/docs/design/system-traces-st0-plan.md @@ -0,0 +1,1149 @@ +# ST0′ — Tracker / Producer Substrate for System Traces — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure `traces.system` the way the Android/iOS SDKs do it — shared **trackers** that own raw signals and aggregate state, per-trace **producers** that subscribe to trackers and turn state into values, and a **provider** that is a container of producers — and prove the pattern end-to-end on one node chain (process stats + event loop) and one browser chain (battery, with the `charging` fix). This replaces the earlier `LifecycleSampler` plan: resource ownership now follows from subscriber presence, so the lifecycle seam is not needed. + +**Architecture:** A tracker is an `InterceptorBase` subclass (already the JS listenable-emitter-with-subscriber-presence-activation primitive): it attaches its OS resource (timer, histogram, `getBattery()` listener) on the first subscriber and releases it on the last. A producer subscribes to a tracker in `start()` and unsubscribes in `stop()`, emits a baseline at start, and de-duplicates. `createSystemTracesProvider` takes `producers`, starts/stops each with error isolation, keeps a last-value snapshot, and re-emits stale values so a rolling capture window never loses a rarely-changing trace. The old bare `sample()` option is kept as a legacy polled adapter so nothing existing breaks while later slices migrate the rest. + +**Tech Stack:** TypeScript (`module: ESNext`, `verbatimModuleSyntax`), Vitest, pnpm + turbo, Biome. Node ≥18, Bun, Deno, browsers. + +**Spec:** `docs/design/system-traces-expansion.md` (PR #53) — read §5, §9, §12, §13.4 and the new **§14 (tracker/producer structure)**. Mobile references: Android `shared/trackers/BugseeTrackerBase.java`, `capture/providers/traces_system/*`, `BugseeCaptureValueProducer*`; iOS `Shared/Trackers/BGSTrackerBase.*`, `Capture/Providers/TracesSystem/*`. + +## Global Constraints + +- **Coverage per package:** 100% line/function/statement, ≥ 90% branch (vitest v8); unreachable code only via `/* v8 ignore … */` with a one-line justification. +- **Test-first + mutator loop (`docs/implementation-standards.md` §2):** write the failing test, implement, then inject up to 10 different bugs into the new code and confirm the tests fail each time; **restore by `cp` from a saved copy, never `git checkout`**; never commit a mutation. +- **Typecheck separately:** vitest does not typecheck — run `pnpm --filter @bugsee/ exec tsc --noEmit` before every commit. +- **Gate before pushing:** `pnpm ci:local`. +- **Portable shared tiers:** `@bugsee/capture` and `@bugsee/core` never import `node:*` or DOM types. +- **Trackers are `InterceptorBase` subclasses**, created **per launch** from injected environment (like `createBrowserSystemEventsSource`), not module singletons. Activation is by subscriber presence only; a tracker never starts anything in its constructor. +- **Never fabricate a reading:** when a source cannot answer, the trace is omitted, never zeroed. Bun/Deno honesty flags live on the tracker/factory deps, not on launch options. +- **Error isolation at every layer:** the emitter already isolates a throwing listener; the provider additionally isolates each producer's `start()`/`stop()`; the provider timer never lets an exception escape. +- **Wire shape:** unchanged trace names and values everywhere **except** `charging`, which becomes `{ state: 1 | 2 | 3 }` (viewer table keys `"0"`–`"3"`), `source` omitted, never invented. `environment.hardware.charging` stays a boolean. +- **Public API:** `systemMetricsSampler` keeps working (legacy). New option `systemTraceProducers`. Beta packages — removing the legacy samplers is a later cleanup slice, not this plan. +- **Branching:** reviewed as a PR. Branch `feat/trace-producers` from `main`; one commit per task; draft PR after Task 3. Attribution on every commit/PR: `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. + +## Review Focus + +Failure modes the spec implies but a straight reading of the tasks would not exercise (each pinned by a named test in the owning task): + +1. **Option gate:** with `captureSystemTraces: false` no tracker activates — no timer, no histogram, no `getBattery()` call — through the real launch + provider path, for the node default **and** the Bun/Deno-flagged factory. (Tasks 4, 5, 6.) +2. **One producer throws in `start()`** → the others still start, the error is reported, the half-started producer is stopped. **One throws in `stop()`** → the rest still stop. (Task 2.) +3. **Rolling-window loss:** a value that never changes (e.g. battery) must still be present in a report taken after the window has rolled — the provider's refresh re-emits it. (Task 2.) +4. **Start → stop → start:** trackers re-attach, baselines re-emit, no double timers, no stale state. (Tasks 3, 4, 6.) +5. **`getBattery()` resolves after `stop()`, or after a stop→start:** the late fulfilment must not attach listeners or emit. (Task 6.) +6. **Late callbacks after provider stop:** a tracker event delivered after the provider stopped must not write an entry. (Task 2.) +7. **Shared tracker, many producers:** three producers on one process-stats tracker must not triple-advance CPU deltas at start. (Task 3.) +8. **Bun/Deno blindness:** event-loop lag is omitted where the runtime cannot measure it, and `sample`-before-start never yields a zero. (Task 4.) +9. **`charging` edges:** level 1 while charging → full (3); 0.99 → charging (2); not charging → unplugged (1); unknown (0) never emitted. (Task 6.) + +--- + +## File Structure + +| File | Change | Responsibility | +|---|---|---| +| `packages/capture/src/trace-producer.ts` (+ `.test.ts`) | create | `TraceSink`, `TraceProducer`, `TraceProducerBase` (baseline-friendly emit + de-dup) | +| `packages/capture/src/system-traces-provider.ts` (+ test) | modify | producers container, sink, isolation, snapshot + refresh, legacy `sample` kept | +| `packages/capture/src/index.ts` | modify | export the new API | +| `packages/node/src/trackers/process-stats-tracker.ts` (+ test) | create | one timer; memory / system RAM / CPU / event-loop; shared by 5 producers | +| `packages/node/src/trackers/guarded-event-loop.ts` (+ test) | create | lazy event-loop + ELU sources with the Bun/Deno guards (moved out of `guarded-system-metrics.ts`) | +| `packages/node/src/trace-producers.ts` (+ test) | create | the five node producers + `createNodeSystemTraceProducers(deps)` | +| `packages/node/src/launch.ts` (+ test) | modify | `systemTraceProducers` option; default producers | +| `packages/bun/src/launch.ts`, `packages/deno/src/launch.ts` (+ tests) | modify | inject `createNodeSystemTraceProducers(...)` with the Deno flag | +| `packages/browser/src/trackers/battery-tracker.ts` (+ test) | create | `getBattery()` on activation, abort token, `levelchange`/`chargingchange` | +| `packages/browser/src/trace-producers.ts` (+ test) | create | `BatteryProducer`, `createBrowserSystemTraceProducers(env)` | +| `packages/browser/src/system-trace-values.ts` (+ test) | modify | `chargingToWire` | +| `packages/browser/src/system-metrics.ts` (+ test) | modify | remove battery/charging from the legacy sampler | +| `packages/browser/src/launch.ts`, `packages/webview/src/launch.ts` (+ tests) | modify | wire `producers` | +| `docs/PROGRESS.md` | modify | record | + +Untouched by design: `guarded-system-metrics.ts` and `system-metrics.ts` in `@bugsee/node` stay (legacy, exported) until a cleanup slice; their tests keep passing. + +## Cross-task interfaces (defined once) + +```ts +// @bugsee/capture (Task 1) +export interface TraceSink { emit(name: string, value: unknown, displayId?: number): void } +export interface TraceProducer { readonly name: string; start(sink: TraceSink): void; stop(): void } +export abstract class TraceProducerBase implements TraceProducer { + abstract readonly name: string; + start(sink: TraceSink): void; // idempotent; clears dedup state; calls onStart() + stop(): void; // idempotent; calls onStop(); drops the sink + protected abstract onStart(): void; + protected onStop(): void; + protected emit(name: string, value: unknown, displayId?: number): void; // always + protected emitIfChanged(name: string, value: unknown, displayId?: number): void; // only when != last +} +// SystemTracesProviderOptions gains: producers?: readonly TraceProducer[]; onError?: (e: unknown) => void; +// refreshMs?: number (default 10_000; Infinity disables); `sample` stays (legacy, now optional) + +// @bugsee/node (Tasks 3-4) +export interface ProcessStats { + memory: { rss: number; heapTotal: number; heapUsed: number; external: number; arrayBuffers: number }; + systemMemory: { total: number; free: number }; + cpu: { userDelta: number; systemDelta: number; processPct: number }; + eventLoop?: { meanMs: number; maxMs: number; p99Ms: number }; // absent when not measurable + utilization?: number; // absent when not measurable +} +export interface EventLoopSource { read(): { meanMs: number; maxMs: number; p99Ms: number } | undefined; start(): void; stop(): void } +export class ProcessStatsTracker extends InterceptorBase<{ stats: ProcessStats }> { current(): ProcessStats } +export function createNodeSystemTraceProducers(deps?: NodeTraceProducerDeps): TraceProducer[]; + +// @bugsee/browser (Tasks 6) +export interface BatteryState { level: number; charging: boolean } +export class BatteryTracker extends InterceptorBase<{ state: BatteryState }> { current(): BatteryState | undefined } +export function createBrowserSystemTraceProducers(env?: BrowserBatteryEnv): TraceProducer[]; +export function chargingToWire(b: { charging: boolean; level: number }): { state: 1 | 2 | 3 }; +``` + +--- + +### Task 1: Producer contract and base class (`@bugsee/capture`) + +**Files:** Create `packages/capture/src/trace-producer.ts`, `packages/capture/src/trace-producer.test.ts`; modify `packages/capture/src/index.ts`. + +- [ ] **Step 1: Write the failing tests** (`trace-producer.test.ts`): + +```ts +import { describe, expect, it } from 'vitest'; +import { type TraceSink, TraceProducerBase } from './trace-producer'; + +type Emitted = { name: string; value: unknown; displayId?: number }; +const mkSink = (): { sink: TraceSink; got: Emitted[] } => { + const got: Emitted[] = []; + return { got, sink: { emit: (name, value, displayId) => void got.push({ name, value, ...(displayId !== undefined ? { displayId } : {}) }) } }; +}; + +class Probe extends TraceProducerBase { + readonly name = 'probe'; + starts = 0; + stops = 0; + emitAll: (n: string, v: unknown, d?: number) => void = (n, v, d) => this.emit(n, v, d); + emitChanged: (n: string, v: unknown, d?: number) => void = (n, v, d) => this.emitIfChanged(n, v, d); + protected onStart(): void { this.starts += 1; } + protected override onStop(): void { this.stops += 1; } +} + +describe('TraceProducerBase', () => { + it('start() is idempotent and calls onStart once', () => { + const p = new Probe(); const { sink } = mkSink(); + p.start(sink); p.start(sink); + expect(p.starts).toBe(1); + }); + + it('stop() is idempotent, a no-op before start, and calls onStop once', () => { + const p = new Probe(); const { sink } = mkSink(); + p.stop(); + expect(p.stops).toBe(0); + p.start(sink); p.stop(); p.stop(); + expect(p.stops).toBe(1); + }); + + it('emit() always forwards; after stop() it is dropped (late callbacks cannot write)', () => { + const p = new Probe(); const { sink, got } = mkSink(); + p.start(sink); + p.emitAll('a', 1); p.emitAll('a', 1); + p.stop(); + p.emitAll('a', 2); + expect(got).toEqual([{ name: 'a', value: 1 }, { name: 'a', value: 1 }]); + }); + + it('emitIfChanged() drops equal consecutive values per name+display (primitives)', () => { + const p = new Probe(); const { sink, got } = mkSink(); + p.start(sink); + p.emitChanged('a', 1); p.emitChanged('a', 1); p.emitChanged('a', 2); p.emitChanged('a', 1); + expect(got.map((g) => g.value)).toEqual([1, 2, 1]); + }); + + it('emitIfChanged() compares objects structurally', () => { + const p = new Probe(); const { sink, got } = mkSink(); + p.start(sink); + p.emitChanged('c', { state: 2 }); p.emitChanged('c', { state: 2 }); p.emitChanged('c', { state: 3 }); + expect(got.map((g) => g.value)).toEqual([{ state: 2 }, { state: 3 }]); + }); + + it('emitIfChanged() tracks each name and displayId separately', () => { + const p = new Probe(); const { sink, got } = mkSink(); + p.start(sink); + p.emitChanged('a', 1); p.emitChanged('b', 1); p.emitChanged('a', 1, 0); p.emitChanged('a', 1, 0); + expect(got).toEqual([ + { name: 'a', value: 1 }, { name: 'b', value: 1 }, { name: 'a', value: 1, displayId: 0 }, + ]); + }); + + it('a restart clears the dedup state, so the baseline re-emits', () => { + const p = new Probe(); const { sink, got } = mkSink(); + p.start(sink); p.emitChanged('a', 1); p.stop(); + p.start(sink); p.emitChanged('a', 1); + expect(got).toHaveLength(2); + }); + + it('stop() still drops the sink when onStop throws', () => { + class Throwing extends Probe { protected override onStop(): void { throw new Error('boom'); } } + const p = new Throwing(); const { sink, got } = mkSink(); + p.start(sink); + expect(() => p.stop()).toThrow('boom'); + p.emitAll('a', 1); // dropped + expect(got).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `pnpm --filter @bugsee/capture exec vitest run src/trace-producer.test.ts` → FAIL (module missing). + +- [ ] **Step 3: Implement** (`trace-producer.ts`): + +```ts +// A TRACE PRODUCER (Android BugseeCaptureValueProducer / iOS BGSCaptureSystemTraceProducer parity): the unit +// that turns a tracker's aggregated state into named `traces.system` values. It SUBSCRIBES to one or more +// trackers in onStart() and unsubscribes in onStop(); trackers attach/release their OS resources by +// subscriber presence, so a producer that is never started costs nothing. The provider hands every +// producer a sink; the producer never talks to the aggregator itself. + +/** Where a producer sends its values (the provider stamps the time and keeps the last-value snapshot). */ +export interface TraceSink { + /** Record one trace sample. `displayId` scopes it to a display; absent = device-wide. */ + emit(name: string, value: unknown, displayId?: number): void; +} + +export interface TraceProducer { + /** Producer id, for diagnostics. */ + readonly name: string; + /** Subscribe to trackers and emit a baseline. Must be safe to call again after stop(). */ + start(sink: TraceSink): void; + /** Unsubscribe from everything start() attached. Idempotent; a no-op before start(). */ + stop(): void; +} + +const sameValue = (a: unknown, b: unknown): boolean => + Object.is(a, b) || + (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null && + JSON.stringify(a) === JSON.stringify(b)); + +const keyOf = (name: string, displayId: number | undefined): string => + displayId === undefined ? name : `${displayId}:${name}`; + +export abstract class TraceProducerBase implements TraceProducer { + abstract readonly name: string; + #sink: TraceSink | undefined; + readonly #last = new Map(); + + start(sink: TraceSink): void { + if (this.#sink !== undefined) return; + this.#sink = sink; + this.#last.clear(); + this.onStart(); + } + + stop(): void { + if (this.#sink === undefined) return; + try { + this.onStop(); + } finally { + this.#sink = undefined; // late tracker callbacks after stop() must not write + this.#last.clear(); + } + } + + /** Subscribe to trackers (and emit the baseline) here. */ + protected abstract onStart(): void; + /** Unsubscribe here. */ + protected onStop(): void {} + + /** Always emit (polled families: cpu / memory emit every tick, like Android). */ + protected emit(name: string, value: unknown, displayId?: number): void { + this.#sink?.emit(name, value, displayId); + if (this.#sink !== undefined) this.#last.set(keyOf(name, displayId), value); + } + + /** Emit only when the value differs from the last emitted for this name+display (iOS emitIfChangedName). */ + protected emitIfChanged(name: string, value: unknown, displayId?: number): void { + const key = keyOf(name, displayId); + if (this.#last.has(key) && sameValue(this.#last.get(key), value)) return; + this.emit(name, value, displayId); + } +} +``` + +Export from `index.ts`: `export { type TraceProducer, TraceProducerBase, type TraceSink } from './trace-producer';` + +- [ ] **Step 4: Run to verify pass** — same command + `pnpm --filter @bugsee/capture exec tsc --noEmit`. +- [ ] **Step 5: Mutator loop** — back up the file; mutate: drop the `#sink !== undefined` guard in `start`/`stop`; remove `#last.clear()` in start; make `sameValue` use `===` only (objects); key without displayId; `emit` writing `#last` before the sink check; swallow `onStop` throw; skip `finally`. Each must fail a test; restore. +- [ ] **Step 6: Commit** — `git add packages/capture/src/trace-producer.ts packages/capture/src/trace-producer.test.ts packages/capture/src/index.ts && git commit -m "feat(capture): TraceProducer contract and base (baseline-friendly emit, de-dup)"` + attribution. + +--- + +### Task 2: Provider becomes a producer container (`@bugsee/capture`) + +**Files:** Modify `packages/capture/src/system-traces-provider.ts` and its `.test.ts`; `index.ts` unchanged. + +**Interfaces:** Consumes `TraceProducer`/`TraceSink` (Task 1). Produces the extended `SystemTracesProviderOptions` and a provider that still accepts the legacy `sample`. + +Behaviour (each is a test below): `start()` → per producer `start(sink)` in try/catch (failure → `onError`, `stop()` that producer, continue); then legacy `sample()` initial emit (a throw there stops all started producers and rethrows — launch already routes a provider-start failure to `onError`); then one timer at `intervalMs` (default 1000) whose tick runs the legacy `sample()` and the refresh. `stop()` → timer cleared, every started producer stopped in try/catch, snapshot cleared, sink closed. The sink stamps `now()`, writes `traces.system` `{timestamp, name, value[, displayId]}`, and records `{value, timestamp}` per `displayId:name`. **Refresh:** on each tick, any held value with `now - timestamp >= refreshMs` is re-emitted with the current timestamp (Android/iOS re-emit the snapshot on a part switch; JS stores roll a time window with no part hook — `chunk-capture-store.ts` evicts by `maxRecordingTimeMs`, so without this a rarely-changing trace ages out of every report). + +- [ ] **Step 1: Write the failing tests** (append; `fakeScheduler`, `buildInit`, `mkStore`, `options`, `drainSystem` already exist in the file): + +```ts +import { TraceProducerBase, type TraceSink } from './trace-producer'; + +class Fixed extends TraceProducerBase { + readonly name: string; + constructor(name: string, private readonly values: Array<[string, unknown, number?]>, private readonly opts: { throwStart?: boolean; throwStop?: boolean } = {}) { super(); this.name = name; } + startedCount = 0; stoppedCount = 0; sinkRef: (() => void) | undefined; + protected onStart(): void { + this.startedCount += 1; + for (const [n, v, d] of this.values) this.emit(n, v, d); + if (this.opts.throwStart) throw new Error(`${this.name} start boom`); + } + protected override onStop(): void { + this.stoppedCount += 1; + if (this.opts.throwStop) throw new Error(`${this.name} stop boom`); + } + push(n: string, v: unknown): void { this.emit(n, v); } +} +const values = async (store: CaptureStore) => (await drainSystem(store))?.map((e) => e.data as { name: string; value: unknown; timestamp: number; displayId?: number }) ?? []; + +describe('createSystemTracesProvider — producers', () => { + it('starts every producer, routing their emits to traces.system with the provider timestamp', async () => { + const store = mkStore(); const fs = fakeScheduler(); + const a = new Fixed('a', [['x', 1]]); const b = new Fixed('b', [['y', 2, 0]]); + const p = createSystemTracesProvider({ producers: [a, b], scheduler: fs.scheduler, now: () => 1000 }); + p.init(buildInit(store)); p.start(options); + expect(await values(store)).toEqual([ + { timestamp: 1000, name: 'x', value: 1 }, + { timestamp: 1000, name: 'y', value: 2, displayId: 0 }, + ]); + }); + + it('isolates a producer whose start() throws: reports, stops it, the others still start', () => { + const fs = fakeScheduler(); const errors: unknown[] = []; + const bad = new Fixed('bad', [], { throwStart: true }); const good = new Fixed('good', [['x', 1]]); + const p = createSystemTracesProvider({ producers: [bad, good], scheduler: fs.scheduler, onError: (e) => errors.push(e) }); + p.init(buildInit(mkStore())); + expect(() => p.start(options)).not.toThrow(); + expect(good.startedCount).toBe(1); + expect(bad.stoppedCount).toBe(1); + expect((errors[0] as Error).message).toBe('bad start boom'); + }); + + it('isolates a producer whose stop() throws: the rest still stop, the timer is cleared', () => { + const fs = fakeScheduler(); const errors: unknown[] = []; + const bad = new Fixed('bad', [], { throwStop: true }); const good = new Fixed('good', []); + const p = createSystemTracesProvider({ producers: [bad, good], scheduler: fs.scheduler, onError: (e) => errors.push(e) }); + p.init(buildInit(mkStore())); p.start(options); + expect(() => p.stop()).not.toThrow(); + expect(good.stoppedCount).toBe(1); + expect(fs.cleared()).toBe(1); + expect(errors).toHaveLength(1); + }); + + it('never starts a producer when captureSystemTraces is off (coordinator gate)', () => { + const fs = fakeScheduler(); const a = new Fixed('a', [['x', 1]]); + const coordinator = createCaptureCoordinator(buildInit(mkStore())); + coordinator.addProvider(createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler })); + coordinator.start(createOptionsContainer({ [BugseeOption.CaptureSystemTraces]: false })); + expect(a.startedCount).toBe(0); + coordinator.stop(); + expect(a.stoppedCount).toBe(0); + }); + + it('drops a producer emit that arrives after stop() (a late tracker callback)', async () => { + const store = mkStore(); const fs = fakeScheduler(); + const a = new Fixed('a', []); + const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, now: () => 1 }); + p.init(buildInit(store)); p.start(options); p.stop(); + a.push('x', 1); + expect(await values(store)).toEqual([]); + }); + + it('restart: producers start again and the baseline re-emits', async () => { + const store = mkStore(); const fs = fakeScheduler(); + const a = new Fixed('a', [['x', 1]]); + const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, now: () => 1 }); + p.init(buildInit(store)); p.start(options); p.stop(); p.start(options); + expect(a.startedCount).toBe(2); + expect((await values(store)).map((v) => v.name)).toEqual(['x', 'x']); + }); + + it('REFRESH: re-emits a held value once it is older than refreshMs, and not before', async () => { + const store = mkStore(); const fs = fakeScheduler(); let t = 0; + const a = new Fixed('a', [['battery', 50]]); + const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, now: () => t, refreshMs: 10_000 }); + p.init(buildInit(store)); p.start(options); // t=0 baseline + t = 9_999; fs.tick(); // not yet + t = 10_000; fs.tick(); // refresh at 10 000 + t = 10_500; fs.tick(); // not again until 20 000 + expect((await values(store)).map((v) => [v.name, v.timestamp])).toEqual([['battery', 0], ['battery', 10_000]]); + }); + + it('refreshMs: Infinity disables the refresh', async () => { + const store = mkStore(); const fs = fakeScheduler(); let t = 0; + const a = new Fixed('a', [['x', 1]]); + const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, now: () => t, refreshMs: Number.POSITIVE_INFINITY }); + p.init(buildInit(store)); p.start(options); + t = 1e9; fs.tick(); + expect(await values(store)).toHaveLength(1); + }); + + it('a value that keeps changing is not refreshed (its latest timestamp is fresh)', async () => { + const store = mkStore(); const fs = fakeScheduler(); let t = 0; + const a = new Fixed('a', [['x', 0]]); + const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, now: () => t, refreshMs: 10_000 }); + p.init(buildInit(store)); p.start(options); + t = 9_000; a.push('x', 1); + t = 12_000; fs.tick(); // last write at 9 000 → age 3 000 < 10 000 + expect((await values(store)).map((v) => v.value)).toEqual([0, 1]); + }); + + it('the timer never lets an exception escape: a throwing legacy sample() goes to onError', () => { + const fs = fakeScheduler(); const errors: unknown[] = []; let n = 0; + const p = createSystemTracesProvider({ + sample: () => { n += 1; if (n > 1) throw new Error('tick boom'); return []; }, + scheduler: fs.scheduler, onError: (e) => errors.push(e), + }); + p.init(buildInit(mkStore())); p.start(options); + expect(() => fs.tick()).not.toThrow(); + expect((errors[0] as Error).message).toBe('tick boom'); + }); + + it('a throwing INITIAL legacy sample stops the started producers and rethrows (launch routes it to onError)', () => { + const fs = fakeScheduler(); const a = new Fixed('a', []); + const p = createSystemTracesProvider({ producers: [a], sample: () => { throw new Error('sample boom'); }, scheduler: fs.scheduler }); + p.init(buildInit(mkStore())); + expect(() => p.start(options)).toThrow('sample boom'); + expect(a.stoppedCount).toBe(1); + expect(fs.interval()).toBe(0); + }); + + it('legacy: `sample` alone still behaves exactly as before (initial snapshot + interval)', async () => { + const store = mkStore(); const fs = fakeScheduler(); + const p = createSystemTracesProvider({ sample: () => [{ name: 'm', value: 1 }], scheduler: fs.scheduler, now: () => 5 }); + p.init(buildInit(store)); p.start(options); fs.tick(); + expect(await values(store)).toHaveLength(2); + expect(fs.interval()).toBe(1000); + }); + + it('requires producers or sample (a provider with neither is a programming error)', () => { + expect(() => createSystemTracesProvider({} as never)).toThrow(/producers or sample/); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `pnpm --filter @bugsee/capture exec vitest run src/system-traces-provider.test.ts` → FAIL. + +- [ ] **Step 3: Implement.** Replace the class body in `system-traces-provider.ts`: + +```ts +import { type TraceProducer, type TraceSink } from './trace-producer'; + +export interface SystemTracesProviderOptions { + /** Trace producers (each subscribes to trackers; the provider owns start/stop + isolation). */ + producers?: readonly TraceProducer[]; + /** LEGACY polled seam: read every system trace on each tick. Kept so existing samplers/tests keep working. */ + sample?: () => readonly TraceSample[]; + intervalMs?: number; + scheduler?: Scheduler; + now?: () => number; + /** A value older than this is re-emitted so a rolling capture window never loses a rarely-changing trace. Default 10 000; Infinity disables. */ + refreshMs?: number; + /** Reports an isolated producer/tick failure. Must not throw. */ + onError?: (error: unknown) => void; +} + +interface Held { name: string; value: unknown; timestamp: number; displayId: number | undefined } + +class SystemTracesProvider extends CaptureProviderBase { + readonly name = 'traces.system'; + readonly controllingOption = BugseeOption.CaptureSystemTraces; + readonly #producers: readonly TraceProducer[]; + readonly #sample: (() => readonly TraceSample[]) | undefined; + readonly #intervalMs: number; + readonly #refreshMs: number; + readonly #scheduler: Scheduler; + readonly #now: () => number; + readonly #onError: (error: unknown) => void; + readonly #held = new Map(); + #started: TraceProducer[] = []; + #running = false; + #timer: unknown = null; + readonly #sink: TraceSink = { + emit: (name, value, displayId) => { + if (this.#running) this.#record(name, value, displayId, this.#now()); + }, + }; + + constructor(options: SystemTracesProviderOptions) { + super(); + if (options.producers === undefined && options.sample === undefined) { + throw new Error('createSystemTracesProvider needs producers or sample'); + } + this.#producers = options.producers ?? []; + this.#sample = options.sample; + this.#intervalMs = options.intervalMs ?? 1000; + this.#refreshMs = options.refreshMs ?? 10_000; + this.#scheduler = options.scheduler ?? defaultScheduler; + this.#now = options.now ?? (() => Date.now()); + this.#onError = options.onError ?? (() => {}); + } + + protected onStart(): void { + this.#held.clear(); + this.#running = true; + for (const producer of this.#producers) { + try { + producer.start(this.#sink); + this.#started.push(producer); + } catch (error) { + this.#onError(error); + this.#safeStop(producer); + } + } + try { + this.#emitLegacy(this.#now()); // initial snapshot, so a short-lived session still has current values + } catch (error) { + this.#stopProducers(); + this.#running = false; + throw error; + } + this.#timer = this.#scheduler.setInterval(() => this.#tick(), this.#intervalMs); + } + + protected override onStop(): void { + this.#scheduler.clearInterval(this.#timer); + this.#timer = null; + this.#stopProducers(); + this.#running = false; + this.#held.clear(); + } + + #tick(): void { + try { + const now = this.#now(); + this.#emitLegacy(now); + for (const held of this.#held.values()) { + if (now - held.timestamp >= this.#refreshMs) { + this.#record(held.name, held.value, held.displayId, now); + } + } + } catch (error) { + this.#onError(error); // a timer must never throw into the host + } + } + + #emitLegacy(timestamp: number): void { + if (this.#sample === undefined) return; + for (const { name, value } of this.#sample()) this.#record(name, value, undefined, timestamp); + } + + #record(name: string, value: unknown, displayId: number | undefined, timestamp: number): void { + this.capture('traces.system', timestamp, { + timestamp, name, value, ...(displayId !== undefined ? { displayId } : {}), + }); + this.#held.set(displayId === undefined ? name : `${displayId}:${name}`, { name, value, timestamp, displayId }); + } + + #stopProducers(): void { + for (const producer of this.#started.splice(0)) this.#safeStop(producer); + } + + #safeStop(producer: TraceProducer): void { + try { + producer.stop(); + } catch (error) { + this.#onError(error); + } + } +} +``` + +(Mutating `#held` while iterating in `#tick` via `#record` only overwrites existing keys — safe for `Map`; if a linter objects, iterate over `[...this.#held.values()]`.) Keep `createSystemTracesProvider(options)` as is. + +- [ ] **Step 4: Run to verify pass** — the whole file (the pre-existing tests are the legacy-compat check) + `tsc --noEmit`. +- [ ] **Step 5: Mutator loop** — back up; mutate: drop the per-producer try/catch in start / in stop; drop `#safeStop` on a failed start; `>=` → `>` in refresh; refresh uses the old timestamp; skip `#held.clear()` on restart; drop the `#running` guard in the sink; swallow the initial-sample throw; remove the tick try/catch; don't clear the timer on stop; refresh ignoring `displayId`. Each must fail; restore. +- [ ] **Step 6: Commit** — `feat(capture): traces provider is a producer container (isolation, last-value refresh), legacy sample kept`. + +--- + +### Task 3: `ProcessStatsTracker` (node) — one timer, shared state + +**Files:** Create `packages/node/src/trackers/process-stats-tracker.ts` and `.test.ts`. + +**Interfaces:** Consumes `InterceptorBase`, `Scheduler` from `@bugsee/core`. Produces `ProcessStats`, `EventLoopSource`, `ProcessStatsTracker` (see Cross-task interfaces). Reuses the arithmetic of `system-metrics.ts` exactly (same names/formulas) so Task 4's parity test can compare against the legacy sampler. + +- [ ] **Step 1: Write the failing tests** (`process-stats-tracker.test.ts`): + +```ts +import type { Scheduler } from '@bugsee/core'; +import { describe, expect, it } from 'vitest'; +import { type EventLoopSource, type ProcessStats, ProcessStatsTracker } from './process-stats-tracker'; + +function fakeScheduler() { + let cb: (() => void) | null = null; let cleared = 0; let sets = 0; + const scheduler: Scheduler = { setInterval: (c) => { cb = c; sets += 1; return 'h'; }, clearInterval: () => { cleared += 1; } }; + return { scheduler, tick: () => cb?.(), cleared: () => cleared, sets: () => sets }; +} +const mem = { rss: 1, heapTotal: 2, heapUsed: 3, external: 4, arrayBuffers: 5 }; +function mk(over: Partial[0]> = {}) { + let cpu = { user: 0, system: 0 }; let wall = 0; + const fs = fakeScheduler(); + const tracker = new ProcessStatsTracker({ + memoryUsage: () => mem, systemMemory: () => ({ total: 100, free: 40 }), + cpuUsage: () => cpu, cpuCount: () => 2, monotonicNowMs: () => wall, + scheduler: fs.scheduler, intervalMs: 1000, ...over, + }); + return { tracker, fs, advance: (c: { user: number; system: number }, w: number) => { cpu = c; wall = w; } }; +} + +describe('ProcessStatsTracker', () => { + it('attaches NOTHING at construction (no timer, no sources started)', () => { + const started: string[] = []; + const eventLoop: EventLoopSource = { read: () => undefined, start: () => void started.push('start'), stop: () => void started.push('stop') }; + const { fs } = mk({ eventLoop }); + expect(fs.sets()).toBe(0); + expect(started).toEqual([]); + }); + + it('the first subscriber activates it: one timer + event-loop source started; the last one leaves → released', () => { + const calls: string[] = []; + const eventLoop: EventLoopSource = { read: () => undefined, start: () => void calls.push('start'), stop: () => void calls.push('stop') }; + const { tracker, fs } = mk({ eventLoop }); + const off1 = tracker.on('stats', () => {}); const off2 = tracker.on('stats', () => {}); + expect(fs.sets()).toBe(1); + expect(calls).toEqual(['start']); + off1(); + expect(fs.cleared()).toBe(0); // still one subscriber + off2(); + expect(fs.cleared()).toBe(1); + expect(calls).toEqual(['start', 'stop']); + }); + + it('emits stats on each tick with cpu deltas over the wall window (normalized by cores)', () => { + const { tracker, fs, advance } = mk(); + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); + advance({ user: 400_000, system: 200_000 }, 1000); // 600 000 µs busy over 1 000 ms on 2 cores + fs.tick(); + expect(got).toHaveLength(1); + expect(got[0]?.memory).toEqual(mem); + expect(got[0]?.systemMemory).toEqual({ total: 100, free: 40 }); + expect(got[0]?.cpu).toEqual({ userDelta: 400_000, systemDelta: 200_000, processPct: 30 }); + }); + + it('current() returns the cached stats and does NOT advance the cpu baseline (shared by many producers)', () => { + const { tracker, advance } = mk(); + tracker.on('stats', () => {}); + const a = tracker.current(); advance({ user: 500_000, system: 0 }, 1000); + const b = tracker.current(); const c = tracker.current(); + expect(b).toBe(a); expect(c).toBe(a); // same object until the next poll + expect(a.cpu.userDelta).toBe(0); // baseline: no delta yet + }); + + it('a zero wall window yields processPct 0 (no divide by zero)', () => { + const { tracker, fs } = mk(); + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); + fs.tick(); // wall unchanged + expect(got[0]?.cpu.processPct).toBe(0); + }); + + it('includes eventLoop / utilization only when their sources answer (omitted, never zeroed)', () => { + const { tracker, fs } = mk({ + eventLoop: { read: () => ({ meanMs: 1, maxMs: 2, p99Ms: 3 }), start: () => {}, stop: () => {} }, + eventLoopUtilization: () => undefined, + }); + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); fs.tick(); + expect(got[0]?.eventLoop).toEqual({ meanMs: 1, maxMs: 2, p99Ms: 3 }); + expect('utilization' in (got[0] ?? {})).toBe(false); + }); + + it('restart: re-attaches and re-baselines (no stale cache)', () => { + const { tracker, fs, advance } = mk(); + const off = tracker.on('stats', () => {}); + advance({ user: 1, system: 1 }, 10); + off(); + tracker.on('stats', () => {}); + expect(fs.sets()).toBe(2); + expect(tracker.current().cpu.userDelta).toBe(0); + }); + + it('a throwing reader is reported via onError and does not break the timer', () => { + const errors: unknown[] = []; + let boom = false; + const { tracker, fs } = mk({ memoryUsage: () => { if (boom) throw new Error('read boom'); return mem; }, onError: (e) => errors.push(e) }); + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); + boom = true; + expect(() => fs.tick()).not.toThrow(); + expect(got).toHaveLength(0); + expect((errors[0] as Error).message).toBe('read boom'); + }); +}); +``` + +- [ ] **Step 2: Run to verify failure** — `pnpm --filter @bugsee/node exec vitest run src/trackers/process-stats-tracker.test.ts`. +- [ ] **Step 3: Implement** (`process-stats-tracker.ts`): + +```ts +import { cpus, freemem, totalmem } from 'node:os'; +import { performance } from 'node:perf_hooks'; +import { InterceptorBase, type Scheduler } from '@bugsee/core'; + +export interface EventLoopSource { + /** Mean/max/p99 lag (ms) since the previous read, or undefined when the runtime cannot measure it. */ + read(): { meanMs: number; maxMs: number; p99Ms: number } | undefined; + start(): void; + stop(): void; +} +export interface ProcessStats { /* as in Cross-task interfaces */ } + +export interface ProcessStatsDeps { + memoryUsage?: () => ProcessStats['memory']; + cpuUsage?: () => { user: number; system: number }; + systemMemory?: () => { total: number; free: number }; + cpuCount?: () => number; + monotonicNowMs?: () => number; + /** Lazily started on activation, stopped on deactivation. Default: none (lag omitted). */ + eventLoop?: EventLoopSource; + /** ELU delta 0..1 per call, or undefined when unavailable. Default: none. */ + eventLoopUtilization?: () => number | undefined; + scheduler?: Scheduler; + intervalMs?: number; + onError?: (error: unknown) => void; +} + +const defaultScheduler: Scheduler = { + setInterval: (cb, ms) => { + const h = setInterval(cb, ms); + (h as { unref?: () => void }).unref?.(); + return h; + }, + clearInterval: (h) => clearInterval(h as ReturnType), +}; + +export class ProcessStatsTracker extends InterceptorBase<{ stats: ProcessStats }> { + readonly name = 'node-process-stats'; + readonly #d: Required> & ProcessStatsDeps; + #timer: unknown = null; + #lastCpu = { user: 0, system: 0 }; + #lastWallMs = 0; + #cached: ProcessStats | undefined; + + constructor(deps: ProcessStatsDeps = {}) { + super(deps.onError); + this.#d = { + memoryUsage: () => process.memoryUsage(), + cpuUsage: () => process.cpuUsage(), + systemMemory: () => ({ total: totalmem(), free: freemem() }), + cpuCount: () => cpus().length, + monotonicNowMs: () => performance.now(), + scheduler: defaultScheduler, + intervalMs: 1000, + ...deps, + }; + } + + /** Latest stats (read once on activation, then replaced on every poll). Safe for many callers. */ + current(): ProcessStats { + this.#cached ??= this.#read(); + return this.#cached; + } + + protected onActivate(): void { + this.#lastCpu = this.#d.cpuUsage(); + this.#lastWallMs = this.#d.monotonicNowMs(); + this.#cached = undefined; + this.#d.eventLoop?.start(); + this.#timer = this.#d.scheduler.setInterval(() => this.#poll(), this.#d.intervalMs); + } + + protected override onDeactivate(): void { + this.#d.scheduler.clearInterval(this.#timer); + this.#timer = null; + this.#d.eventLoop?.stop(); + this.#cached = undefined; + } + + #poll(): void { + try { + this.#cached = this.#read(); + } catch (error) { + this.#d.onError?.(error); + return; + } + this.emit('stats', this.#cached); + } + + #read(): ProcessStats { + const memory = this.#d.memoryUsage(); + const systemMemory = this.#d.systemMemory(); + const cpu = this.#d.cpuUsage(); + const nowMs = this.#d.monotonicNowMs(); + const userDelta = cpu.user - this.#lastCpu.user; + const systemDelta = cpu.system - this.#lastCpu.system; + const wallMsDelta = nowMs - this.#lastWallMs; + this.#lastCpu = cpu; + this.#lastWallMs = nowMs; + const processPct = wallMsDelta > 0 ? ((userDelta + systemDelta) / (wallMsDelta * 1000) / this.#d.cpuCount()) * 100 : 0; + const eventLoop = this.#d.eventLoop?.read(); + const utilization = this.#d.eventLoopUtilization?.(); + return { + memory, systemMemory, cpu: { userDelta, systemDelta, processPct }, + ...(eventLoop !== undefined ? { eventLoop } : {}), + ...(utilization !== undefined ? { utilization } : {}), + }; + } +} +``` + +Note the first-call baseline in the "current() does not advance" test: `onActivate` reads the CPU baseline, then `current()` performs the first `#read()` with `userDelta` 0 only if `cpuUsage()` is unchanged between activation and the call — the test advances **after** the first `current()`, which is what it asserts. + +- [ ] **Step 4: Run to verify pass** + `tsc --noEmit` for `@bugsee/node`. +- [ ] **Step 5: Mutator loop** — mutate: start the timer in the constructor; never call `eventLoop.stop()`; `current()` re-reads each call; keep `#cached` across deactivate; drop `?? ` baseline reset in `onActivate`; divide by 1000 only; ignore `onError`; emit before caching; omit the `utilization` guard (always include). Restore each. +- [ ] **Step 6: Commit** — `feat(node): ProcessStatsTracker — one timer, shared state, lazy event-loop source`. + +--- + +### Task 4: Guarded event-loop sources + node producers + factory + +**Files:** Create `packages/node/src/trackers/guarded-event-loop.ts` (+ test), `packages/node/src/trace-producers.ts` (+ test); modify `packages/node/src/index.ts` (exports). + +**Interfaces:** Consumes `ProcessStatsTracker`, `EventLoopSource` (Task 3), `TraceProducerBase` (Task 1). Produces `createNodeSystemTraceProducers(deps?: NodeTraceProducerDeps): TraceProducer[]` where `NodeTraceProducerDeps extends ProcessStatsDeps { perfHooks?: PerfHooks; measuresEventLoopDelay?: boolean }`. + +- [ ] **Step 1: Write the failing tests.** + +`guarded-event-loop.test.ts` — move/adapt the guard logic and tests from `guarded-system-metrics.ts` (`guardedEventLoop`, `guardedElu`, `eluUnavailable` semantics): with a fake `perfHooks`: (a) construction calls neither `monitorEventLoopDelay` nor `enable`; (b) `start()` constructs + enables once, idempotent; `stop()` disables, `?.disable?.()` tolerated when absent; restart reuses the histogram; (c) `read()` before `start()` → `undefined`; after → `{meanMs, maxMs, p99Ms}` in ms and resets the histogram; (d) a throwing `monitorEventLoopDelay` / `enable` → `start()` does not throw and `read()` stays `undefined`; (e) `createGuardedElu(elu)` → `undefined` when idle and active are both 0 (Bun/Deno), the utilization delta otherwise, `undefined` when it throws. (Write them in full with the same `fakePerfHooks` helper shape as `guarded-system-metrics.test.ts`; `lagMs` conversion: `mean 2e6` ns → 2 ms.) + +`trace-producers.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { createNodeSystemTraceProducers } from './trace-producers'; +import { createNodeSystemMetricsSampler } from './system-metrics'; + +// Collect what a producer set emits for one tick, via a recording sink. +function runOnce(deps: Parameters[0]) { + const got = new Map(); + let tick: (() => void) | undefined; + const producers = createNodeSystemTraceProducers({ ...deps, scheduler: { setInterval: (cb) => ((tick = cb), 'h'), clearInterval: () => {} } }); + for (const p of producers) p.start({ emit: (n, v) => void got.set(n, v) }); + tick?.(); + return { got, producers, stopAll: () => producers.forEach((p) => p.stop()) }; +} +const baseDeps = (over = {}) => ({ + memoryUsage: () => ({ rss: 10, heapTotal: 20, heapUsed: 30, external: 40, arrayBuffers: 50 }), + systemMemory: () => ({ total: 1000, free: 400 }), + cpuUsage: (() => { let u = 0; return () => ({ user: (u += 100_000), system: (u += 100_000) }); })(), + cpuCount: () => 2, monotonicNowMs: (() => { let w = 0; return () => (w += 1000); })(), + ...over, +}); + +describe('createNodeSystemTraceProducers', () => { + it('emits the SAME trace names and values as the legacy sampler (parity)', () => { + const { got } = runOnce(baseDeps({ eventLoop: { read: () => ({ meanMs: 1, maxMs: 2, p99Ms: 3 }), start: () => {}, stop: () => {} }, eventLoopUtilization: () => 0.5 })); + expect([...got.keys()].sort()).toEqual([ + 'cpu_usage_process', 'cpu_usage_system', 'cpu_usage_user', 'event_loop_lag_max_ms', 'event_loop_lag_ms', + 'event_loop_lag_p99_ms', 'event_loop_utilization', 'process_memory_array_buffers', 'process_memory_external', + 'process_memory_heap_total', 'process_memory_heap_used', 'process_memory_rss', 'ram_system_free', 'ram_system_total', + ]); + expect(got.get('process_memory_rss')).toBe(10); + expect(got.get('ram_system_total')).toBe(1000); + expect(got.get('event_loop_lag_max_ms')).toBe(2); + expect(got.get('event_loop_utilization')).toBe(0.5); + }); + // … +}); +``` + +(Add the real parity check to this test: build the legacy sampler with the **same** injected readers, call it once, and assert the producers' tick values equal the legacy sampler's values name-for-name — the legacy sampler's first call and the tracker's first poll see the same reader sequence only if the readers are deterministic counters; use fixed readers returning constant deltas to make this exact.) Further tests: omitted — with no `eventLoop` source the three lag traces and `event_loop_utilization` are absent; `measuresEventLoopDelay: false` → no histogram is ever constructed (fake perfHooks counts `monitor`), lag absent, `event_loop_utilization` still present when ELU answers; **gate** — creating the producers constructs no histogram and starts no timer (`setInterval` spy not called) until `start`; after `stopAll()` the histogram is disabled and the interval cleared; **shared tracker** — three producers starting does not triple the CPU baseline (the first tick's `cpu_usage_user` delta equals one window, not three); a restart re-emits the baseline. + +- [ ] **Step 2: Run to verify failure.** +- [ ] **Step 3: Implement.** `guarded-event-loop.ts`: export `createGuardedEventLoopSource(monitor)`, `createGuardedElu(elu)`, `eluUnavailable` re-exported from `system-metrics` (do not duplicate), and `PerfHooks`/`realPerfHooks` — code per the previous design: + +```ts +export function createGuardedEventLoopSource(monitor: () => DelayHistogram): EventLoopSource { + let histogram: DelayHistogram | undefined; + let unusable = false; + return { + start() { + if (unusable) return; + try { histogram ??= monitor(); histogram.enable(); histogram.reset(); } + catch { unusable = true; histogram = undefined; } // a partial runtime must not abort SDK startup + }, + stop() { try { histogram?.disable?.(); } catch { /* best-effort */ } }, + read() { + if (histogram === undefined) return undefined; // not started → omit, never zero + try { + const out = { meanMs: lagMs(histogram.mean), maxMs: lagMs(histogram.max), p99Ms: lagMs(histogram.percentile(99)) }; + histogram.reset(); return out; + } catch { return undefined; } + }, + }; +} +``` + +`trace-producers.ts`: five `TraceProducerBase` subclasses, each constructed with the shared tracker: + +```ts +abstract class StatsProducer extends TraceProducerBase { + #off: (() => void) | undefined; + constructor(protected readonly tracker: ProcessStatsTracker) { super(); } + protected abstract publish(stats: ProcessStats): void; + protected onStart(): void { + this.#off = this.tracker.on('stats', (s) => this.publish(s)); // subscribing activates the tracker + this.publish(this.tracker.current()); // baseline + } + protected override onStop(): void { this.#off?.(); this.#off = undefined; } +} +class ProcessMemoryProducer extends StatsProducer { readonly name = 'process-memory'; protected publish(s: ProcessStats) { this.emit('process_memory_rss', s.memory.rss); /* heap_total, heap_used, external, array_buffers */ } } +class SystemMemoryProducer extends StatsProducer { /* ram_system_total, ram_system_free */ } +class CpuProducer extends StatsProducer { /* cpu_usage_user, cpu_usage_system, cpu_usage_process */ } +class EventLoopLagProducer extends StatsProducer { /* event_loop_lag_ms, _max_ms, _p99_ms — only when s.eventLoop !== undefined */ } +class EventLoopUtilizationProducer extends StatsProducer { /* event_loop_utilization — only when s.utilization !== undefined */ } + +export interface NodeTraceProducerDeps extends ProcessStatsDeps { perfHooks?: PerfHooks; measuresEventLoopDelay?: boolean } +export function createNodeSystemTraceProducers(deps: NodeTraceProducerDeps = {}): TraceProducer[] { + const { perfHooks, measuresEventLoopDelay = true, ...rest } = deps; + const perf = perfHooks ?? realPerfHooks; + const tracker = new ProcessStatsTracker({ + eventLoop: measuresEventLoopDelay ? createGuardedEventLoopSource(perf.monitorEventLoopDelay) : undefined, + eventLoopUtilization: createGuardedElu(perf.eventLoopUtilization), + ...rest, // an explicit caller-supplied reader wins over the guarded default + }); + return [new ProcessMemoryProducer(tracker), new SystemMemoryProducer(tracker), new CpuProducer(tracker), new EventLoopLagProducer(tracker), new EventLoopUtilizationProducer(tracker)]; +} +``` + +(`eventLoop: undefined` must be omitted rather than set when the flag is off — with `exactOptionalPropertyTypes` build the object with a conditional spread.) Export `createNodeSystemTraceProducers`, `NodeTraceProducerDeps`, `ProcessStatsTracker`, `type ProcessStats`, `type EventLoopSource` from `packages/node/src/index.ts`. The ELU baseline stays at tracker construction inside `createGuardedElu` (a value, not a resource). + +- [ ] **Step 4: Run to verify pass + typecheck.** +- [ ] **Step 5: Mutator loop** — mutate: construct the histogram at factory time (gate test); `measuresEventLoopDelay` ignored; emit lag when `eventLoop` undefined (zeros); producers not unsubscribing in `stop`; `current()` replaced by a direct read (triple-advance test); skip `start()` of the source; emit a utilization 0 when undefined. Restore each. +- [ ] **Step 6: Commit** — `feat(node): node trace producers over ProcessStatsTracker; guarded lazy event-loop source`. + +--- + +### Task 5: Wire node / Bun / Deno launch + +**Files:** Modify `packages/node/src/launch.ts` (option `systemTraceProducers?: readonly TraceProducer[]`, import line ~8, wiring ~865), `packages/bun/src/launch.ts`, `packages/deno/src/launch.ts`; tests `node/src/launch.test.ts`, `bun/src/launch.test.ts`, `deno/src/launch.test.ts`. + +- [ ] **Step 1: Write the failing tests.** In `node/src/launch.test.ts` (reusing `baseOptions`, `launchTracked`, `fakeScheduler`, `memStore`, `fixedClock`): + +```ts +it('the default producers attach nothing when captureSystemTraces is false, and release everything on stop()', async () => { + const fs = fakeScheduler(); const monitorCalls: string[] = []; + const perfHooks = { monitorEventLoopDelay: () => (monitorCalls.push('monitor'), { enable: () => void monitorCalls.push('enable'), disable: () => void monitorCalls.push('disable'), reset() {}, mean: 0, max: 0, percentile: () => 0 }), eventLoopUtilization: () => ({ utilization: 0, idle: 1, active: 0 }) }; + const off = launchTracked('tok', baseOptions({ captureStore: memStore(), clock: fixedClock, scheduler: fs.scheduler, captureSystemTraces: false, systemTraceProducers: createNodeSystemTraceProducers({ perfHooks, scheduler: fs.scheduler }) })); + expect(monitorCalls).toEqual([]); + await off.stop(); + expect(monitorCalls).toEqual([]); + const on = launchTracked('tok', baseOptions({ captureStore: memStore(), clock: fixedClock, scheduler: fs.scheduler, systemTraceProducers: createNodeSystemTraceProducers({ perfHooks, scheduler: fs.scheduler }) })); + expect(monitorCalls).toEqual(['monitor', 'enable']); + await on.stop(); + expect(monitorCalls.at(-1)).toBe('disable'); +}); + +it('a legacy systemMetricsSampler still wins when supplied (back-compat for existing injectors)', () => { + const client = launchTracked('tok', baseOptions({ captureStore: memStore(), systemMetricsSampler: () => [{ name: 'x', value: 1 }] })); + expect(client.isLaunched()).toBe(true); +}); + +it('routes a throwing producer to onError and still launches', () => { + const onError = vi.fn(); + const bad: TraceProducer = { name: 'bad', start: () => { throw new Error('p boom'); }, stop: () => {} }; + const client = launchTracked('tok', baseOptions({ captureStore: memStore(), onError, systemTraceProducers: [bad] })); + expect(client.isLaunched()).toBe(true); + expect((onError.mock.calls[0]?.[0] as Error).message).toBe('p boom'); +}); +``` + +In `bun/src/launch.test.ts` and `deno/src/launch.test.ts` (both `vi.mock('@bugsee/node', …)` with a pass-through spy — change the spied function to `createNodeSystemTraceProducers`): Deno pins `toHaveBeenCalledWith(expect.objectContaining({ measuresEventLoopDelay: false }))`; Bun pins it was called with no `measuresEventLoopDelay: false`. Both also assert the injected `systemTraceProducers` is a non-empty array of objects with `start`/`stop`. + +- [ ] **Step 2: Run to verify failure.** +- [ ] **Step 3: Implement.** `node/src/launch.ts`: + +```ts +/** Trace producers (each subscribes to trackers; the provider starts/stops them). Default: the node set. */ +systemTraceProducers?: readonly TraceProducer[]; +``` + +```ts +client.addCaptureProvider( + createSystemTracesProvider({ + ...(options.systemMetricsSampler !== undefined + ? { sample: options.systemMetricsSampler } // legacy injector wins (tests) + : { producers: options.systemTraceProducers ?? createNodeSystemTraceProducers() }), + refreshMs: Math.min(10_000, Math.max(1_000, (maxRecordingTime * 1000) / 4)), + ...(options.onError !== undefined ? { onError: options.onError } : {}), + ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), + }), +); +``` + +(`maxRecordingTime` is the seconds value already in scope at `launch.ts:657`; confirm the variable is visible at line ~865, else compute `refreshMs` beside it.) `bun/src/launch.ts`: replace `systemMetricsSampler: createGuardedSystemMetricsSampler()` with `systemTraceProducers: createNodeSystemTraceProducers()`; `deno/src/launch.ts`: `systemTraceProducers: createNodeSystemTraceProducers({ measuresEventLoopDelay: false })`, keeping the existing explanatory comment about Deno's histogram. Both stay **before** `...options` (same position as today). + +- [ ] **Step 4: Run to verify pass** — `pnpm --filter @bugsee/node exec tsc --noEmit && … vitest run` for node, bun, deno. +- [ ] **Step 5: Mutator loop** — mutate: provider always given `sample` (producers ignored); `refreshMs` omitted; Deno flag dropped; `onError` not forwarded. Restore. +- [ ] **Step 6: Real-runtime check** — `pnpm test:e2e`: the uploaded bundle has `event_loop_lag_*` on node and bun, none on deno; `ram_*`/`process_memory_*`/`cpu_usage_*` present on all three. This is the regression the whole design guards. +- [ ] **Step 7: Commit** — `feat(node,bun,deno): launch wires trace producers; Deno opts out of event-loop lag`. + +--- + +### Task 6: Browser — `BatteryTracker`, `BatteryProducer`, the `charging` fix + +**Files:** Create `packages/browser/src/trackers/battery-tracker.ts` (+ test), `packages/browser/src/trace-producers.ts` (+ test); modify `system-trace-values.ts` (+ test), `system-metrics.ts` (+ test), `browser/src/launch.ts`, `webview/src/launch.ts` (+ tests), `browser/src/index.ts`. + +- [ ] **Step 1: Write the failing tests.** + +`system-trace-values.test.ts` — `chargingToWire`: not charging → `{state: 1}`; charging at 0.5 and 0.99 → `{state: 2}`; charging at 1 → `{state: 3}`; never a `source` key, never `0`. + +`battery-tracker.test.ts`: + +```ts +function mkManager(level = 0.5, charging = false) { + const listeners = new Map void>>(); + return { + level, charging, + addEventListener: (t: string, l: () => void) => { (listeners.get(t) ?? listeners.set(t, new Set()).get(t)!).add(l); }, + removeEventListener: (t: string, l: () => void) => void listeners.get(t)?.delete(l), + fire: (t: string) => listeners.get(t)?.forEach((l) => l()), + count: () => [...listeners.values()].reduce((n, s) => n + s.size, 0), + }; +} +``` + +Cases: construction never calls `getBattery`; first subscriber calls it once (a second subscriber does not); resolve → listeners attached for `levelchange`+`chargingchange` and a `state` event emitted with the current `{level, charging}`; `fire('levelchange')` emits the new state; last unsubscribe removes both listeners; **stop before resolve → the late resolve attaches nothing and emits nothing**; **stop → start → old promise resolves late → ignored, the new one applies**; a rejecting or synchronously-throwing `getBattery` → no throw, no event; a manager without `addEventListener` (older shape) still emits the one baseline; `current()` is `undefined` before resolve and the latest state after, and `undefined` again after deactivation. + +`trace-producers.test.ts`: `BatteryProducer` emits `battery` (`Math.round(level*100)`) and `charging` (`chargingToWire`) as the baseline once the manager resolves, then only on change (de-dup: a `levelchange` with an unchanged rounded level emits nothing for `battery`); `createBrowserSystemTraceProducers({ getBattery })` constructs nothing at call time (no `getBattery` call); started via a provider with `captureSystemTraces:false` → `getBattery` not called. + +`system-metrics.test.ts`: delete the battery/charging expectations (they move to the producer); add one test that the legacy sampler no longer calls `getBattery` at all. + +- [ ] **Step 2: Run to verify failure** (`pnpm --filter @bugsee/browser exec vitest run`). +- [ ] **Step 3: Implement.** + +```ts +// battery-tracker.ts +export interface BatteryManagerLike { + level: number; charging: boolean; + addEventListener?(type: string, listener: () => void): void; + removeEventListener?(type: string, listener: () => void): void; +} +export interface BatteryState { level: number; charging: boolean } +export interface BatteryEnv { getBattery?: () => Promise } + +export class BatteryTracker extends InterceptorBase<{ state: BatteryState }> { + readonly name = 'browser-battery'; + readonly #env: BatteryEnv; + #generation = 0; + #manager: BatteryManagerLike | undefined; + #state: BatteryState | undefined; + readonly #onChange = (): void => this.#publish(); + + constructor(env: BatteryEnv, onError?: (e: unknown) => void) { super(onError); this.#env = env; } + + current(): BatteryState | undefined { return this.#state; } + + protected onActivate(): void { + const mine = ++this.#generation; + try { + this.#env.getBattery?.().then( + (manager) => { + if (this.#generation !== mine) return; // stopped (or restarted) while in flight + this.#manager = manager; + manager.addEventListener?.('levelchange', this.#onChange); + manager.addEventListener?.('chargingchange', this.#onChange); + this.#publish(); + }, + () => {}, // unsupported / denied → no battery traces + ); + } catch { /* a synchronous SecurityError just means no battery traces */ } + } + + protected override onDeactivate(): void { + this.#generation += 1; // invalidate any in-flight fetch + this.#manager?.removeEventListener?.('levelchange', this.#onChange); + this.#manager?.removeEventListener?.('chargingchange', this.#onChange); + this.#manager = undefined; + this.#state = undefined; + } + + #publish(): void { + if (this.#manager === undefined) return; + this.#state = { level: this.#manager.level, charging: this.#manager.charging }; + this.emit('state', this.#state); + } +} +``` + +`chargingToWire` in `system-trace-values.ts`: + +```ts +/** The viewer's `charging` table is keyed by the Android/iOS ints (0 unknown, 1 unplugged, 2 charging, 3 full) and + * `processCharging` takes an int or `{state}`. A boolean never matched (false is dropped by a falsy guard; true looks up + * states["true"]), so the old trace rendered nothing. `source` is omitted: the Battery API cannot say AC/USB/wireless. */ +export function chargingToWire(b: { charging: boolean; level: number }): { state: 1 | 2 | 3 } { + if (!b.charging) return { state: 1 }; + return { state: b.level >= 1 ? 3 : 2 }; +} +``` + +`trace-producers.ts`: + +```ts +class BatteryProducer extends TraceProducerBase { + readonly name = 'battery'; + #off: (() => void) | undefined; + constructor(private readonly tracker: BatteryTracker) { super(); } + protected onStart(): void { + this.#off = this.tracker.on('state', (s) => this.#publish(s)); + const cur = this.tracker.current(); + if (cur !== undefined) this.#publish(cur); + } + protected override onStop(): void { this.#off?.(); this.#off = undefined; } + #publish(s: BatteryState): void { + this.emitIfChanged('battery', Math.round(s.level * 100)); // 0-1 → 0-100 (Android parity) + this.emitIfChanged('charging', chargingToWire(s)); + } +} +export function createBrowserSystemTraceProducers(env: BatteryEnv = realBatteryEnv()): TraceProducer[] { + return [new BatteryProducer(new BatteryTracker(env))]; +} +``` + +(`realBatteryEnv()` reads `globalThis.navigator` lazily and binds `getBattery` — `{ getBattery: () => nav.getBattery() }` when present, else `{}`.) In `system-metrics.ts` delete the `getBattery` fetch, the `battery` variable, and the two battery samples; update the header comment (battery is now a tracker-backed producer). `browser/src/launch.ts` (~797) and `webview/src/launch.ts` (~672): `createSystemTracesProvider({ sample: options.systemMetricsSampler ?? createBrowserSystemTracesSampler(), producers: createBrowserSystemTraceProducers(), refreshMs: …, onError })`; widen nothing else (the public `systemMetricsSampler` option stays a function). Export `createBrowserSystemTraceProducers` and `chargingToWire` from `browser/src/index.ts`. + +- [ ] **Step 4: Run to verify pass + typecheck** — `pnpm --filter @bugsee/browser exec tsc --noEmit && … vitest run`, same for `@bugsee/webview`. Also `grep -rn "charging" packages/*/src e2e` and fix any other assertion on the boolean. +- [ ] **Step 5: Mutator loop** — mutate: `getBattery` in the constructor; drop `generation !== mine`; drop the generation bump in `onDeactivate`; skip removing listeners; `>= 1` → `> 1`; `>= 0.99`; swap states 1/2; add `source`; emit instead of `emitIfChanged`; baseline skipped when `current()` is set; drop the sync try/catch. Restore each. +- [ ] **Step 6: Commit** — `feat(browser,webview): BatteryTracker + BatteryProducer (event-driven, abortable); charging is { state }`. + +--- + +### Task 7: Gates, record, review, PR + +- [ ] **Step 1:** `pnpm ci:local` green (lint → typecheck → cycles → per-package coverage → e2e). Fix coverage by adding tests, never by lowering a threshold. `pnpm check:cycles` matters here: trackers live in platform packages and import only `@bugsee/core` / `@bugsee/capture`. +- [ ] **Step 2:** Add a paragraph to `docs/PROGRESS.md` (traces section): "ST0′: trace producers + trackers; provider is a producer container with last-value refresh; node `ProcessStatsTracker` (+ guarded lazy event-loop source) and browser `BatteryTracker` are the first chains; legacy `sample` remains for the rest; `charging` is `{state}`." Commit `docs: record ST0′`. +- [ ] **Step 3:** Convergent multi-agent review (`docs/implementation-standards.md` §7) over the branch diff, one agent per concern: (a) provider container + refresh correctness, (b) tracker lifecycle and subscriber-presence activation/restart, (c) Bun/Deno honesty and gate wiring, (d) browser battery abort + `charging`, (e) tests (outcomes asserted, mutations die). Agents are read-only and cite `file:line`. Triage, fix real findings test-first, repeat with a **fresh** review until a round yields zero new real findings. +- [ ] **Step 4:** `gh pr create --draft --base main --title "feat(capture,node,browser): trace producers over shared trackers (ST0′) + charging fix"` — body: summary of Tasks 1–6, link to the design doc (PR #53) incl. §14, test evidence (`pnpm ci:local`, e2e on node/bun/deno), the Review Focus list with the test pinning each item. + +--- + +## Follow-on slices (not in this plan) + +| Slice | Content | +|---|---| +| ST0′-B | Browser trackers for the rest of the legacy sampler: memory (`performance.memory`), connection (`online`/`offline` + `connection.change`, `onLine`-first mapper kept), orientation, device memory — then delete the legacy browser sampler | +| ST0′-C | Shared `PageLifecycleTracker` (visibility / `pagehide` / `freeze` / `resume`) bound to the injected window+document, subscribed by BOTH the system-events source and the traces producers (`app_state`, later `fps`/`main_thread_lag_ms` gating) — removes the duplicated `visibilitychange` wiring | +| ST0′-D | Delete the legacy `createNodeSystemMetricsSampler` / `createGuardedSystemMetricsSampler` / `sample` option once nothing injects them | +| ST1+ | New traces from the design doc as producers on these trackers (V8 heap, GC observer, disk, `process_memory_limit`, …) with their `measures*` flags on `NodeTraceProducerDeps` | +| ST6 | Electron trackers (`app`, `powerMonitor`, `screen`) subscribed by Electron producers and the events source — no wrapping of the node sampler | + +## Self-Review + +**Spec coverage:** structure (§14) → Tasks 1–6; gate-by-subscriber-presence (§5 lifecycle seam, round-2–9 findings) → Tasks 3, 4, 5, 6 and Review Focus 1/4/5/6; Bun/Deno honesty flags on tracker/factory deps → Tasks 4–5; `charging` (§13.4) → Task 6; rolling-window parity (mobile snapshot-on-part-switch) → Task 2 refresh. **Out of this plan by design:** the remaining browser/Electron/page-lifecycle migrations (follow-on table), new traces (ST1+), viewer/specs PRs. + +**Placeholder scan:** two spots are explicitly marked "replace with…" inside tests (the legacy-parity helper in Task 4 and the `guarded-event-loop` test list, which is "port the existing guarded tests"); both name the exact source to port from and the assertion required. No TBD/TODO. + +**Type consistency:** `TraceSink.emit(name, value, displayId?)`, `TraceProducer{name,start,stop}`, `TraceProducerBase.emit/emitIfChanged`, `ProcessStats`, `EventLoopSource{read,start,stop}`, `ProcessStatsTracker.current()`, `BatteryTracker.current()`, `chargingToWire`, `createNodeSystemTraceProducers`, `createBrowserSystemTraceProducers` are used with identical names in every task. + +**Risks to watch:** (1) `InterceptorBase`'s `emit`/`onActiveChange` come from `MultiKeyEmitterBase` — confirm `emit` is public/protected as used (`this.emit(...)` is how the existing sources call it); (2) subscribing a tracker listener from a producer inside the provider's `onStart` activates the tracker synchronously — the baseline `current()` call right after relies on that; (3) `Map` mutation while iterating in `#tick` (see note in Task 2); (4) all runtime facts are macOS-measured — Task 5 Step 6 (real-runtime e2e) is the confirmation on node/bun/deno. diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index c73e9937..188cd8a8 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -85,6 +85,8 @@ export { export { parseStack } from './stack'; export { type BrowserSystemEventsEnv, createBrowserSystemEventsSource } from './system-events'; export { type BrowserTracesEnv, createBrowserSystemTracesSampler } from './system-metrics'; +export { chargingToWire } from './system-trace-values'; +export { createBrowserSystemTraceProducers } from './trace-producers'; export { createUiBreadcrumbProvider, createUiBreadcrumbSource, diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index 18d69680..aa99a61a 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -1014,6 +1014,105 @@ describe('launch', () => { expect(await drain(store, 'traces.system')).toBeUndefined(); }); + const batteryManager = () => ({ + level: 0.5, + charging: true, + addEventListener() {}, + removeEventListener() {}, + }); + + it('drives battery/charging through the real provider: getBattery is called and traces are stored', async () => { + const getBattery = vi.fn(() => Promise.resolve(batteryManager())); + vi.stubGlobal('navigator', { getBattery }); + const store = memStore(); + await launchTracked('tok', baseOptions({ captureStore: store })); + await new Promise((r) => setTimeout(r, 0)); + expect(getBattery).toHaveBeenCalledTimes(1); + const traces = (await drain(store, 'traces.system'))?.map( + (e) => e.data as { name: string; value: unknown }, + ); + expect(traces).toContainEqual(expect.objectContaining({ name: 'battery', value: 50 })); + expect(traces).toContainEqual( + expect.objectContaining({ name: 'charging', value: { state: 2 } }), + ); + }); + + it('a battery manager that throws after getBattery resolves reaches options.onError', async () => { + const getBattery = vi.fn(() => + Promise.resolve({ + ...batteryManager(), + addEventListener() { + throw new Error('battery add refused'); + }, + }), + ); + vi.stubGlobal('navigator', { getBattery }); + const onError = vi.fn(); + await launchTracked('tok', baseOptions({ captureStore: memStore(), onError })); + await new Promise((r) => setTimeout(r, 0)); + expect(onError.mock.calls.map((c) => (c[0] as Error).message)).toContain('battery add refused'); + }); + + it.each([ + { maxRecordingTime: 20, refreshMs: 5000 }, // 20s/4 = 5s + { maxRecordingTime: 8, refreshMs: 2000 }, // 8s/4 = 2s, exactly the floor + { maxRecordingTime: 2, refreshMs: 2000 }, // 0.5s clamped up to the 2s floor (2 x the 1s interval) + { maxRecordingTime: 600, refreshMs: 10_000 }, // 150s clamped down to 10s + ])('refreshes the held battery trace after $refreshMs ms (recording window $maxRecordingTime s)', async ({ + maxRecordingTime, + refreshMs, + }) => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + vi.setSystemTime(0); + vi.stubGlobal('navigator', { getBattery: () => Promise.resolve(batteryManager()) }); + const ticks: Array<{ cb: () => void; ms: number }> = []; + const store = memStore(); + await launchTracked( + 'tok', + baseOptions({ + captureStore: store, + maxRecordingTime, + scheduler: { + setInterval: (cb, ms) => { + ticks.push({ cb, ms }); + return `h${ticks.length}`; + }, + clearInterval: () => {}, + }, + }), + ); + await new Promise((r) => setTimeout(r, 0)); // getBattery resolves: the held baseline at t=0 + const battery = async () => + ((await drain(store, 'traces.system')) ?? []).filter( + (e) => (e.data as { name: string }).name === 'battery', + ).length; + const tick = () => { + for (const t of ticks.filter((x) => x.ms === 1000)) t.cb(); + }; + expect(await battery()).toBe(1); + vi.setSystemTime(refreshMs - 1); + tick(); + expect(await battery()).toBe(1); + vi.setSystemTime(refreshMs); + tick(); + expect(await battery()).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it('never calls getBattery when captureSystemTraces is false', async () => { + const getBattery = vi.fn(() => Promise.resolve(batteryManager())); + vi.stubGlobal('navigator', { getBattery }); + await launchTracked( + 'tok', + baseOptions({ captureStore: memStore(), captureSystemTraces: false }), + ); + await new Promise((r) => setTimeout(r, 0)); + expect(getBattery).not.toHaveBeenCalled(); + }); + it('wires the network capture provider (captureNetwork on): a fetch yields a network entry', async () => { const slot = globalThis as unknown as { fetch?: (i: unknown, init?: unknown) => Promise; diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 17dcd9aa..fabc970a 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -14,6 +14,7 @@ import { createSystemEventsProvider, createSystemTracesProvider, createVideoAuxProvider, + deriveRefreshMs, installNetworkCapture, type NetworkCapture, type TraceSample, @@ -92,6 +93,7 @@ import { import { parseStack } from './stack'; import { createBrowserSystemEventsSource } from './system-events'; import { createBrowserSystemTracesSampler } from './system-metrics'; +import { createBrowserSystemTraceProducers } from './trace-producers'; import { createUiBreadcrumbProvider, createUiBreadcrumbSource } from './ui-breadcrumb-source'; import { createUserIdentifierStore } from './user-identifier-store'; import { createBrowserViewportSource } from './viewport-source'; @@ -796,6 +798,9 @@ async function buildLaunch( client.addCaptureProvider( createSystemTracesProvider({ sample: options.systemMetricsSampler ?? createBrowserSystemTracesSampler(), + producers: createBrowserSystemTraceProducers(undefined, options.onError), + refreshMs: deriveRefreshMs(resolved.options.get(BugseeOption.Duration, 60) * 1000), + ...(options.onError !== undefined ? { onError: options.onError } : {}), ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), }), ); diff --git a/packages/browser/src/system-metrics.test.ts b/packages/browser/src/system-metrics.test.ts index c5d644ee..25da888c 100644 --- a/packages/browser/src/system-metrics.test.ts +++ b/packages/browser/src/system-metrics.test.ts @@ -111,47 +111,14 @@ describe('createBrowserSystemTracesSampler', () => { ]); }); - it('samples the cached battery as battery (rounded 0-100) + charging once getBattery resolves', async () => { + it('no longer touches getBattery or emits battery/charging (they are a tracker-backed producer now)', async () => { + const getBattery = vi.fn(() => Promise.resolve({ level: 0.5, charging: true })); const sampler = createBrowserSystemTracesSampler({ - navigator: { getBattery: () => Promise.resolve({ level: 0.426, charging: true }) }, - }); - // Before getBattery resolves: only the connection trace a `navigator` always yields. - expect(sampler()).toEqual([{ name: 'connection', value: { type: 'unknown' } }]); - await tick(); - expect(sampler().filter((t) => t.name !== 'connection')).toEqual([ - { name: 'battery', value: 43 }, // 0.426 → 42.6 → round → 43 (pins Math.round) - { name: 'charging', value: true }, - ]); - }); - - it('reads the cached battery manager LIVE — later samples reflect charge changes', async () => { - const manager = { level: 0.5, charging: false }; - const sampler = createBrowserSystemTracesSampler({ - navigator: { getBattery: () => Promise.resolve(manager) }, - }); - await tick(); - // A `navigator` now always yields a connection trace (onLine alone is worth reporting), so the - // battery assertions filter to what they are about. - const batteryOf = () => sampler().filter((t) => t.name !== 'connection'); - expect(batteryOf()).toEqual([ - { name: 'battery', value: 50 }, - { name: 'charging', value: false }, - ]); - manager.level = 0.8; // the real BatteryManager mutates its properties in place - manager.charging = true; - expect(batteryOf()).toEqual([ - { name: 'battery', value: 80 }, // re-read live (not a one-time snapshot) - { name: 'charging', value: true }, - ]); - }); - - it('omits battery when getBattery rejects (unsupported / denied)', async () => { - const sampler = createBrowserSystemTracesSampler({ - navigator: { getBattery: () => Promise.reject(new Error('denied')) }, - }); + navigator: { getBattery }, + } as unknown as BrowserTracesEnv); await tick(); - // Only the connection trace, which a `navigator` always produces — no battery/charging. - expect(sampler()).toEqual([{ name: 'connection', value: { type: 'unknown' } }]); + expect(getBattery).not.toHaveBeenCalled(); + expect(sampler().map((t) => t.name)).toEqual(['connection']); }); it('yields nothing when no context API is available (empty env)', () => { @@ -163,7 +130,6 @@ describe('createBrowserSystemTracesSampler', () => { performance: { memory }, navigator: { connection: { effectiveType: '3g' }, - getBattery: () => Promise.resolve({ level: 1, charging: false }), }, screen: { orientation: { type: 'portrait-primary', angle: 0 } }, }); @@ -174,8 +140,6 @@ describe('createBrowserSystemTracesSampler', () => { 'ram_js_heap_used', 'connection', 'orientation', - 'battery', - 'charging', ]); }); diff --git a/packages/browser/src/system-metrics.ts b/packages/browser/src/system-metrics.ts index c5831af2..6f19415e 100644 --- a/packages/browser/src/system-metrics.ts +++ b/packages/browser/src/system-metrics.ts @@ -11,10 +11,8 @@ import { connectionTypeToWire, orientationToWire } from './system-trace-values'; // The last two carry ANDROID's vocabulary, not the browser's — see ./system-trace-values.ts for why // and how. `traces.system` is a cross-platform stream and the viewer renders it from fixed tables, so // a browser-shaped value is a value nothing can render. -// navigator.getBattery() → battery (0-100) + charging (Android `power_status`) -// getBattery() is async, so the BatteryManager is fetched once on construction and cached; its -// `level`/`charging` update live, so the sampler reads the cached manager each interval (omitted until it -// resolves / where unsupported). +// Battery (battery + charging) is NOT sampled here: it is event-driven, so it is a tracker-backed producer +// (./trace-producers.ts over ./trackers/battery-tracker.ts) that calls getBattery() only while capturing. interface BrowserMemory { usedJSHeapSize: number; @@ -31,10 +29,6 @@ interface NetworkInformation { rtt?: number; saveData?: boolean; } -interface BatteryManager { - level: number; - charging: boolean; -} interface ScreenOrientationLike { type: string; angle: number; @@ -45,7 +39,6 @@ export interface BrowserTracesEnv { performance?: { memory?: BrowserMemory }; navigator?: { connection?: NetworkInformation; - getBattery?: () => Promise; /** navigator.onLine — the one connectivity fact every browser reports. */ onLine?: boolean; /** @@ -62,7 +55,6 @@ const realEnv = (): BrowserTracesEnv => { performance?: { memory?: BrowserMemory }; navigator?: { connection?: NetworkInformation; - getBattery?: () => Promise; onLine?: boolean; deviceMemory?: number; }; @@ -90,16 +82,6 @@ const realEnv = (): BrowserTracesEnv => { export function createBrowserSystemTracesSampler( env: BrowserTracesEnv = realEnv(), ): () => TraceSample[] { - // Fetch the BatteryManager once; cache it. Its level/charging update live, so the sampler reads the - // cached manager each interval. Omitted until it resolves (or where getBattery is unsupported/denied). - let battery: BatteryManager | undefined; - env.navigator?.getBattery?.().then( - (manager) => { - battery = manager; - }, - () => {}, // unsupported / permission denied → no battery traces - ); - return () => { const samples: TraceSample[] = []; const memory = env.performance?.memory; @@ -153,12 +135,6 @@ export function createBrowserSystemTracesSampler( value: orientationToWire(orientation.type, orientation.angle), }); } - if (battery !== undefined) { - samples.push( - { name: 'battery', value: Math.round(battery.level * 100) }, // 0-1 → 0-100 (Android parity) - { name: 'charging', value: battery.charging }, - ); - } return samples; }; } diff --git a/packages/browser/src/system-trace-values.test.ts b/packages/browser/src/system-trace-values.test.ts index 63eae71d..82720030 100644 --- a/packages/browser/src/system-trace-values.test.ts +++ b/packages/browser/src/system-trace-values.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { ANDROID_ORIENTATION, + chargingToWire, connectionTypeToWire, orientationToWire, } from './system-trace-values'; @@ -164,3 +165,26 @@ describe('connectionTypeToWire', () => { } }); }); + +describe('chargingToWire', () => { + it('not charging is state 1 (unplugged), whatever the level', () => { + expect(chargingToWire({ charging: false, level: 0.5 })).toEqual({ state: 1 }); + expect(chargingToWire({ charging: false, level: 1 })).toEqual({ state: 1 }); + }); + it('charging below full is state 2, including 0.99', () => { + expect(chargingToWire({ charging: true, level: 0.5 })).toEqual({ state: 2 }); + expect(chargingToWire({ charging: true, level: 0.99 })).toEqual({ state: 2 }); + }); + it('charging at full is state 3', () => { + expect(chargingToWire({ charging: true, level: 1 })).toEqual({ state: 3 }); + }); + it('never carries a source key and never state 0', () => { + for (const charging of [true, false]) { + for (const level of [0, 0.5, 0.99, 1]) { + const w = chargingToWire({ charging, level }); + expect(Object.keys(w)).toEqual(['state']); + expect(w.state).not.toBe(0); + } + } + }); +}); diff --git a/packages/browser/src/system-trace-values.ts b/packages/browser/src/system-trace-values.ts index 3a1c418b..08aff626 100644 --- a/packages/browser/src/system-trace-values.ts +++ b/packages/browser/src/system-trace-values.ts @@ -153,3 +153,11 @@ export function connectionTypeToWire( } return 'unknown'; } + +/** The viewer's `charging` table is keyed by the Android/iOS ints (0 unknown, 1 unplugged, 2 charging, 3 full) and + * `processCharging` takes an int or `{state}`. A boolean never matched (false is dropped by a falsy guard; true looks up + * states["true"]), so the old trace rendered nothing. `source` is omitted: the Battery API cannot say AC/USB/wireless. */ +export function chargingToWire(b: { charging: boolean; level: number }): { state: 1 | 2 | 3 } { + if (!b.charging) return { state: 1 }; + return { state: b.level >= 1 ? 3 : 2 }; +} diff --git a/packages/browser/src/trace-producers.test.ts b/packages/browser/src/trace-producers.test.ts new file mode 100644 index 00000000..0a154c5f --- /dev/null +++ b/packages/browser/src/trace-producers.test.ts @@ -0,0 +1,144 @@ +import type { TraceProducer } from '@bugsee/capture'; +import { describe, expect, it, vi } from 'vitest'; +import { BatteryProducer, createBrowserSystemTraceProducers } from './trace-producers'; +import { BatteryTracker } from './trackers/battery-tracker'; + +function mkManager(level: number, charging: boolean) { + const l = new Map void>>(); + return { + level, + charging, + addEventListener: (t: string, f: () => void) => { + const s = l.get(t) ?? new Set<() => void>(); + s.add(f); + l.set(t, s); + }, + removeEventListener: (t: string, f: () => void) => void l.get(t)?.delete(f), + count: () => [...l.values()].reduce((n, x) => n + x.size, 0), + fire: (t: string) => + l.get(t)?.forEach((f) => { + f(); + }), + }; +} +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); + +function run(producers: TraceProducer[]) { + const out: Array<{ name: string; value: unknown }> = []; + const sink = { emit: (name: string, value: unknown) => void out.push({ name, value }) }; + const p = producers[0]; + if (p === undefined) throw new Error('no producer'); + p.start(sink); + return { out, p }; +} + +describe('BatteryProducer', () => { + it('publishes the shared tracker current() at start when another subscriber already resolved it', async () => { + const m = mkManager(0.25, false); + const tracker = new BatteryTracker({ getBattery: () => Promise.resolve(m) }); + const offOther = tracker.on('state', () => {}); + await flush(); + const { out, p } = run([new BatteryProducer(tracker)]); + expect(out).toEqual([ + { name: 'battery', value: 25 }, + { name: 'charging', value: { state: 1 } }, + ]); + p.stop(); + offOther(); + }); + + it('emits battery (rounded percent) and charging ({state}) once the manager resolves, then only on change', async () => { + const m = mkManager(0.426, true); + const { out, p } = run( + createBrowserSystemTraceProducers({ getBattery: () => Promise.resolve(m) }), + ); + expect(out).toEqual([]); + await flush(); + expect(out).toEqual([ + { name: 'battery', value: 43 }, + { name: 'charging', value: { state: 2 } }, + ]); + m.level = 0.428; // rounds to the same 43 -> nothing for battery + m.fire('levelchange'); + expect(out).toHaveLength(2); + m.level = 1; + m.fire('levelchange'); + expect(out.slice(2)).toEqual([ + { name: 'battery', value: 100 }, + { name: 'charging', value: { state: 3 } }, + ]); + m.charging = false; + m.fire('chargingchange'); + expect(out.slice(4)).toEqual([{ name: 'charging', value: { state: 1 } }]); + expect(m.count()).toBe(2); + p.stop(); + expect(m.count()).toBe(0); // stop released the tracker's listeners + m.level = 0.1; + m.fire('levelchange'); + expect(out).toHaveLength(5); + }); + + it('a restart (stop, then start) re-fetches getBattery and re-emits the baseline', async () => { + const m = mkManager(0.5, false); + const getBattery = vi.fn(() => Promise.resolve(m)); + const producers = createBrowserSystemTraceProducers({ getBattery }); + const first = run(producers); + await flush(); + first.p.stop(); // tracker deactivates -> current() undefined, listeners released + expect(m.count()).toBe(0); + const again = run(producers); // nothing cached: emits only after the NEW getBattery resolves + expect(again.out).toEqual([]); + await flush(); + expect(getBattery).toHaveBeenCalledTimes(2); + expect(again.out).toEqual([ + { name: 'battery', value: 50 }, + { name: 'charging', value: { state: 1 } }, + ]); + }); + + it('forwards onError to the battery tracker: a throw after getBattery resolves is reported', async () => { + const m = mkManager(0.5, false); + m.addEventListener = () => { + throw new Error('add refused'); + }; + const onError = vi.fn(); + const { out } = run( + createBrowserSystemTraceProducers({ getBattery: () => Promise.resolve(m) }, onError), + ); + await flush(); + expect((onError.mock.calls[0]?.[0] as Error).message).toBe('add refused'); + expect(out).toEqual([]); + }); + + it('constructs nothing at call time (no getBattery call)', () => { + const getBattery = vi.fn(); + createBrowserSystemTraceProducers({ getBattery }); + expect(getBattery).not.toHaveBeenCalled(); + }); + + it('default env reads navigator lazily and tolerates its absence', async () => { + vi.stubGlobal('navigator', undefined); + try { + const { out } = run(createBrowserSystemTraceProducers()); + await flush(); + expect(out).toEqual([]); + vi.stubGlobal('navigator', {}); + expect(run(createBrowserSystemTraceProducers()).out).toEqual([]); + const m = mkManager(0.7, false); + const nav = { + getBattery(this: unknown) { + return this === nav ? Promise.resolve(m) : Promise.reject(new Error('unbound')); + }, + }; + vi.stubGlobal('navigator', nav); + const r = run(createBrowserSystemTraceProducers()); + await flush(); + expect(r.out).toEqual([ + { name: 'battery', value: 70 }, + { name: 'charging', value: { state: 1 } }, + ]); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/packages/browser/src/trace-producers.ts b/packages/browser/src/trace-producers.ts new file mode 100644 index 00000000..04b4dc4f --- /dev/null +++ b/packages/browser/src/trace-producers.ts @@ -0,0 +1,60 @@ +import { type TraceProducer, TraceProducerBase } from '@bugsee/capture'; +import { chargingToWire } from './system-trace-values'; +import { + type BatteryEnv, + type BatteryManagerLike, + type BatteryState, + BatteryTracker, +} from './trackers/battery-tracker'; + +// Browser `traces.system` producers (mirrors the mobile tracker/producer split). + +export class BatteryProducer extends TraceProducerBase { + readonly name = 'battery'; + readonly #tracker: BatteryTracker; + #off: (() => void) | undefined; + + constructor(tracker: BatteryTracker) { + super(); + this.#tracker = tracker; + } + + protected onStart(): void { + this.#off = this.#tracker.on('state', (s) => this.#publish(s)); // subscribing activates the tracker + const cur = this.#tracker.current(); + if (cur !== undefined) this.#publish(cur); + } + + protected override onStop(): void { + this.#off?.(); + this.#off = undefined; + } + + #publish(s: BatteryState): void { + this.emitIfChanged('battery', Math.round(s.level * 100)); // 0-1 -> 0-100 (Android parity) + this.emitIfChanged('charging', chargingToWire(s)); + } +} + +/** Reads `navigator` lazily (workers / Node tests may have none) and binds getBattery to it. */ +function realBatteryEnv(): BatteryEnv { + const nav = ( + globalThis as unknown as { + navigator?: { getBattery?: () => Promise }; + } + ).navigator; + if (nav === undefined || typeof nav.getBattery !== 'function') return {}; + const getBattery = nav.getBattery; + return { getBattery: () => getBattery.call(nav) }; +} + +/** + * The browser trace producers. `onError` receives a battery failure that happens after getBattery() + * resolves (a throwing manager getter / addEventListener), which would otherwise be swallowed. + */ +export function createBrowserSystemTraceProducers( + env?: BatteryEnv, + onError?: (error: unknown) => void, +): TraceProducer[] { + return [new BatteryProducer(new BatteryTracker(env ?? realBatteryEnv(), onError))]; +} diff --git a/packages/browser/src/trackers/battery-tracker.test.ts b/packages/browser/src/trackers/battery-tracker.test.ts new file mode 100644 index 00000000..1383d88c --- /dev/null +++ b/packages/browser/src/trackers/battery-tracker.test.ts @@ -0,0 +1,299 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type BatteryManagerLike, type BatteryState, BatteryTracker } from './battery-tracker'; + +function mkManager(level = 0.5, charging = false) { + const listeners = new Map void>>(); + const set = (t: string): Set<() => void> => { + let s = listeners.get(t); + if (s === undefined) { + s = new Set(); + listeners.set(t, s); + } + return s; + }; + return { + level, + charging, + addEventListener: (t: string, l: () => void) => { + set(t).add(l); + }, + removeEventListener: (t: string, l: () => void) => void listeners.get(t)?.delete(l), + fire: (t: string) => + listeners.get(t)?.forEach((l) => { + l(); + }), + has: (t: string) => (listeners.get(t)?.size ?? 0) > 0, + count: () => [...listeners.values()].reduce((n, s) => n + s.size, 0), + }; +} + +const flush = (): Promise => new Promise((r) => setTimeout(r, 0)); + +describe('BatteryTracker', () => { + it('does not call getBattery at construction', () => { + const getBattery = vi.fn(); + new BatteryTracker({ getBattery }); + expect(getBattery).not.toHaveBeenCalled(); + }); + + it('calls getBattery once on the first subscriber only, then attaches listeners and emits state', async () => { + const m = mkManager(0.5, true); + const getBattery = vi.fn(() => Promise.resolve(m)); + const t = new BatteryTracker({ getBattery }); + const a: BatteryState[] = []; + const b: BatteryState[] = []; + t.on('state', (s) => a.push(s)); + t.on('state', (s) => b.push(s)); + expect(getBattery).toHaveBeenCalledTimes(1); + expect(t.current()).toBeUndefined(); + await flush(); + expect(m.has('levelchange')).toBe(true); + expect(m.has('chargingchange')).toBe(true); + expect(a).toEqual([{ level: 0.5, charging: true }]); + expect(b).toEqual(a); + expect(t.current()).toEqual({ level: 0.5, charging: true }); + }); + + it('re-emits the new state on levelchange and chargingchange', async () => { + const m = mkManager(0.5, false); + const t = new BatteryTracker({ getBattery: () => Promise.resolve(m) }); + const seen: BatteryState[] = []; + t.on('state', (s) => seen.push(s)); + await flush(); + m.level = 0.4; + m.fire('levelchange'); + m.charging = true; + m.fire('chargingchange'); + expect(seen).toEqual([ + { level: 0.5, charging: false }, + { level: 0.4, charging: false }, + { level: 0.4, charging: true }, + ]); + expect(t.current()).toEqual({ level: 0.4, charging: true }); + }); + + it('last unsubscribe removes both listeners and clears current()', async () => { + const m = mkManager(); + const t = new BatteryTracker({ getBattery: () => Promise.resolve(m) }); + const off1 = t.on('state', () => {}); + const off2 = t.on('state', () => {}); + await flush(); + off1(); + expect(m.count()).toBe(2); + off2(); + expect(m.count()).toBe(0); + expect(t.current()).toBeUndefined(); + }); + + it('stop before resolve: the late resolve attaches nothing and emits nothing', async () => { + const m = mkManager(); + let resolve: (v: BatteryManagerLike) => void = () => {}; + const t = new BatteryTracker({ getBattery: () => new Promise((r) => (resolve = r)) }); + const seen: BatteryState[] = []; + const off = t.on('state', (s) => seen.push(s)); + off(); + resolve(m); + await flush(); + expect(m.count()).toBe(0); + expect(seen).toEqual([]); + expect(t.current()).toBeUndefined(); + }); + + it('stop -> start: the old promise resolving late is ignored, the new one applies', async () => { + const oldM = mkManager(0.1); + const newM = mkManager(0.9); + const resolvers: Array<(v: BatteryManagerLike) => void> = []; + const t = new BatteryTracker({ + getBattery: () => new Promise((r) => resolvers.push(r)), + }); + const seen: BatteryState[] = []; + t.on('state', (s) => seen.push(s))(); + t.on('state', (s) => seen.push(s)); + expect(resolvers).toHaveLength(2); + resolvers[1]?.(newM); + await flush(); + resolvers[0]?.(oldM); + await flush(); + expect(oldM.count()).toBe(0); + expect(newM.count()).toBe(2); + expect(seen).toEqual([{ level: 0.9, charging: false }]); + }); + + it('a rejecting getBattery neither throws nor emits', async () => { + const t = new BatteryTracker({ getBattery: () => Promise.reject(new Error('denied')) }); + const seen: BatteryState[] = []; + expect(() => t.on('state', (s) => seen.push(s))).not.toThrow(); + await flush(); + expect(seen).toEqual([]); + expect(t.current()).toBeUndefined(); + }); + + it('a synchronously throwing getBattery neither throws nor emits', () => { + const t = new BatteryTracker({ + getBattery: () => { + throw new Error('SecurityError'); + }, + }); + const seen: BatteryState[] = []; + expect(() => t.on('state', (s) => seen.push(s))).not.toThrow(); + expect(seen).toEqual([]); + }); + + it('an env without getBattery is inert', () => { + const t = new BatteryTracker({}); + expect(() => t.on('state', () => {})).not.toThrow(); + expect(t.current()).toBeUndefined(); + }); + + it('a manager without addEventListener still emits the one baseline', async () => { + const t = new BatteryTracker({ + getBattery: () => Promise.resolve({ level: 0.3, charging: true }), + }); + const seen: BatteryState[] = []; + const off = t.on('state', (s) => seen.push(s)); + await flush(); + expect(seen).toEqual([{ level: 0.3, charging: true }]); + expect(() => off()).not.toThrow(); + }); + + it('a subscriber that leaves synchronously during the baseline publish leaves nothing attached', async () => { + const m = mkManager(0.5, false); + const t = new BatteryTracker({ getBattery: () => Promise.resolve(m) }); + const off = t.on('state', () => off()); + await flush(); + expect(m.count()).toBe(0); + expect(t.current()).toBeUndefined(); + }); + + it('a throwing removeEventListener on the last unsubscribe does not throw out of off()', async () => { + const m = mkManager(); + const t = new BatteryTracker({ getBattery: () => Promise.resolve(m) }); + const off = t.on('state', () => {}); + await flush(); + m.removeEventListener = () => { + throw new Error('remove refused'); + }; + expect(() => off()).not.toThrow(); + expect(t.current()).toBeUndefined(); + }); + + describe('a throw AFTER getBattery() resolves (inside the fulfilment)', () => { + // A throw inside the .then() callback rejects the derived promise, which nothing handles. + // The suite runs on Node: its process-level hook is the observable for an unhandled rejection. + const proc = ( + globalThis as unknown as { + process: { + on(event: string, fn: (reason: unknown) => void): void; + off(event: string, fn: (reason: unknown) => void): void; + }; + } + ).process; + const rejections: unknown[] = []; + const onRejection = (reason: unknown): void => void rejections.push(reason); + beforeEach(() => { + rejections.length = 0; + proc.on('unhandledRejection', onRejection); + }); + afterEach(() => { + proc.off('unhandledRejection', onRejection); + }); + const settle = async (): Promise => { + await flush(); + await flush(); // unhandledRejection is emitted after a macrotask turn + }; + + it('addEventListener throwing: onError, no unhandled rejection, no baseline, nothing left attached; a later re-activation works', async () => { + const m = mkManager(0.7, true); + const realAdd = m.addEventListener; + let refuse = true; + m.addEventListener = (t: string, l: () => void) => { + if (refuse && t === 'chargingchange') throw new Error('add refused'); + realAdd(t, l); + }; + const errors: unknown[] = []; + const t = new BatteryTracker({ getBattery: () => Promise.resolve(m) }, (e) => errors.push(e)); + const seen: BatteryState[] = []; + const off = t.on('state', (s) => seen.push(s)); + await settle(); + expect(rejections).toEqual([]); + expect((errors[0] as Error).message).toBe('add refused'); + expect(seen).toEqual([]); + expect(t.current()).toBeUndefined(); + expect(m.count()).toBe(0); // the levelchange listener attached before the throw was removed + const realRemove = m.removeEventListener; + let removes = 0; + m.removeEventListener = (type: string, l: () => void) => { + removes += 1; + realRemove(type, l); + }; + off(); + expect(removes).toBe(0); // already detached: the deactivation finds nothing left to remove + refuse = false; + t.on('state', (s) => seen.push(s)); + await settle(); + expect(seen).toEqual([{ level: 0.7, charging: true }]); + expect(m.count()).toBe(2); + expect(errors).toHaveLength(1); + }); + + it('a level getter throwing: onError, no unhandled rejection, no baseline, listeners removed', async () => { + const m = mkManager(); + let broken = true; + Object.defineProperty(m, 'level', { + get: () => { + if (broken) throw new Error('level boom'); + return 0.25; + }, + }); + const errors: unknown[] = []; + const t = new BatteryTracker({ getBattery: () => Promise.resolve(m) }, (e) => errors.push(e)); + const seen: BatteryState[] = []; + const off = t.on('state', (s) => seen.push(s)); + await settle(); + expect(rejections).toEqual([]); + expect((errors[0] as Error).message).toBe('level boom'); + expect(seen).toEqual([]); + expect(t.current()).toBeUndefined(); + expect(m.count()).toBe(0); + off(); + broken = false; + t.on('state', (s) => seen.push(s)); + await settle(); + expect(seen).toEqual([{ level: 0.25, charging: false }]); + }); + + it('a throwing removeEventListener during the unwind is swallowed; without onError nothing escapes', async () => { + const m = mkManager(); + m.addEventListener = (t: string) => { + if (t === 'chargingchange') throw new Error('add refused'); + }; + m.removeEventListener = () => { + throw new Error('remove refused'); + }; + const t = new BatteryTracker({ getBattery: () => Promise.resolve(m) }); + const seen: BatteryState[] = []; + t.on('state', (s) => seen.push(s)); + await settle(); + expect(rejections).toEqual([]); + expect(seen).toEqual([]); + }); + + it('a getter throwing on a LATER levelchange is reported, never thrown into the event dispatch', async () => { + const m = mkManager(0.5, false); + const errors: unknown[] = []; + const t = new BatteryTracker({ getBattery: () => Promise.resolve(m) }, (e) => errors.push(e)); + const seen: BatteryState[] = []; + t.on('state', (s) => seen.push(s)); + await settle(); + Object.defineProperty(m, 'level', { + get: () => { + throw new Error('late boom'); + }, + }); + expect(() => m.fire('levelchange')).not.toThrow(); + expect((errors[0] as Error).message).toBe('late boom'); + expect(seen).toEqual([{ level: 0.5, charging: false }]); + expect(t.current()).toEqual({ level: 0.5, charging: false }); // last good state kept + }); + }); +}); diff --git a/packages/browser/src/trackers/battery-tracker.ts b/packages/browser/src/trackers/battery-tracker.ts new file mode 100644 index 00000000..eb3c909d --- /dev/null +++ b/packages/browser/src/trackers/battery-tracker.ts @@ -0,0 +1,109 @@ +import { InterceptorBase } from '@bugsee/core'; + +// Browser battery TRACKER (mirrors the mobile tracker/producer split): wraps navigator.getBattery(). It calls +// getBattery() only on first subscription, listens to levelchange/chargingchange, and releases everything on the +// last unsubscribe. A generation token makes a late-resolving getBattery() after stop (or stop -> start) inert. + +export interface BatteryManagerLike { + level: number; + charging: boolean; + addEventListener?(type: string, listener: () => void): void; + removeEventListener?(type: string, listener: () => void): void; +} +export interface BatteryState { + level: number; + charging: boolean; +} +export interface BatteryEnv { + getBattery?: () => Promise; +} + +export class BatteryTracker extends InterceptorBase<{ state: BatteryState }> { + readonly name = 'browser-battery'; + readonly #env: BatteryEnv; + #generation = 0; + #manager: BatteryManagerLike | undefined; + #state: BatteryState | undefined; + #onChange: (() => void) | undefined; + + readonly #onError: ((error: unknown) => void) | undefined; + + constructor(env: BatteryEnv, onError?: (error: unknown) => void) { + super(onError); + this.#env = env; + this.#onError = onError; + } + + /** Latest state; undefined before getBattery() resolves and after deactivation. */ + current(): BatteryState | undefined { + return this.#state; + } + + protected onActivate(): void { + const mine = ++this.#generation; + try { + this.#env.getBattery?.().then( + (manager) => { + if (this.#generation !== mine) return; // stopped (or restarted) while in flight + this.#attach(manager); + }, + () => {}, // unsupported / denied -> no battery traces + ); + } catch { + /* a synchronous SecurityError just means no battery traces */ + } + } + + // Runs inside the getBattery() fulfilment: a throw here would reject a promise nobody handles, so the + // whole body is guarded. On failure everything attached so far is detached and no baseline is kept; + // the next activation retries. + #attach(manager: BatteryManagerLike): void { + const onChange = (): void => { + try { + this.#publish(manager); + } catch (error) { + this.#onError?.(error); // never throw into the BatteryManager's event dispatch + } + }; + // Recorded BEFORE attaching (and before the baseline publish), so a subscriber that leaves + // synchronously during the publish — or the unwind below — detaches exactly these. + this.#manager = manager; + this.#onChange = onChange; + try { + manager.addEventListener?.('levelchange', onChange); + manager.addEventListener?.('chargingchange', onChange); + this.#publish(manager); + } catch (error) { + this.#detach(); // removing a never-added listener is a no-op (DOM semantics) + this.#onError?.(error); + } + } + + protected override onDeactivate(): void { + this.#generation += 1; // invalidate any in-flight fetch + this.#detach(); + } + + /** Removes the change listeners (best-effort) and forgets the manager and its state. */ + #detach(): void { + const manager = this.#manager; + const onChange = this.#onChange; + this.#manager = undefined; + this.#onChange = undefined; + this.#state = undefined; + if (manager === undefined || onChange === undefined) return; + for (const type of ['levelchange', 'chargingchange']) { + try { + manager.removeEventListener?.(type, onChange); + } catch { + /* best-effort */ + } + } + } + + #publish(manager: BatteryManagerLike): void { + const state = { level: manager.level, charging: manager.charging }; // getters may throw + this.#state = state; + this.emit('state', state); + } +} diff --git a/packages/bugsee/src/relaunch-options.test.ts b/packages/bugsee/src/relaunch-options.test.ts index bb1bcbe2..b90c76ed 100644 --- a/packages/bugsee/src/relaunch-options.test.ts +++ b/packages/bugsee/src/relaunch-options.test.ts @@ -121,7 +121,11 @@ function optionKeys(pkg: string, file: string, name: string): string[] { * - `instanceIdentity` (node) is a per-instance identity: a pinned one must never be shared by two instances, * so a relaunch builds a fresh one; * - `captureNonce`, `controlNonce`, `otelExportHeaders` and `otelExportUrl` are secrets or per-launch - * credentials: never kept (`setCarrierLastLaunch` stores only the kept list). + * credentials: never kept (`setCarrierLastLaunch` stores only the kept list); + * - `systemTraceProducers` (node/bun/deno) holds STATEFUL producer instances: a producer's `start()` is a + * no-op while it is already started, so an instance still running on another client would silently no-op + * on the new one, and its trackers stay bound to the scheduler/onError of the launch that built them. A + * relaunch therefore builds the runtime's fresh default set. */ const RESET_OPTIONS: readonly string[] = [ 'app', @@ -196,6 +200,7 @@ const RESET_OPTIONS: readonly string[] = [ 'reportTrigger', 'shutdownTimeoutMs', 'startInBlackout', + 'systemTraceProducers', 'traceInteractions', 'traceNavigations', 'tracePropagationOrigin', diff --git a/packages/bun/src/index.ts b/packages/bun/src/index.ts index 07d798e0..df4696b5 100644 --- a/packages/bun/src/index.ts +++ b/packages/bun/src/index.ts @@ -1,7 +1,7 @@ // @bugsee/bun — Bun platform. Bun is node-API-compatible, so this package IS @bugsee/node with // two Bun-specific overrides: launch()/launchCore() default the system probe (platform.type 'bun' + Bun -// version) and the system-metrics sampler (guarded perf_hooks). The full node surface is re-exported; the -// explicit launch/launchCore exports below shadow node's same-named exports. +// version) and the system trace producers (createNodeSystemTraceProducers over guarded perf_hooks). The full +// node surface is re-exported; the explicit launch/launchCore exports below shadow node's same-named exports. export * from '@bugsee/node'; export { @@ -13,5 +13,6 @@ export { bunSystemProbe, createBunSystemProbe } from './environment'; export { launch, launchCore } from './launch'; // Likewise `relaunch` re-runs THIS launch (the node one from `export *` would drop the runtime probe/gates). export { relaunch } from './relaunch'; -// The guarded sampler (createGuardedSystemMetricsSampler + PerfHooks + GuardedSystemMetricsDeps) is now -// shared in @bugsee/node and surfaced via `export *` above — no Bun-specific copy. +// The legacy guarded sampler (createGuardedSystemMetricsSampler + PerfHooks + GuardedSystemMetricsDeps) lives in +// @bugsee/node and is surfaced via `export *` above; launch no longer uses it (it uses the trace producers), but +// it still works as a caller-supplied `systemMetricsSampler`. diff --git a/packages/bun/src/launch.test.ts b/packages/bun/src/launch.test.ts index 66125528..bbc9fbf2 100644 --- a/packages/bun/src/launch.test.ts +++ b/packages/bun/src/launch.test.ts @@ -1,18 +1,29 @@ import process from 'node:process'; import { type Clock, + createCaptureExporter, createMemoryCaptureStore, type HttpRequestOptions, type HttpResponse, type HttpTransport, type Scheduler, } from '@bugsee/core'; -import type { NodeRuntime, SystemProbe } from '@bugsee/node'; +import { createNodeSystemTraceProducers, type NodeRuntime, type SystemProbe } from '@bugsee/node'; import { strFromU8, unzipSync } from '@bugsee/util'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { bunSystemProbe } from './environment'; import { launch, launchCore } from './launch'; +// Pass-through spy: pins the wiring (Bun measures event-loop delay, so it must NOT opt out) without +// changing behaviour. +vi.mock('@bugsee/node', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createNodeSystemTraceProducers: vi.fn(actual.createNodeSystemTraceProducers), + }; +}); + // --- fakes: no real process / network / timers -------------------------------------------------- function fakeProcess(): NodeRuntime { @@ -76,6 +87,12 @@ const base = (over: Partial[1]> = {}): Parameters { + vi.mocked(createNodeSystemTraceProducers).mockClear(); +}); + const clients: Array<{ stop: (t?: number) => Promise }> = []; afterEach(async () => { await Promise.all(clients.splice(0).map((c) => c.stop())); @@ -87,6 +104,32 @@ const track = Promise }>(c: T): T => }; describe('@bugsee/bun launch', () => { + it('injects the node trace producers (not a legacy sampler) and they feed traces.system', async () => { + vi.mocked(createNodeSystemTraceProducers).mockClear(); + const store = memStore(); + track(launchCore('tok', base({ captureStore: store, captureSystemTraces: true })).client); + const produced = vi.mocked(createNodeSystemTraceProducers).mock.results[0]?.value as unknown[]; + expect(Array.isArray(produced)).toBe(true); + expect(produced.length).toBeGreaterThan(0); + for (const p of produced) { + expect(typeof (p as { start: unknown }).start).toBe('function'); + expect(typeof (p as { stop: unknown }).stop).toBe('function'); + } + const traces = (await createCaptureExporter(store).drain()).get('traces.system') ?? []; + const names = traces.map((e) => (e.data as { name: string }).name); + expect(names).toContain('process_memory_rss'); + expect(names).toContain('ram_system_total'); + expect(names).toContain('cpu_usage_process'); + }); + + it('does not opt out of event-loop delay (Bun measures it)', () => { + track(launchCore('tok', base()).client); + expect(createNodeSystemTraceProducers).toHaveBeenCalledTimes(1); + const arg = vi.mocked(createNodeSystemTraceProducers).mock.calls[0]?.[0]; + expect(arg).toBeDefined(); + expect(arg).not.toHaveProperty('measuresEventLoopDelay'); // the node default (true) applies + expect(arg?.scheduler).toBe(fakeScheduler); // only the launch seams are forwarded + }); it('reports the Bun RUNTIME identity in the environment envelope', () => { const { client, internals } = launchCore('tok', base()); track(client); @@ -261,3 +304,82 @@ describe('@bugsee/bun launch', () => { } }); }); + +describe('@bugsee/bun — the launch scheduler / onError reach the trace tracker', () => { + const recordingScheduler = () => { + const calls: Array<{ cb: () => void; ms: number }> = []; + const scheduler: Scheduler = { + setInterval: (cb, ms) => { + calls.push({ cb, ms }); + return `h${calls.length}`; + }, + clearInterval: () => {}, + }; + return { calls, scheduler }; + }; + + it('forwards the launch scheduler and onError into the producer factory', () => { + vi.mocked(createNodeSystemTraceProducers).mockClear(); + const { scheduler } = recordingScheduler(); + const onError = vi.fn(); + track(launchCore('tok', base({ scheduler, onError })).client); + expect(createNodeSystemTraceProducers).toHaveBeenCalledTimes(1); + const arg = vi.mocked(createNodeSystemTraceProducers).mock.calls[0]?.[0]; + expect(arg?.scheduler).toBe(scheduler); + expect(arg?.onError).toBe(onError); + }); + + it('sampling runs on the injected scheduler: a fake tick produces a stats emit (no real timer)', async () => { + const { calls, scheduler } = recordingScheduler(); + const store = memStore(); + track( + launchCore('tok', base({ scheduler, captureStore: store, captureSystemTraces: true })).client, + ); + const rss = async () => + ((await createCaptureExporter(store).drain()).get('traces.system') ?? []).filter( + (e) => (e.data as { name: string }).name === 'process_memory_rss', + ).length; + expect(await rss()).toBe(1); // the start baseline + for (const c of calls.filter((x) => x.ms === 1000)) c.cb(); + expect(await rss()).toBe(2); + }); + + it('a throwing reader during a poll reaches options.onError', () => { + const { calls, scheduler } = recordingScheduler(); + const onError = vi.fn(); + track(launchCore('tok', base({ scheduler, onError, captureSystemTraces: true })).client); + const spy = vi.spyOn(process, 'memoryUsage').mockImplementation(() => { + throw new Error('memoryUsage boom'); + }); + try { + for (const c of calls.filter((x) => x.ms === 1000)) c.cb(); + } finally { + spy.mockRestore(); + } + expect(onError.mock.calls.map((c) => (c[0] as Error).message)).toContain('memoryUsage boom'); + }); +}); + +describe('@bugsee/bun — captureSystemTraces:false attaches no trace sampling', () => { + it('no sampling timer, no trace entries; enabling adds exactly the provider tick + the tracker timer', async () => { + const launchWith = (captureSystemTraces: boolean) => { + const calls: number[] = []; + const store = memStore(); + const scheduler: Scheduler = { + setInterval: (_cb, ms) => { + calls.push(ms); + return `h${calls.length}`; + }, + clearInterval: () => {}, + }; + track( + launchCore('tok', base({ scheduler, captureStore: store, captureSystemTraces })).client, + ); + return { store, oneSecond: calls.filter((ms) => ms === 1000).length }; + }; + const off = launchWith(false); + const on = launchWith(true); + expect(on.oneSecond - off.oneSecond).toBe(2); + expect((await createCaptureExporter(off.store).drain()).get('traces.system')).toBeUndefined(); + }); +}); diff --git a/packages/bun/src/launch.ts b/packages/bun/src/launch.ts index 934f8362..fb2f127f 100644 --- a/packages/bun/src/launch.ts +++ b/packages/bun/src/launch.ts @@ -2,7 +2,7 @@ import { getCarrierClient } from '@bugsee/core'; import { type Bugsee, type BugseeLaunchOptions, - createGuardedSystemMetricsSampler, + createNodeSystemTraceProducers, type LaunchResult, launchCore as nodeLaunchCore, } from '@bugsee/node'; @@ -13,13 +13,17 @@ import { bunSystemProbe } from './environment'; // node:fs, process, timers), so this reuses the ENTIRE @bugsee/node composition (transport, fs storage, // node:http capture, crash detection, durable queue + capture recovery) and only swaps the two // Bun-specific defaults: the system probe (reports platform.type 'bun' + the Bun version) and the -// system-metrics sampler (guarded perf_hooks). Both remain overridable — a caller-supplied option wins +// system trace producers (guarded perf_hooks). Both remain overridable — a caller-supplied option wins // (it spreads AFTER the Bun defaults). export function launchCore(appToken: string, options: BugseeLaunchOptions = {}): LaunchResult { return nodeLaunchCore(appToken, { systemProbe: bunSystemProbe, - systemMetricsSampler: createGuardedSystemMetricsSampler(), + systemTraceProducers: createNodeSystemTraceProducers({ + // the shared tracker's sampling timer honors an injected scheduler, and its poll errors are reported + ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), + ...(options.onError !== undefined ? { onError: options.onError } : {}), + }), ...options, // LOCAL VARIABLES ARE OFF HERE, after `...options` so a caller cannot switch them on. Measured on // Bun 1.4.0: `Debugger.enable` is REFUSED — `Inspector method "Debugger.enable" requires an active diff --git a/packages/capture/src/index.ts b/packages/capture/src/index.ts index b1f3e8fb..e57971af 100644 --- a/packages/capture/src/index.ts +++ b/packages/capture/src/index.ts @@ -62,9 +62,16 @@ export { } from './system-events-provider'; export { createSystemTracesProvider, + deriveRefreshMs, type SystemTracesProviderOptions, type TraceSample, } from './system-traces-provider'; +export { + type TraceEmitOptions, + type TraceProducer, + TraceProducerBase, + type TraceSink, +} from './trace-producer'; export { createTraceparentDecorator, type ParsedTraceparent, diff --git a/packages/capture/src/system-traces-provider.test.ts b/packages/capture/src/system-traces-provider.test.ts index 36a77f76..f821f8b4 100644 --- a/packages/capture/src/system-traces-provider.test.ts +++ b/packages/capture/src/system-traces-provider.test.ts @@ -12,7 +12,12 @@ import { } from '@bugsee/core'; import { BugseeOption } from '@bugsee/protocol'; import { describe, expect, it, vi } from 'vitest'; -import { createSystemTracesProvider, type TraceSample } from './system-traces-provider'; +import { + createSystemTracesProvider, + deriveRefreshMs, + type TraceSample, +} from './system-traces-provider'; +import { type TraceProducer, TraceProducerBase, type TraceSink } from './trace-producer'; const mkStore = (): CaptureStore => createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }); @@ -150,3 +155,607 @@ describe('createSystemTracesProvider', () => { } }); }); + +class Fixed extends TraceProducerBase { + readonly name: string; + startedCount = 0; + stoppedCount = 0; + constructor( + name: string, + readonly seed: Array<[string, unknown, number?]>, + private readonly opts: { throwStart?: boolean; throwStop?: boolean; held?: boolean } = {}, + ) { + super(); + this.name = name; + } + #out(n: string, v: unknown, d?: number): void { + if (this.opts.held) this.emitIfChanged(n, v, d); + else this.emit(n, v, d); + } + protected onStart(): void { + this.startedCount += 1; + for (const [n, v, d] of this.seed) this.#out(n, v, d); + if (this.opts.throwStart) throw new Error(`${this.name} start boom`); + } + protected override onStop(): void { + this.stoppedCount += 1; + if (this.opts.throwStop) throw new Error(`${this.name} stop boom`); + } + push(n: string, v: unknown): void { + this.#out(n, v); + } +} +const values = async (store: CaptureStore) => + (await drainSystem(store))?.map( + (e) => e.data as { name: string; value: unknown; timestamp: number; displayId?: number }, + ) ?? []; + +describe('createSystemTracesProvider — producers', () => { + it('starts every producer, routing their emits to traces.system with the provider timestamp', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + const a = new Fixed('a', [['x', 1]]); + const b = new Fixed('b', [['y', 2, 0]]); + const p = createSystemTracesProvider({ + producers: [a, b], + scheduler: fs.scheduler, + now: () => 1000, + }); + p.init(buildInit(store)); + p.start(options); + expect(await values(store)).toEqual([ + { timestamp: 1000, name: 'x', value: 1 }, + { timestamp: 1000, name: 'y', value: 2, displayId: 0 }, + ]); + }); + + it('isolates a producer whose start() throws: reports, stops it, the others still start', () => { + const fs = fakeScheduler(); + const errors: unknown[] = []; + const bad = new Fixed('bad', [], { throwStart: true }); + const good = new Fixed('good', [['x', 1]]); + const p = createSystemTracesProvider({ + producers: [bad, good], + scheduler: fs.scheduler, + onError: (e) => errors.push(e), + }); + p.init(buildInit(mkStore())); + expect(() => p.start(options)).not.toThrow(); + expect(good.startedCount).toBe(1); + expect(bad.stoppedCount).toBe(1); + expect((errors[0] as Error).message).toBe('bad start boom'); + }); + + it('isolates a producer whose stop() throws: the rest still stop, the timer is cleared', () => { + const fs = fakeScheduler(); + const errors: unknown[] = []; + const bad = new Fixed('bad', [], { throwStop: true }); + const good = new Fixed('good', []); + const p = createSystemTracesProvider({ + producers: [bad, good], + scheduler: fs.scheduler, + onError: (e) => errors.push(e), + }); + p.init(buildInit(mkStore())); + p.start(options); + expect(() => p.stop()).not.toThrow(); + expect(good.stoppedCount).toBe(1); + expect(fs.cleared()).toBe(1); + expect(errors).toHaveLength(1); + }); + + it('never starts a producer when captureSystemTraces is off (coordinator gate)', () => { + const fs = fakeScheduler(); + const a = new Fixed('a', [['x', 1]]); + const coordinator = createCaptureCoordinator(buildInit(mkStore())); + coordinator.addProvider( + createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler }), + ); + coordinator.start(options, () => false); + expect(a.startedCount).toBe(0); + coordinator.stop(); + expect(a.stoppedCount).toBe(0); + }); + + it('drops a producer emit that arrives after stop() (a late tracker callback)', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + const a = new Fixed('a', []); + const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, now: () => 1 }); + p.init(buildInit(store)); + p.start(options); + p.stop(); + a.push('x', 1); + expect(await values(store)).toEqual([]); + }); + + it('restart: producers start again and the baseline re-emits', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + const a = new Fixed('a', [['x', 1]]); + const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, now: () => 1 }); + p.init(buildInit(store)); + p.start(options); + p.stop(); + p.start(options); + expect(a.startedCount).toBe(2); + expect((await values(store)).map((v) => v.name)).toEqual(['x', 'x']); + }); + + it('REFRESH: re-emits a held value once it is older than refreshMs, and not before', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let t = 0; + const a = new Fixed('a', [['battery', 50]], { held: true }); + const p = createSystemTracesProvider({ + producers: [a], + scheduler: fs.scheduler, + now: () => t, + refreshMs: 10_000, + }); + p.init(buildInit(store)); + p.start(options); + t = 9_999; + fs.tick(); + t = 10_000; + fs.tick(); + t = 10_500; + fs.tick(); + expect((await values(store)).map((v) => [v.name, v.timestamp])).toEqual([ + ['battery', 0], + ['battery', 10_000], + ]); + }); + + it('REFRESH keeps displayId so per-display values are distinct and re-emitted with it', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let t = 0; + const a = new Fixed( + 'a', + [ + ['o', 1, 0], + ['o', 2, 1], + ], + { held: true }, + ); + const p = createSystemTracesProvider({ + producers: [a], + scheduler: fs.scheduler, + now: () => t, + refreshMs: 10, + }); + p.init(buildInit(store)); + p.start(options); + t = 10; + fs.tick(); + expect((await values(store)).map((v) => [v.value, v.displayId, v.timestamp])).toEqual([ + [1, 0, 0], + [2, 1, 0], + [1, 0, 10], + [2, 1, 10], + ]); + }); + + it('NO REFRESH for a polled (non-held) value: a producer whose poll fails stops appearing, never frozen at its last value', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let t = 0; + const cpu = new Fixed('cpu', [ + ['cpu_usage_process', 12], + ['memory_process', 3], + ]); + const p = createSystemTracesProvider({ + producers: [cpu], + scheduler: fs.scheduler, + now: () => t, + refreshMs: 10_000, + }); + p.init(buildInit(store)); + p.start(options); + // the tracker's poll now throws, so the producer emits nothing more — for well past refreshMs + for (t = 1_000; t <= 35_000; t += 1_000) fs.tick(); + expect((await values(store)).map((v) => [v.name, v.timestamp])).toEqual([ + ['cpu_usage_process', 0], + ['memory_process', 0], + ]); + }); + + it('NO REFRESH for legacy sample() entries (they are re-read each tick, never held)', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let t = 0; + let rows: TraceSample[] = [{ name: 'm', value: 1 }]; + const p = createSystemTracesProvider({ + sample: () => rows, + scheduler: fs.scheduler, + now: () => t, + refreshMs: 10, + }); + p.init(buildInit(store)); + p.start(options); + rows = []; // the sampler has nothing to say any more + t = 100; + fs.tick(); + t = 200; + fs.tick(); + expect((await values(store)).map((v) => v.timestamp)).toEqual([0]); + }); + + it('the sink holds ONLY an explicit hold: true (an emit without options is not refreshed)', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let t = 0; + const raw: TraceProducer = { + name: 'raw', + start: (sink) => { + sink.emit('plain', 1); + sink.emit('truthy', 2, undefined, { hold: 1 as unknown as boolean }); + sink.emit('held', 3, undefined, { hold: true }); + }, + stop: () => {}, + }; + const p = createSystemTracesProvider({ + producers: [raw], + scheduler: fs.scheduler, + now: () => t, + refreshMs: 10, + }); + p.init(buildInit(store)); + p.start(options); + t = 10; + fs.tick(); + expect((await values(store)).map((v) => [v.name, v.timestamp])).toEqual([ + ['plain', 0], + ['truthy', 0], + ['held', 0], + ['held', 10], + ]); + }); + + it('refreshMs: Infinity disables the refresh', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let t = 0; + const a = new Fixed('a', [['x', 1]], { held: true }); + const p = createSystemTracesProvider({ + producers: [a], + scheduler: fs.scheduler, + now: () => t, + refreshMs: Number.POSITIVE_INFINITY, + }); + p.init(buildInit(store)); + p.start(options); + t = 1e9; + fs.tick(); + expect(await values(store)).toHaveLength(1); + }); + + it('a value that keeps changing is not refreshed (its latest timestamp is fresh)', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let t = 0; + const a = new Fixed('a', [['x', 0]], { held: true }); + const p = createSystemTracesProvider({ + producers: [a], + scheduler: fs.scheduler, + now: () => t, + refreshMs: 10_000, + }); + p.init(buildInit(store)); + p.start(options); + t = 9_000; + a.push('x', 1); + t = 12_000; + fs.tick(); + expect((await values(store)).map((v) => v.value)).toEqual([0, 1]); + }); + + it('the snapshot is cleared on stop and on restart (no stale refresh)', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let t = 0; + const a = new Fixed('a', [['x', 1]], { held: true }); + const p = createSystemTracesProvider({ + producers: [a], + scheduler: fs.scheduler, + now: () => t, + refreshMs: 10, + }); + p.init(buildInit(store)); + p.start(options); + p.stop(); + a.seed.length = 0; + t = 100; + p.start(options); + t = 200; + fs.tick(); + expect((await values(store)).map((v) => v.timestamp)).toEqual([0]); + }); + + it('stop() clears the snapshot: a stray tick after stop does not refresh', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let t = 0; + const a = new Fixed('a', [['x', 1]], { held: true }); + const p = createSystemTracesProvider({ + producers: [a], + scheduler: fs.scheduler, + now: () => t, + refreshMs: 10, + }); + p.init(buildInit(store)); + p.start(options); + p.stop(); + t = 100; + fs.tick(); + expect(await values(store)).toHaveLength(1); + }); + + it('a failed start (initial sample threw) leaves no stale snapshot for the next start', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let t = 0; + let fail = true; + const a = new Fixed('a', [['x', 1]], { held: true }); + const p = createSystemTracesProvider({ + producers: [a], + sample: () => { + if (fail) throw new Error('boom'); + return []; + }, + scheduler: fs.scheduler, + now: () => t, + refreshMs: 10, + }); + p.init(buildInit(store)); + expect(() => p.start(options)).toThrow('boom'); + fail = false; + a.seed.length = 0; + t = 100; + p.start(options); + t = 200; + fs.tick(); + expect((await values(store)).map((v) => v.timestamp)).toEqual([0]); + }); + + it('drops an emit from a producer that kept the sink past stop() (provider-side guard)', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let kept: TraceSink | undefined; + const rogue: TraceProducer = { + name: 'rogue', + start: (sink) => { + kept = sink; + }, + stop: () => {}, + }; + const p = createSystemTracesProvider({ + producers: [rogue], + scheduler: fs.scheduler, + now: () => 1, + }); + p.init(buildInit(store)); + p.start(options); + p.stop(); + kept?.emit('x', 1); + expect(await values(store)).toEqual([]); + }); + + it('without onError a producer failure is swallowed silently (default no-op reporter)', () => { + const fs = fakeScheduler(); + const bad = new Fixed('bad', [], { throwStart: true }); + const p = createSystemTracesProvider({ producers: [bad], scheduler: fs.scheduler }); + p.init(buildInit(mkStore())); + expect(() => p.start(options)).not.toThrow(); + expect(bad.stoppedCount).toBe(1); + }); + + it('the timer never lets an exception escape: a throwing legacy sample() goes to onError', () => { + const fs = fakeScheduler(); + const errors: unknown[] = []; + let n = 0; + const p = createSystemTracesProvider({ + sample: () => { + n += 1; + if (n > 1) throw new Error('tick boom'); + return []; + }, + scheduler: fs.scheduler, + onError: (e) => errors.push(e), + }); + p.init(buildInit(mkStore())); + p.start(options); + expect(() => fs.tick()).not.toThrow(); + expect((errors[0] as Error).message).toBe('tick boom'); + }); + + it('a throwing INITIAL legacy sample stops the started producers and rethrows (launch routes it to onError)', () => { + const fs = fakeScheduler(); + const a = new Fixed('a', []); + const p = createSystemTracesProvider({ + producers: [a], + sample: () => { + throw new Error('sample boom'); + }, + scheduler: fs.scheduler, + }); + p.init(buildInit(mkStore())); + expect(() => p.start(options)).toThrow('sample boom'); + expect(a.stoppedCount).toBe(1); + expect(fs.interval()).toBe(0); + }); + + it('legacy: `sample` alone still behaves exactly as before (initial snapshot + interval)', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + const p = createSystemTracesProvider({ + sample: () => [{ name: 'm', value: 1 }], + scheduler: fs.scheduler, + now: () => 5, + }); + p.init(buildInit(store)); + p.start(options); + fs.tick(); + expect(await values(store)).toHaveLength(2); + expect(fs.interval()).toBe(1000); + }); + + it('requires producers or sample (a provider with neither is a programming error)', () => { + expect(() => createSystemTracesProvider({} as never)).toThrow(/producers or sample/); + }); +}); + +describe('createSystemTracesProvider — hardening', () => { + it('a throwing legacy sample() does not stop the refresh of held values on the same tick', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + const errors: unknown[] = []; + let t = 0; + let n = 0; + const a = new Fixed('a', [['battery', 50]], { held: true }); + const p = createSystemTracesProvider({ + producers: [a], + sample: () => { + n += 1; + if (n > 1) throw new Error('sample boom'); + return []; + }, + scheduler: fs.scheduler, + now: () => t, + refreshMs: 10, + onError: (e) => errors.push(e), + }); + p.init(buildInit(store)); + p.start(options); + t = 10; + fs.tick(); + expect((errors[0] as Error).message).toBe('sample boom'); + expect((await values(store)).map((v) => [v.name, v.timestamp])).toEqual([ + ['battery', 0], + ['battery', 10], + ]); + }); + + it('a throwing scheduler.setInterval on start unwinds like a failed initial sample (stop producers, not running, rethrow)', async () => { + const store = mkStore(); + let refuse = true; + let cb: (() => void) | undefined; + const scheduler: Scheduler = { + setInterval: (c) => { + if (refuse) throw new Error('no timers'); + cb = c; + return 'h'; + }, + clearInterval: () => {}, + }; + let t = 0; + const a = new Fixed('a', [['x', 1]], { held: true }); + const p = createSystemTracesProvider({ + producers: [a], + scheduler, + now: () => t, + refreshMs: 10, + }); + p.init(buildInit(store)); + expect(() => p.start(options)).toThrow('no timers'); + expect(a.stoppedCount).toBe(1); + a.push('x', 2); // the producer is stopped: dropped + refuse = false; + a.seed.length = 0; + t = 100; + p.start(options); + t = 200; + cb?.(); + // only the first baseline exists; nothing held from the failed start is refreshed + expect((await values(store)).map((v) => [v.value, v.timestamp])).toEqual([[1, 0]]); + }); + + it('the timer never lets an exception escape: a throwing clock on a tick goes to onError', () => { + const fs = fakeScheduler(); + const errors: unknown[] = []; + let calls = 0; + const p = createSystemTracesProvider({ + producers: [new Fixed('a', [])], + scheduler: fs.scheduler, + now: () => { + calls += 1; + if (calls > 1) throw new Error('clock boom'); + return 0; + }, + onError: (e) => errors.push(e), + }); + p.init(buildInit(mkStore())); + p.start(options); + expect(() => fs.tick()).not.toThrow(); + expect((errors[0] as Error).message).toBe('clock boom'); + }); + + it('a tick that fires after stop() records nothing (not running)', async () => { + const store = mkStore(); + const fs = fakeScheduler(); + let t = 0; + const p = createSystemTracesProvider({ + sample: () => [{ name: 'm', value: 1 }], + scheduler: fs.scheduler, + now: () => t, + }); + p.init(buildInit(store)); + p.start(options); + p.stop(); + t = 5; + fs.tick(); + expect((await values(store)).map((v) => v.timestamp)).toEqual([0]); + }); + + it('one timer per start and one clearInterval per stop across restarts', () => { + let sets = 0; + let cleared = 0; + const handles: unknown[] = []; + const scheduler: Scheduler = { + setInterval: () => { + sets += 1; + const h = `h${sets}`; + handles.push(h); + return h; + }, + clearInterval: (h) => { + cleared += 1; + expect(h).toBe(handles[cleared - 1]); // clears exactly the handle that start registered + }, + }; + const p = createSystemTracesProvider({ producers: [new Fixed('a', [])], scheduler }); + p.init(buildInit(mkStore())); + p.start(options); + expect([sets, cleared]).toEqual([1, 0]); + p.stop(); + expect([sets, cleared]).toEqual([1, 1]); + p.start(options); + expect([sets, cleared]).toEqual([2, 1]); + p.stop(); + expect([sets, cleared]).toEqual([2, 2]); + }); +}); + +describe('deriveRefreshMs', () => { + it.each([ + [600_000, 10_000], // 150 s clamped down to the 10 s ceiling + [40_000, 10_000], // exactly the ceiling + [20_000, 5_000], // a quarter of the window + [8_000, 2_000], // exactly the floor + [2_000, 2_000], // 0.5 s clamped up to 2 x the 1 s provider interval + [1, 2_000], + ])('window %d ms -> %d ms', (windowMs, expected) => { + expect(deriveRefreshMs(windowMs)).toBe(expected); + }); + + it.each([ + 0, + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ])('a non-finite or non-positive window (%d) falls back to the 10 s default', (windowMs) => { + expect(deriveRefreshMs(windowMs)).toBe(10_000); + }); +}); diff --git a/packages/capture/src/system-traces-provider.ts b/packages/capture/src/system-traces-provider.ts index 5d9b6cd5..f57f4ce1 100644 --- a/packages/capture/src/system-traces-provider.ts +++ b/packages/capture/src/system-traces-provider.ts @@ -1,5 +1,6 @@ import { type CaptureProvider, CaptureProviderBase, type Scheduler } from '@bugsee/core'; import { BugseeOption } from '@bugsee/protocol'; +import type { TraceProducer, TraceSink } from './trace-producer'; // Runtime-agnostic SYSTEM TRACES provider (Android traces.system parity). A trace is a // named value sampled over TIME. While started, it periodically calls an injected `sample()` and routes @@ -29,47 +30,171 @@ const defaultScheduler: Scheduler = { }; export interface SystemTracesProviderOptions { - /** Reads the current system traces (the runtime-specific seam — e.g. node memory/cpu). Required. */ - sample: () => readonly TraceSample[]; - /** Sampling interval in ms while started. Default 1000. */ + /** Trace producers (each subscribes to trackers; the provider owns start/stop + isolation). */ + producers?: readonly TraceProducer[]; + /** LEGACY polled seam: read every system trace on each tick. Kept so existing samplers/tests keep working. */ + sample?: () => readonly TraceSample[]; + /** Tick interval in ms while started. Default 1000. */ intervalMs?: number; - /** Scheduler for the sampling timer; injectable for tests/edge. Default global timers (unref'd). */ + /** Scheduler for the tick timer; injectable for tests/edge. Default global timers (unref'd). */ scheduler?: Scheduler; /** Wall-clock source for the entry timestamp; injectable for tests. Default Date.now. */ now?: () => number; + /** + * A HELD value (emitted with `hold: true`, i.e. a producer's `emitIfChanged`) older than this is + * re-recorded so a rolling capture window never loses a rarely-changing trace. Polled values (`emit`, + * `hold: false`) and legacy `sample()` rows are never refreshed. Default 10 000; Infinity disables. + */ + refreshMs?: number; + /** Reports an isolated producer/tick failure. Must not throw. */ + onError?: (error: unknown) => void; +} + +const DEFAULT_INTERVAL_MS = 1000; +const DEFAULT_REFRESH_MS = 10_000; + +/** + * The `refreshMs` a launch derives from its rolling recording window (ms): a quarter of the window, so a + * held value appears several times in any captured slice, clamped to [2 x the default 1 s provider + * interval, 10 s]. The floor keeps a refresh from firing on every tick; a non-finite or non-positive + * window (a misconfigured `maxRecordingTime`) falls back to the 10 s default. ONE definition, shared by + * the node, browser and webview launches. + */ +export function deriveRefreshMs(recordingWindowMs: number): number { + if (!Number.isFinite(recordingWindowMs) || recordingWindowMs <= 0) return DEFAULT_REFRESH_MS; + return Math.min(DEFAULT_REFRESH_MS, Math.max(2 * DEFAULT_INTERVAL_MS, recordingWindowMs / 4)); +} + +interface Held { + name: string; + value: unknown; + timestamp: number; + displayId: number | undefined; } class SystemTracesProvider extends CaptureProviderBase { readonly name = 'traces.system'; readonly controllingOption = BugseeOption.CaptureSystemTraces; - readonly #sample: () => readonly TraceSample[]; + readonly #producers: readonly TraceProducer[]; + readonly #sample: (() => readonly TraceSample[]) | undefined; readonly #intervalMs: number; + readonly #refreshMs: number; readonly #scheduler: Scheduler; readonly #now: () => number; + readonly #onError: (error: unknown) => void; + readonly #held = new Map(); + #started: TraceProducer[] = []; + #running = false; #timer: unknown = null; + readonly #sink: TraceSink = { + emit: (name, value, displayId, options) => { + if (this.#running) this.#record(name, value, displayId, this.#now(), options?.hold === true); + }, + }; constructor(options: SystemTracesProviderOptions) { super(); + if (options.producers === undefined && options.sample === undefined) { + throw new Error('createSystemTracesProvider needs producers or sample'); + } + this.#producers = options.producers ?? []; this.#sample = options.sample; - this.#intervalMs = options.intervalMs ?? 1000; + this.#intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS; + this.#refreshMs = options.refreshMs ?? DEFAULT_REFRESH_MS; this.#scheduler = options.scheduler ?? defaultScheduler; this.#now = options.now ?? (() => Date.now()); + this.#onError = options.onError ?? (() => {}); } protected onStart(): void { - this.#emit(); // initial snapshot, so a short-lived session still has current values - this.#timer = this.#scheduler.setInterval(() => this.#emit(), this.#intervalMs); + this.#held.clear(); + this.#running = true; + for (const producer of this.#producers) { + try { + producer.start(this.#sink); + this.#started.push(producer); + } catch (error) { + this.#onError(error); + this.#safeStop(producer); + } + } + try { + this.#emitLegacy(this.#now()); // initial snapshot, so a short-lived session still has current values + this.#timer = this.#scheduler.setInterval(() => this.#tick(), this.#intervalMs); + } catch (error) { + // a failed start leaves nothing running: producers stopped and #running off (so no stray tick or + // late emit records); the snapshot is cleared by the next start + this.#stopProducers(); + this.#running = false; + throw error; + } } protected override onStop(): void { this.#scheduler.clearInterval(this.#timer); this.#timer = null; + this.#stopProducers(); + this.#running = false; + this.#held.clear(); } - #emit(): void { - const timestamp = this.#now(); + #tick(): void { + if (!this.#running) return; // a tick that slipped past stop() records nothing + try { + const now = this.#now(); + try { + this.#emitLegacy(now); + } catch (error) { + this.#onError(error); // a broken legacy sampler must not stop the held-value refresh + } + for (const held of [...this.#held.values()]) { + if (now - held.timestamp >= this.#refreshMs) { + this.#record(held.name, held.value, held.displayId, now, true); + } + } + } catch (error) { + this.#onError(error); // a timer must never throw into the host + } + } + + #emitLegacy(timestamp: number): void { + if (this.#sample === undefined) return; for (const { name, value } of this.#sample()) { - this.capture('traces.system', timestamp, { timestamp, name, value }); + this.#record(name, value, undefined, timestamp, false); // re-read every tick: never held + } + } + + #record( + name: string, + value: unknown, + displayId: number | undefined, + timestamp: number, + hold: boolean, + ): void { + this.capture('traces.system', timestamp, { + timestamp, + name, + value, + ...(displayId !== undefined ? { displayId } : {}), + }); + if (!hold) return; + this.#held.set(displayId === undefined ? name : `${displayId}:${name}`, { + name, + value, + timestamp, + displayId, + }); + } + + #stopProducers(): void { + for (const producer of this.#started.splice(0)) this.#safeStop(producer); + } + + #safeStop(producer: TraceProducer): void { + try { + producer.stop(); + } catch (error) { + this.#onError(error); } } } diff --git a/packages/capture/src/trace-producer.test.ts b/packages/capture/src/trace-producer.test.ts new file mode 100644 index 00000000..452cd860 --- /dev/null +++ b/packages/capture/src/trace-producer.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest'; +import { TraceProducerBase, type TraceSink } from './trace-producer'; + +type Emitted = { name: string; value: unknown; displayId?: number }; +const mkSink = (): { sink: TraceSink; got: Emitted[]; holds: unknown[] } => { + const got: Emitted[] = []; + const holds: unknown[] = []; + return { + got, + holds, + sink: { + emit: (name, value, displayId, options) => { + got.push({ name, value, ...(displayId !== undefined ? { displayId } : {}) }); + holds.push(options?.hold); + }, + }, + }; +}; + +class Probe extends TraceProducerBase { + readonly name = 'probe'; + starts = 0; + stops = 0; + emitAll: (n: string, v: unknown, d?: number) => void = (n, v, d) => this.emit(n, v, d); + emitChanged: (n: string, v: unknown, d?: number) => void = (n, v, d) => + this.emitIfChanged(n, v, d); + protected onStart(): void { + this.starts += 1; + } + protected override onStop(): void { + this.stops += 1; + } +} + +describe('TraceProducerBase', () => { + it('start() is idempotent and calls onStart once', () => { + const p = new Probe(); + const { sink } = mkSink(); + p.start(sink); + p.start(sink); + expect(p.starts).toBe(1); + }); + + it('stop() is idempotent, a no-op before start, and calls onStop once', () => { + const p = new Probe(); + const { sink } = mkSink(); + p.stop(); + expect(p.stops).toBe(0); + p.start(sink); + p.stop(); + p.stop(); + expect(p.stops).toBe(1); + }); + + it('emit() always forwards; after stop() it is dropped (late callbacks cannot write)', () => { + const p = new Probe(); + const { sink, got } = mkSink(); + p.start(sink); + p.emitAll('a', 1); + p.emitAll('a', 1); + p.stop(); + p.emitAll('a', 2); + expect(got).toEqual([ + { name: 'a', value: 1 }, + { name: 'a', value: 1 }, + ]); + }); + + it('emitIfChanged() drops equal consecutive values per name+display (primitives)', () => { + const p = new Probe(); + const { sink, got } = mkSink(); + p.start(sink); + p.emitChanged('a', 1); + p.emitChanged('a', 1); + p.emitChanged('a', 2); + p.emitChanged('a', 1); + expect(got.map((g) => g.value)).toEqual([1, 2, 1]); + }); + + it('emitIfChanged() compares objects structurally', () => { + const p = new Probe(); + const { sink, got } = mkSink(); + p.start(sink); + p.emitChanged('c', { state: 2 }); + p.emitChanged('c', { state: 2 }); + p.emitChanged('c', { state: 3 }); + expect(got.map((g) => g.value)).toEqual([{ state: 2 }, { state: 3 }]); + }); + + it('emitIfChanged() tracks each name and displayId separately', () => { + const p = new Probe(); + const { sink, got } = mkSink(); + p.start(sink); + p.emitChanged('a', 1); + p.emitChanged('b', 1); + p.emitChanged('a', 1, 0); + p.emitChanged('a', 1, 0); + expect(got).toEqual([ + { name: 'a', value: 1 }, + { name: 'b', value: 1 }, + { name: 'a', value: 1, displayId: 0 }, + ]); + }); + + it('a restart clears the dedup state, so the baseline re-emits', () => { + const p = new Probe(); + const { sink, got } = mkSink(); + p.start(sink); + p.emitChanged('a', 1); + p.stop(); + p.start(sink); + p.emitChanged('a', 1); + expect(got).toHaveLength(2); + }); + + it('emit() marks the value NOT held (a polled value must not be refreshed by the provider)', () => { + const p = new Probe(); + const { sink, holds } = mkSink(); + p.start(sink); + p.emitAll('cpu', 1); + p.emitAll('cpu', 1, 0); + expect(holds).toEqual([false, false]); + }); + + it('emitIfChanged() marks the value HELD (a change-driven value is refreshed by the provider)', () => { + const p = new Probe(); + const { sink, holds } = mkSink(); + p.start(sink); + p.emitChanged('battery', 50); + p.emitChanged('battery', 50); // dropped: no second emit + p.emitChanged('battery', 40, 0); + expect(holds).toEqual([true, true]); + }); + + it('stop() still drops the sink when onStop throws', () => { + class Throwing extends Probe { + protected override onStop(): void { + throw new Error('boom'); + } + } + const p = new Throwing(); + const { sink, got } = mkSink(); + p.start(sink); + expect(() => p.stop()).toThrow('boom'); + p.emitAll('a', 1); // dropped + expect(got).toEqual([]); + }); + + it('onStop() defaults to a no-op for producers that do not override it', () => { + class Minimal extends TraceProducerBase { + readonly name = 'minimal'; + protected onStart(): void {} + } + const p = new Minimal(); + const { sink } = mkSink(); + p.start(sink); + expect(() => p.stop()).not.toThrow(); + }); +}); diff --git a/packages/capture/src/trace-producer.ts b/packages/capture/src/trace-producer.ts new file mode 100644 index 00000000..61f59447 --- /dev/null +++ b/packages/capture/src/trace-producer.ts @@ -0,0 +1,99 @@ +// A TRACE PRODUCER (Android BugseeCaptureValueProducer / iOS BGSCaptureSystemTraceProducer parity): the unit +// that turns a tracker's aggregated state into named `traces.system` values. It SUBSCRIBES to one or more +// trackers in onStart() and unsubscribes in onStop(); trackers attach/release their OS resources by +// subscriber presence, so a producer that is never started costs nothing. The provider hands every +// producer a sink; the producer never talks to the aggregator itself. + +/** Per-emit options for {@link TraceSink.emit}. */ +export interface TraceEmitOptions { + /** + * `true` = a CHANGE-DRIVEN value the producer only re-emits when it changes (battery, charging, …): + * the provider keeps it as the last value and re-records it every `refreshMs` so a rolling capture + * window never loses it. Anything else (absent / `false`) = a POLLED value (cpu, memory, lag, ELU) that + * is emitted every tick and is NEVER refreshed — if its poll fails, the trace simply stops, rather than + * the provider fabricating a flat line at the last good reading. + */ + hold?: boolean; +} + +/** Where a producer sends its values (the provider stamps the time and keeps the last-value snapshot). */ +export interface TraceSink { + /** + * Record one trace sample. `displayId` scopes it to a display; absent = device-wide. Only an emit with + * `options.hold === true` is kept for the provider's last-value refresh (see {@link TraceEmitOptions}). + */ + emit(name: string, value: unknown, displayId?: number, options?: TraceEmitOptions): void; +} + +export interface TraceProducer { + /** Producer id, for diagnostics. */ + readonly name: string; + /** Subscribe to trackers and emit a baseline. Must be safe to call again after stop(). */ + start(sink: TraceSink): void; + /** Unsubscribe from everything start() attached. Idempotent; a no-op before start(). */ + stop(): void; +} + +const sameValue = (a: unknown, b: unknown): boolean => + Object.is(a, b) || + (typeof a === 'object' && + a !== null && + typeof b === 'object' && + b !== null && + JSON.stringify(a) === JSON.stringify(b)); + +const keyOf = (name: string, displayId: number | undefined): string => + displayId === undefined ? name : `${displayId}:${name}`; + +export abstract class TraceProducerBase implements TraceProducer { + abstract readonly name: string; + #sink: TraceSink | undefined; + readonly #last = new Map(); + + start(sink: TraceSink): void { + if (this.#sink !== undefined) return; + this.#sink = sink; + this.#last.clear(); + this.onStart(); + } + + stop(): void { + if (this.#sink === undefined) return; + try { + this.onStop(); + } finally { + this.#sink = undefined; // late tracker callbacks after stop() must not write + this.#last.clear(); + } + } + + /** Subscribe to trackers (and emit the baseline) here. */ + protected abstract onStart(): void; + /** Unsubscribe here. */ + protected onStop(): void {} + + /** + * Always emit (polled families: cpu / memory emit every tick, like Android). Sent with `hold: false`: + * the provider never refreshes a polled value, so a failed poll leaves a gap instead of a frozen line. + */ + protected emit(name: string, value: unknown, displayId?: number): void { + this.#send(name, value, displayId, false); + } + + /** + * Emit only when the value differs from the last emitted for this name+display (iOS emitIfChangedName). + * Sent with `hold: true`: the provider re-records the held value every `refreshMs`, because an unchanged + * value is deliberately not re-emitted here. + */ + protected emitIfChanged(name: string, value: unknown, displayId?: number): void { + const key = keyOf(name, displayId); + if (this.#last.has(key) && sameValue(this.#last.get(key), value)) return; + this.#send(name, value, displayId, true); + } + + #send(name: string, value: unknown, displayId: number | undefined, hold: boolean): void { + if (this.#sink === undefined) return; + this.#sink.emit(name, value, displayId, { hold }); + this.#last.set(keyOf(name, displayId), value); + } +} diff --git a/packages/deno/src/index.ts b/packages/deno/src/index.ts index e31686e2..817b0f83 100644 --- a/packages/deno/src/index.ts +++ b/packages/deno/src/index.ts @@ -1,7 +1,8 @@ // @bugsee/deno — Deno platform. Deno is node-API-compatible, so this package IS @bugsee/node with // two Deno-specific overrides: launch()/launchCore() default the system probe (platform.type 'deno' + Deno -// version) and the guarded system-metrics sampler (partial perf_hooks). The full node surface is re-exported; -// the explicit launch/launchCore exports below shadow node's same-named exports. +// version) and the system trace producers (createNodeSystemTraceProducers over partial perf_hooks, with the +// event-loop lag opted out). The full node surface is re-exported; the explicit launch/launchCore exports below +// shadow node's same-named exports. export * from '@bugsee/node'; export { diff --git a/packages/deno/src/launch.test.ts b/packages/deno/src/launch.test.ts index a6a4e2fb..347c5fef 100644 --- a/packages/deno/src/launch.test.ts +++ b/packages/deno/src/launch.test.ts @@ -1,19 +1,16 @@ import process from 'node:process'; import { type Clock, + createCaptureExporter, createMemoryCaptureStore, type HttpRequestOptions, type HttpResponse, type HttpTransport, type Scheduler, } from '@bugsee/core'; -import { - createGuardedSystemMetricsSampler, - type NodeRuntime, - type SystemProbe, -} from '@bugsee/node'; +import { createNodeSystemTraceProducers, type NodeRuntime, type SystemProbe } from '@bugsee/node'; import { strFromU8, unzipSync } from '@bugsee/util'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { denoSystemProbe } from './environment'; import { launch, launchCore } from './launch'; @@ -25,7 +22,7 @@ vi.mock('@bugsee/node', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - createGuardedSystemMetricsSampler: vi.fn(actual.createGuardedSystemMetricsSampler), + createNodeSystemTraceProducers: vi.fn(actual.createNodeSystemTraceProducers), }; }); @@ -92,6 +89,12 @@ const base = (over: Partial[1]> = {}): Parameters { + vi.mocked(createNodeSystemTraceProducers).mockClear(); +}); + const clients: Array<{ stop: (t?: number) => Promise }> = []; afterEach(async () => { await Promise.all(clients.splice(0).map((c) => c.stop())); @@ -275,8 +278,108 @@ describe('@bugsee/deno — the event-loop metrics Deno cannot measure', () => { // which gives itself away with a zero `idle`. A lag of ~0 is exactly what a healthy process reports, // which is why fabricating one is worse than reporting nothing. track(launchCore('tok', base()).client); - expect(createGuardedSystemMetricsSampler).toHaveBeenCalledWith( + expect(createNodeSystemTraceProducers).toHaveBeenCalledTimes(1); + expect(createNodeSystemTraceProducers).toHaveBeenCalledWith( expect.objectContaining({ measuresEventLoopDelay: false }), ); }); + + it('injects the node trace producers (not a legacy sampler) and they feed traces.system', async () => { + vi.mocked(createNodeSystemTraceProducers).mockClear(); + const store = memStore(); + track(launchCore('tok', base({ captureStore: store, captureSystemTraces: true })).client); + const produced = vi.mocked(createNodeSystemTraceProducers).mock.results[0]?.value as unknown[]; + expect(Array.isArray(produced)).toBe(true); + expect(produced.length).toBeGreaterThan(0); + for (const p of produced) { + expect(typeof (p as { start: unknown }).start).toBe('function'); + expect(typeof (p as { stop: unknown }).stop).toBe('function'); + } + const traces = (await createCaptureExporter(store).drain()).get('traces.system') ?? []; + const names = traces.map((e) => (e.data as { name: string }).name); + expect(names).toContain('process_memory_rss'); + expect(names).toContain('ram_system_total'); + expect(names).toContain('cpu_usage_process'); + // Deno cannot observe a blocked loop, so no lag trace is fabricated. + expect(names.some((n) => n.startsWith('event_loop_lag'))).toBe(false); + }); +}); + +describe('@bugsee/deno — the launch scheduler / onError reach the trace tracker', () => { + const recordingScheduler = () => { + const calls: Array<{ cb: () => void; ms: number }> = []; + const scheduler: Scheduler = { + setInterval: (cb, ms) => { + calls.push({ cb, ms }); + return `h${calls.length}`; + }, + clearInterval: () => {}, + }; + return { calls, scheduler }; + }; + + it('forwards the launch scheduler and onError into the producer factory', () => { + vi.mocked(createNodeSystemTraceProducers).mockClear(); + const { scheduler } = recordingScheduler(); + const onError = vi.fn(); + track(launchCore('tok', base({ scheduler, onError })).client); + expect(createNodeSystemTraceProducers).toHaveBeenCalledTimes(1); + const arg = vi.mocked(createNodeSystemTraceProducers).mock.calls[0]?.[0]; + expect(arg?.scheduler).toBe(scheduler); + expect(arg?.onError).toBe(onError); + }); + + it('sampling runs on the injected scheduler: a fake tick produces a stats emit (no real timer)', async () => { + const { calls, scheduler } = recordingScheduler(); + const store = memStore(); + track( + launchCore('tok', base({ scheduler, captureStore: store, captureSystemTraces: true })).client, + ); + const rss = async () => + ((await createCaptureExporter(store).drain()).get('traces.system') ?? []).filter( + (e) => (e.data as { name: string }).name === 'process_memory_rss', + ).length; + expect(await rss()).toBe(1); // the start baseline + for (const c of calls.filter((x) => x.ms === 1000)) c.cb(); + expect(await rss()).toBe(2); + }); + + it('a throwing reader during a poll reaches options.onError', () => { + const { calls, scheduler } = recordingScheduler(); + const onError = vi.fn(); + track(launchCore('tok', base({ scheduler, onError, captureSystemTraces: true })).client); + const spy = vi.spyOn(process, 'memoryUsage').mockImplementation(() => { + throw new Error('memoryUsage boom'); + }); + try { + for (const c of calls.filter((x) => x.ms === 1000)) c.cb(); + } finally { + spy.mockRestore(); + } + expect(onError.mock.calls.map((c) => (c[0] as Error).message)).toContain('memoryUsage boom'); + }); +}); + +describe('@bugsee/deno — captureSystemTraces:false attaches no trace sampling', () => { + it('no sampling timer, no trace entries; enabling adds exactly the provider tick + the tracker timer', async () => { + const launchWith = (captureSystemTraces: boolean) => { + const calls: number[] = []; + const store = memStore(); + const scheduler: Scheduler = { + setInterval: (_cb, ms) => { + calls.push(ms); + return `h${calls.length}`; + }, + clearInterval: () => {}, + }; + track( + launchCore('tok', base({ scheduler, captureStore: store, captureSystemTraces })).client, + ); + return { store, oneSecond: calls.filter((ms) => ms === 1000).length }; + }; + const off = launchWith(false); + const on = launchWith(true); + expect(on.oneSecond - off.oneSecond).toBe(2); + expect((await createCaptureExporter(off.store).drain()).get('traces.system')).toBeUndefined(); + }); }); diff --git a/packages/deno/src/launch.ts b/packages/deno/src/launch.ts index 7709a159..ff6cb95d 100644 --- a/packages/deno/src/launch.ts +++ b/packages/deno/src/launch.ts @@ -2,7 +2,7 @@ import { getCarrierClient } from '@bugsee/core'; import { type Bugsee, type BugseeLaunchOptions, - createGuardedSystemMetricsSampler, + createNodeSystemTraceProducers, type LaunchResult, launchCore as nodeLaunchCore, } from '@bugsee/node'; @@ -13,7 +13,7 @@ import { denoSystemProbe } from './environment'; // node:fs, node:process, node:worker_threads, node:inspector), so this reuses the ENTIRE @bugsee/node // composition (transport, fs storage, node:http capture, crash detection, durable queue + capture recovery, // CPU profiling, ANR/hang detection) and only swaps the two Deno-specific defaults: the system probe (reports -// platform.type 'deno' + the Deno version) and the guarded system-metrics sampler (partial perf_hooks). +// platform.type 'deno' + the Deno version) and the guarded system trace producers (partial perf_hooks). // Diagnostics that touch a partially-supported API (node:inspector / worker_threads) are capability-guarded // in the node tier, so they self-disable where Deno lacks them. Both defaults remain overridable (a // caller-supplied option spreads AFTER them). @@ -26,7 +26,12 @@ export function launchCore(appToken: string, options: BugseeLaunchOptions = {}): // 160.956ms, bun 146.634ms, deno 0.065ms. Reporting 0.065ms through a 150ms freeze reads as a // healthy process, so the lag traces are omitted here rather than fabricated. ELU self-disables on // its own tell (a zero `idle`), so it needs no flag. - systemMetricsSampler: createGuardedSystemMetricsSampler({ measuresEventLoopDelay: false }), + systemTraceProducers: createNodeSystemTraceProducers({ + measuresEventLoopDelay: false, + // the shared tracker's sampling timer honors an injected scheduler, and its poll errors are reported + ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), + ...(options.onError !== undefined ? { onError: options.onError } : {}), + }), ...options, // LOCAL VARIABLES ARE OFF HERE, and deliberately AFTER `...options` so a caller cannot switch them // back on. This is not a preference: with `includeCaught` Deno FREEZES. Measured with the real SDK diff --git a/packages/node/src/index.test.ts b/packages/node/src/index.test.ts index 07c3fde2..0585f641 100644 --- a/packages/node/src/index.test.ts +++ b/packages/node/src/index.test.ts @@ -45,6 +45,8 @@ const EXPECTED_EXPORTS = [ 'createNodeRequestContextStore', 'createNodeSystemEventsSource', 'createNodeSystemMetricsSampler', + 'createNodeSystemTraceProducers', + 'ProcessStatsTracker', 'createProfilingController', 'createRequestScopedActiveSpanStore', 'createUncaughtExceptionProvider', diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 8babc185..1e56e94e 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -122,3 +122,9 @@ export { } from './server-instrument'; export { createNodeSystemEventsSource } from './system-events'; export { createNodeSystemMetricsSampler, type NodeSystemMetricsDeps } from './system-metrics'; +export { createNodeSystemTraceProducers, type NodeTraceProducerDeps } from './trace-producers'; +export { + type EventLoopSource, + type ProcessStats, + ProcessStatsTracker, +} from './trackers/process-stats-tracker'; diff --git a/packages/node/src/launch.test.ts b/packages/node/src/launch.test.ts index e260094c..bf0d4491 100644 --- a/packages/node/src/launch.test.ts +++ b/packages/node/src/launch.test.ts @@ -14,6 +14,7 @@ import https from 'node:https'; import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import type { TraceProducer } from '@bugsee/capture'; import { type BundleStore, BundleStoreToken, @@ -64,6 +65,7 @@ import { type SystemProbe, SystemProbeToken } from './environment'; import type { HangLevel } from './event-loop-watchdog'; import { type BugseeLaunchOptions, launch, launchCore, type NodeRuntime } from './launch'; import { createNodeRequestContextStore, RequestContextStoreToken } from './request-context-store'; +import { createNodeSystemTraceProducers } from './trace-producers'; /** A test-only contributed service token (an "extension"). */ const DemoExtToken = serviceToken<{ storeIsRegistered: boolean }>('demoExt'); @@ -1367,6 +1369,197 @@ function seedPriorGeneration( } } +describe('launch — system trace producers', () => { + const producerDeps = (fs: ReturnType) => { + const calls: string[] = []; + const perfHooks = { + monitorEventLoopDelay: () => { + calls.push('monitor'); + return { + enable: () => void calls.push('enable'), + disable: () => void calls.push('disable'), + reset() {}, + mean: 0, + max: 0, + percentile: () => 0, + }; + }, + eventLoopUtilization: () => ({ utilization: 0, idle: 1, active: 0 }), + }; + return { calls, deps: { perfHooks, scheduler: fs.scheduler } }; + }; + + it.each([ + { label: 'lag measured', over: {} }, + { label: 'lag opted out (Deno)', over: { measuresEventLoopDelay: false } }, + ])('the producers ($label) attach nothing when captureSystemTraces is false, and release everything on stop()', async ({ + over, + }) => { + const fs = fakeScheduler(); + const trackerTimers = fakeScheduler(); // the shared tracker's OWN scheduler, separate from the launch's + const { calls, deps } = producerDeps(trackerTimers); + const lagMeasured = !('measuresEventLoopDelay' in over); + const off = launchTracked( + 'tok', + baseOptions({ + captureStore: memStore(), + clock: fixedClock, + scheduler: fs.scheduler, + captureSystemTraces: false, + systemMetricsSampler: undefined, + systemTraceProducers: createNodeSystemTraceProducers({ ...deps, ...over }), + }), + ); + expect(calls).toEqual([]); + expect(trackerTimers.calls).toHaveLength(0); // no sampling timer registered + await off.stop(); + expect(calls).toEqual([]); + expect(trackerTimers.calls).toHaveLength(0); + const on = launchTracked( + 'tok', + baseOptions({ + captureStore: memStore(), + clock: fixedClock, + scheduler: fs.scheduler, + systemMetricsSampler: undefined, + systemTraceProducers: createNodeSystemTraceProducers({ ...deps, ...over }), + }), + ); + expect(calls).toEqual(lagMeasured ? ['monitor', 'enable'] : []); + expect(trackerTimers.calls.map((c) => c.ms)).toEqual([1000]); // exactly one sampling timer + await on.stop(); + expect(calls.at(-1)).toBe(lagMeasured ? 'disable' : undefined); + }); + + it.each([ + { maxRecordingTime: 20, refreshMs: 5000 }, // 20s/4 = 5s + { maxRecordingTime: 8, refreshMs: 2000 }, // 8s/4 = 2s, exactly the floor + { maxRecordingTime: 2, refreshMs: 2000 }, // 0.5s clamped up to the 2s floor (2 x the 1s interval) + { maxRecordingTime: 600, refreshMs: 10_000 }, // 150s clamped down to 10s + ])('refreshes a held trace after $refreshMs ms (recording window $maxRecordingTime s)', async ({ + maxRecordingTime, + refreshMs, + }) => { + vi.useFakeTimers({ toFake: ['Date'] }); + try { + vi.setSystemTime(0); + const fs = fakeScheduler(); + const store = memStore(); + const once: TraceProducer = { + name: 'once', + start: (sink) => sink.emit('held_trace', 1, undefined, { hold: true }), + stop: () => {}, + }; + launchTracked( + 'tok', + baseOptions({ + captureStore: store, + scheduler: fs.scheduler, + maxRecordingTime, + systemMetricsSampler: undefined, + systemTraceProducers: [once], + }), + ); + const tick = fs.calls.find((c) => c.ms === 1000)?.cb; + const count = async () => + ((await drain(store, 'traces.system')) ?? []).filter( + (e) => (e.data as { name: string }).name === 'held_trace', + ).length; + expect(await count()).toBe(1); + vi.setSystemTime(refreshMs - 1); + tick?.(); + expect(await count()).toBe(1); + vi.setSystemTime(refreshMs); + tick?.(); + expect(await count()).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it('the DEFAULT producers sample through the injected scheduler (no real timer)', async () => { + const fs = fakeScheduler(); + const store = memStore(); + launchTracked( + 'tok', + baseOptions({ + captureStore: store, + clock: fixedClock, + scheduler: fs.scheduler, + systemMetricsSampler: undefined, + }), + ); + const rss = async () => + ((await drain(store, 'traces.system')) ?? []).filter( + (e) => (e.data as { name: string }).name === 'process_memory_rss', + ).length; + expect(await rss()).toBe(1); // the start baseline + for (const t of fs.calls.filter((c) => c.ms === 1000)) t.cb(); // every 1 s SDK timer, once + expect(await rss()).toBe(2); // the tracker's poll, driven by the fake scheduler + }); + + it('a throwing reader during a DEFAULT-producer poll reaches options.onError', () => { + const fs = fakeScheduler(); + const onError = vi.fn(); + launchTracked( + 'tok', + baseOptions({ + captureStore: memStore(), + clock: fixedClock, + scheduler: fs.scheduler, + onError, + systemMetricsSampler: undefined, + }), + ); + const spy = vi.spyOn(process, 'memoryUsage').mockImplementation(() => { + throw new Error('memoryUsage boom'); + }); + try { + for (const t of fs.calls.filter((c) => c.ms === 1000)) t.cb(); + } finally { + spy.mockRestore(); + } + expect(onError.mock.calls.map((c) => (c[0] as Error).message)).toContain('memoryUsage boom'); + }); + + it('a legacy systemMetricsSampler still wins when supplied, and the producers are then not started', () => { + const start = vi.fn(); + const spy: TraceProducer = { name: 'spy', start, stop: () => {} }; + const client = launchTracked( + 'tok', + baseOptions({ + captureStore: memStore(), + systemMetricsSampler: () => [{ name: 'x', value: 1 }], + systemTraceProducers: [spy], + }), + ); + expect(client.isLaunched()).toBe(true); + expect(start).not.toHaveBeenCalled(); + }); + + it('routes a throwing producer to onError and still launches', () => { + const onError = vi.fn(); + const bad: TraceProducer = { + name: 'bad', + start: () => { + throw new Error('p boom'); + }, + stop: () => {}, + }; + const client = launchTracked( + 'tok', + baseOptions({ + captureStore: memStore(), + onError, + systemMetricsSampler: undefined, + systemTraceProducers: [bad], + }), + ); + expect(client.isLaunched()).toBe(true); + expect((onError.mock.calls[0]?.[0] as Error).message).toBe('p boom'); + }); +}); + describe('launch — capture recovery', () => { // The launch generation is fixedClock.wallNow() = 1000, so prior gens use 500 (≠ 1000). it('rebuilds + uploads a prior run’s detected incident, then sweeps its generation + marker', async () => { diff --git a/packages/node/src/launch.ts b/packages/node/src/launch.ts index 878ad3b1..f05323f4 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -6,8 +6,10 @@ import { createLogCaptureProvider, createSystemEventsProvider, createSystemTracesProvider, + deriveRefreshMs, installNetworkCapture, type NetworkCapture, + type TraceProducer, type TraceSample, } from '@bugsee/capture'; import { @@ -133,7 +135,7 @@ import type { TraceResponseConfig } from './server-instrument'; import { createSourceContextEnricher, type SourceContextOptions } from './source-context'; import { sweepAgedInstances } from './sweep-instances'; import { createNodeSystemEventsSource } from './system-events'; -import { createNodeSystemMetricsSampler } from './system-metrics'; +import { createNodeSystemTraceProducers } from './trace-producers'; import { buildTracePropagationDecorator } from './trace-propagation'; // @bugsee/node launch() — the Node composition root. It assembles the runtime-agnostic @@ -383,6 +385,18 @@ export interface BugseeLaunchOptions { hangWatchdogFactory?: (deps: EventLoopWatchdogDeps) => EventLoopWatchdog; /** System-traces sampler. Default the Node memory/cpu/event-loop sampler. */ systemMetricsSampler?: () => readonly TraceSample[]; + /** + * Trace producers (each subscribes to trackers; the provider starts/stops them). Default: the node set + * (`createNodeSystemTraceProducers`, wired to this launch's `scheduler`/`onError`). A supplied + * `systemMetricsSampler` wins over this. + * + * PER-LAUNCH ONLY: producers are stateful — a producer's `start()` is a no-op while it is already started — + * so never share one array across clients; build a fresh set for each launch (it is RESET on relaunch). + * On Bun/Deno a caller-supplied `systemTraceProducers` (or `systemMetricsSampler`) REPLACES the runtime's + * default set, including its gates (e.g. Deno's event-loop-lag opt-out), so pass + * `createNodeSystemTraceProducers({ measuresEventLoopDelay: false, … })` yourself there. + */ + systemTraceProducers?: readonly TraceProducer[]; /** Durable bundle store override; wins over dataDir/pending. Default fs-backed when dataDir is set. */ bundleStore?: BundleStore; /** Carrier host for the process-global interceptor singletons; injectable for tests. Default `globalThis`. */ @@ -863,7 +877,19 @@ function buildLaunch(appToken: string, options: BugseeLaunchOptions = {}): Launc } client.addCaptureProvider( createSystemTracesProvider({ - sample: options.systemMetricsSampler ?? createNodeSystemMetricsSampler(), + ...(options.systemMetricsSampler !== undefined + ? { sample: options.systemMetricsSampler } // legacy injector wins (tests) + : { + producers: + options.systemTraceProducers ?? + createNodeSystemTraceProducers({ + // the tracker's sampling timer honors an injected scheduler, and its poll errors are reported + ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), + ...(options.onError !== undefined ? { onError: options.onError } : {}), + }), + }), + refreshMs: deriveRefreshMs(maxRecordingTime * 1000), + ...(options.onError !== undefined ? { onError: options.onError } : {}), ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), }), ); diff --git a/packages/node/src/trace-producers.test.ts b/packages/node/src/trace-producers.test.ts new file mode 100644 index 00000000..72187a67 --- /dev/null +++ b/packages/node/src/trace-producers.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it } from 'vitest'; +import { createNodeSystemMetricsSampler } from './system-metrics'; +import { createNodeSystemTraceProducers, type NodeTraceProducerDeps } from './trace-producers'; + +function harness(deps: NodeTraceProducerDeps) { + const got = new Map(); + const spy = { set: 0, cleared: 0 }; + let tick: (() => void) | undefined; + const producers = createNodeSystemTraceProducers({ + ...deps, + scheduler: { + setInterval: (cb) => { + spy.set += 1; + tick = cb; + return 'h'; + }, + clearInterval: () => { + spy.cleared += 1; + }, + }, + }); + const sink = { emit: (n: string, v: unknown) => void got.set(n, v) }; + return { + got, + spy, + producers, + startAll: () => { + for (const p of producers) p.start(sink); + }, + stopAll: () => { + for (const p of producers) p.stop(); + }, + tick: () => tick?.(), + }; +} + +const constDeps = (over: NodeTraceProducerDeps = {}): NodeTraceProducerDeps => ({ + memoryUsage: () => ({ rss: 10, heapTotal: 20, heapUsed: 30, external: 40, arrayBuffers: 50 }), + systemMemory: () => ({ total: 1000, free: 400 }), + cpuUsage: () => ({ user: 100_000, system: 200_000 }), + cpuCount: () => 2, + monotonicNowMs: () => 1000, + ...over, +}); + +function fakePerfHooks( + elu: (a?: unknown, b?: unknown) => unknown = () => ({ idle: 5, active: 5, utilization: 0.3 }), +) { + const state = { monitor: 0, enabled: false, disables: 0 }; + const histogram = { + count: 3, // samples taken; 0 = nothing measured yet + enable() { + state.enabled = true; + }, + disable() { + state.enabled = false; + state.disables += 1; + }, + reset() {}, + mean: 2e6, + max: 9e6, + percentile: () => 7e6, + }; + return { + state, + histogram, + perfHooks: { + monitorEventLoopDelay: () => { + state.monitor += 1; + return histogram; + }, + eventLoopUtilization: elu as never, + }, + }; +} + +const eventLoop = { + read: () => ({ meanMs: 1, maxMs: 2, p99Ms: 3 }), + start: () => {}, + stop: () => {}, +}; + +describe('createNodeSystemTraceProducers', () => { + // Per-call constant increments: every read advances one fixed step, so whichever reader takes the + // baseline, each window is exactly one step — the producers and the legacy sampler see the SAME window. + const steppingCpu = () => { + let calls = 0; + return { + cpuUsage: () => { + calls += 1; + return { user: calls * 100_000, system: calls * 50_000 }; + }, + monotonicNowMs: () => calls * 1000, // read right after cpuUsage: the same step + }; + }; + + it('emits the SAME trace names and values as the legacy sampler (parity, non-zero CPU window)', () => { + const h = harness(constDeps({ ...steppingCpu(), eventLoop, eventLoopUtilization: () => 0.5 })); + h.startAll(); + h.tick(); + const { eventLoop: _source, ...legacyDeps } = constDeps(steppingCpu()); + const legacy = createNodeSystemMetricsSampler({ + ...legacyDeps, + eventLoop: eventLoop.read, + eventLoopUtilization: () => 0.5, + })(); + expect([...h.got.keys()].sort()).toEqual(legacy.map((s) => s.name).sort()); + for (const s of legacy) expect(h.got.get(s.name)).toBe(s.value); + expect(h.got.get('cpu_usage_process')).toBe(7.5); // a real, non-zero window on both sides + expect(h.got.get('process_memory_rss')).toBe(10); + expect(h.got.get('ram_system_total')).toBe(1000); + expect(h.got.get('event_loop_lag_max_ms')).toBe(2); + expect(h.got.get('event_loop_utilization')).toBe(0.5); + expect(h.got.size).toBe(14); + }); + + it('CPU producer: user, system and process% are each their own value (distinct non-zero steps)', () => { + let user = 0; + let system = 0; + let wall = 0; + const h = harness( + constDeps({ + cpuUsage: () => ({ user, system }), + monotonicNowMs: () => wall, + cpuCount: () => 2, + }), + ); + h.startAll(); + user += 100_000; // µs + system += 50_000; // µs + wall += 1000; // ms + h.tick(); + expect(h.got.get('cpu_usage_user')).toBe(100_000); + expect(h.got.get('cpu_usage_system')).toBe(50_000); + // (100 000 + 50 000) µs busy / (1 000 ms * 1 000) µs wall / 2 cores * 100 = 7.5 % + expect(h.got.get('cpu_usage_process')).toBe(7.5); + }); + + it('emits a baseline on start, before any tick', () => { + const h = harness(constDeps()); + h.startAll(); + expect(h.got.get('process_memory_rss')).toBe(10); + expect(h.got.get('cpu_usage_process')).toBe(0); + }); + + it('omits lag and utilization when no eventLoop / utilization source answers', () => { + const h = harness( + constDeps({ measuresEventLoopDelay: false, eventLoopUtilization: () => undefined }), + ); + h.startAll(); + h.tick(); + expect(h.got.has('event_loop_lag_ms')).toBe(false); + expect(h.got.has('event_loop_lag_max_ms')).toBe(false); + expect(h.got.has('event_loop_lag_p99_ms')).toBe(false); + expect(h.got.has('event_loop_utilization')).toBe(false); + expect(h.got.has('process_memory_rss')).toBe(true); + }); + + it('omits lag (never zero) when the source read() is undefined', () => { + const h = harness(constDeps({ eventLoop: { ...eventLoop, read: () => undefined } })); + h.startAll(); + h.tick(); + expect(h.got.has('event_loop_lag_ms')).toBe(false); + }); + + it('measuresEventLoopDelay:false never constructs a histogram; ELU still reported', () => { + const { perfHooks, state } = fakePerfHooks(); + const h = harness({ ...constDeps(), perfHooks, measuresEventLoopDelay: false }); + h.startAll(); + h.tick(); + h.stopAll(); + expect(state.monitor).toBe(0); + expect(h.got.has('event_loop_lag_ms')).toBe(false); + expect(h.got.get('event_loop_utilization')).toBe(0.3); + }); + + it('default perfHooks path reports lag from a real-shaped histogram, lazily', () => { + const { perfHooks, state } = fakePerfHooks(); + const h = harness({ ...constDeps(), perfHooks }); + expect(state.monitor).toBe(0); + expect(h.spy.set).toBe(0); + h.startAll(); + h.tick(); + expect(h.got.get('event_loop_lag_ms')).toBe(2); + expect(h.got.get('event_loop_lag_max_ms')).toBe(9); + expect(h.got.get('event_loop_lag_p99_ms')).toBe(7); + }); + + it('NO FABRICATED LAG BASELINE: lag is absent at start (empty histogram) and appears once it has sampled', () => { + const { perfHooks, histogram } = fakePerfHooks(); + histogram.count = 0; + histogram.mean = Number.NaN; + const h = harness({ ...constDeps(), perfHooks }); + h.startAll(); + expect(h.got.has('process_memory_rss')).toBe(true); // the baseline itself was emitted + expect(h.got.has('event_loop_lag_ms')).toBe(false); + expect(h.got.has('event_loop_lag_max_ms')).toBe(false); + expect(h.got.has('event_loop_lag_p99_ms')).toBe(false); + histogram.count = 2; + histogram.mean = 4e6; + h.tick(); + expect(h.got.get('event_loop_lag_ms')).toBe(4); + expect(h.got.get('event_loop_lag_max_ms')).toBe(9); + expect(h.got.get('event_loop_lag_p99_ms')).toBe(7); + }); + + it('real node perf_hooks: the start baseline carries NO lag traces (nothing measured yet)', () => { + const got = new Map(); + const ps = createNodeSystemTraceProducers(); + for (const p of ps) p.start({ emit: (n, v) => void got.set(n, v) }); + for (const p of ps) p.stop(); + expect(got.has('event_loop_lag_ms')).toBe(false); + expect(got.has('event_loop_lag_max_ms')).toBe(false); + expect(got.has('event_loop_lag_p99_ms')).toBe(false); + }); + + it('gate: creating the producers constructs no histogram and starts no timer until start', () => { + const { perfHooks, state } = fakePerfHooks(); + const h = harness({ ...constDeps(), perfHooks }); + expect(h.producers).toHaveLength(5); + expect(state.monitor).toBe(0); + expect(h.spy.set).toBe(0); + h.producers[0]?.start({ emit: () => {} }); + expect(h.spy.set).toBe(1); + expect(state.monitor).toBe(1); + expect(state.enabled).toBe(true); + }); + + it('stop of all producers disables the histogram and clears the interval', () => { + const { perfHooks, state } = fakePerfHooks(); + const h = harness({ ...constDeps(), perfHooks }); + h.startAll(); + expect(h.spy.set).toBe(1); // one shared timer for five producers + h.stopAll(); + expect(state.enabled).toBe(false); + expect(state.disables).toBe(1); + expect(h.spy.cleared).toBe(1); + }); + + it('a stopped producer no longer receives ticks', () => { + const h = harness(constDeps()); + h.startAll(); + h.stopAll(); + h.got.clear(); + h.tick(); + expect(h.got.size).toBe(0); + }); + + it('shared tracker: three+ producers do not multiply the CPU baseline advance', () => { + let u = 0; + let w = 0; + const h = harness( + constDeps({ + cpuUsage: () => { + u += 100_000; + return { user: u, system: 0 }; + }, + monotonicNowMs: () => { + w += 1000; + return w; + }, + }), + ); + h.startAll(); + const baselineUser = h.got.get('cpu_usage_user'); + h.tick(); + // activation read 100k; the single first read (baseline) is 200k, tick is 300k => one window of 100k each + expect(baselineUser).toBe(100_000); + expect(h.got.get('cpu_usage_user')).toBe(100_000); + }); + + it('a restart re-emits the baseline', () => { + const h = harness(constDeps()); + h.startAll(); + h.stopAll(); + h.got.clear(); + h.startAll(); + expect(h.got.get('ram_system_free')).toBe(400); + expect(h.spy.set).toBe(2); + }); + + it('a caller-supplied explicit reader wins over the guarded default', () => { + const { perfHooks } = fakePerfHooks(); + const h = harness({ ...constDeps(), perfHooks, eventLoop, eventLoopUtilization: () => 0.9 }); + h.startAll(); + h.tick(); + expect(h.got.get('event_loop_lag_ms')).toBe(1); + expect(h.got.get('event_loop_utilization')).toBe(0.9); + }); + + it('an explicit undefined reader does not clobber the guarded default', () => { + const { perfHooks } = fakePerfHooks(); + const h = harness({ + ...constDeps(), + perfHooks, + eventLoopUtilization: undefined, + eventLoop: undefined, + }); + h.startAll(); + h.tick(); + expect(h.got.get('event_loop_utilization')).toBe(0.3); + expect(h.got.get('event_loop_lag_ms')).toBe(2); + }); + + it('works with real node perf_hooks and no injected readers', () => { + const got = new Map(); + const ps = createNodeSystemTraceProducers(); + for (const p of ps) p.start({ emit: (n, v) => void got.set(n, v) }); + for (const p of ps) p.stop(); + expect(typeof got.get('process_memory_rss')).toBe('number'); + }); +}); diff --git a/packages/node/src/trace-producers.ts b/packages/node/src/trace-producers.ts new file mode 100644 index 00000000..2448f180 --- /dev/null +++ b/packages/node/src/trace-producers.ts @@ -0,0 +1,111 @@ +import { type TraceProducer, TraceProducerBase } from '@bugsee/capture'; +import { + createGuardedElu, + createGuardedEventLoopSource, + type PerfHooks, + realPerfHooks, +} from './trackers/guarded-event-loop'; +import { + type ProcessStats, + type ProcessStatsDeps, + ProcessStatsTracker, +} from './trackers/process-stats-tracker'; + +// Node `traces.system` producers (mirrors the mobile tracker/producer split): five producers over ONE shared +// ProcessStatsTracker, emitting exactly the trace names/values of the legacy createNodeSystemMetricsSampler. + +abstract class StatsProducer extends TraceProducerBase { + #off: (() => void) | undefined; + constructor(protected readonly tracker: ProcessStatsTracker) { + super(); + } + protected abstract publish(stats: ProcessStats): void; + protected onStart(): void { + this.#off = this.tracker.on('stats', (s) => this.publish(s)); // subscribing activates the tracker + this.publish(this.tracker.current()); // baseline + } + protected override onStop(): void { + this.#off?.(); + this.#off = undefined; + } +} + +class ProcessMemoryProducer extends StatsProducer { + readonly name = 'process-memory'; + protected publish(s: ProcessStats): void { + this.emit('process_memory_rss', s.memory.rss); + this.emit('process_memory_heap_total', s.memory.heapTotal); + this.emit('process_memory_heap_used', s.memory.heapUsed); + this.emit('process_memory_external', s.memory.external); + this.emit('process_memory_array_buffers', s.memory.arrayBuffers); + } +} + +class SystemMemoryProducer extends StatsProducer { + readonly name = 'system-memory'; + protected publish(s: ProcessStats): void { + this.emit('ram_system_total', s.systemMemory.total); + this.emit('ram_system_free', s.systemMemory.free); + } +} + +class CpuProducer extends StatsProducer { + readonly name = 'cpu'; + protected publish(s: ProcessStats): void { + this.emit('cpu_usage_user', s.cpu.userDelta); + this.emit('cpu_usage_system', s.cpu.systemDelta); + this.emit('cpu_usage_process', s.cpu.processPct); + } +} + +// Omitted, never zeroed, when the runtime cannot measure it. +class EventLoopLagProducer extends StatsProducer { + readonly name = 'event-loop-lag'; + protected publish(s: ProcessStats): void { + if (s.eventLoop === undefined) return; + this.emit('event_loop_lag_ms', s.eventLoop.meanMs); + this.emit('event_loop_lag_max_ms', s.eventLoop.maxMs); + this.emit('event_loop_lag_p99_ms', s.eventLoop.p99Ms); + } +} + +class EventLoopUtilizationProducer extends StatsProducer { + readonly name = 'event-loop-utilization'; + protected publish(s: ProcessStats): void { + if (s.utilization === undefined) return; + this.emit('event_loop_utilization', s.utilization); + } +} + +export interface NodeTraceProducerDeps extends ProcessStatsDeps { + /** perf_hooks primitives for the event-loop metrics (partial on Bun/Deno). Default node:perf_hooks. */ + perfHooks?: PerfHooks; + /** + * Does this runtime's `monitorEventLoopDelay` observe a BLOCKED loop? Default `true`. Deno's does not + * (answers, never throws, never registers a stall), so `@bugsee/deno` passes `false` and the histogram + * is never even constructed. + */ + measuresEventLoopDelay?: boolean; +} + +export function createNodeSystemTraceProducers(deps: NodeTraceProducerDeps = {}): TraceProducer[] { + const { perfHooks, measuresEventLoopDelay = true, ...rest } = deps; + const perf = perfHooks ?? realPerfHooks; + // `??` / conditional spreads (never a bare `...rest` over defaults): an explicit `undefined` must not + // clobber a default, and `eventLoop` must be ABSENT (not undefined) when the runtime cannot measure it. + const eventLoop = + rest.eventLoop ?? + (measuresEventLoopDelay ? createGuardedEventLoopSource(perf.monitorEventLoopDelay) : undefined); + const tracker = new ProcessStatsTracker({ + ...rest, + ...(eventLoop !== undefined ? { eventLoop } : {}), + eventLoopUtilization: rest.eventLoopUtilization ?? createGuardedElu(perf.eventLoopUtilization), + }); + return [ + new ProcessMemoryProducer(tracker), + new SystemMemoryProducer(tracker), + new CpuProducer(tracker), + new EventLoopLagProducer(tracker), + new EventLoopUtilizationProducer(tracker), + ]; +} diff --git a/packages/node/src/trackers/guarded-event-loop.test.ts b/packages/node/src/trackers/guarded-event-loop.test.ts new file mode 100644 index 00000000..5ef30b61 --- /dev/null +++ b/packages/node/src/trackers/guarded-event-loop.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from 'vitest'; +import { + createGuardedElu, + createGuardedEventLoopSource, + eluUnavailable, + realPerfHooks, +} from './guarded-event-loop'; + +// A controllable event-loop delay histogram (readings in ns). +// `count` is the number of samples the histogram has taken (a real Node histogram exposes it). +function fakePerfHooks( + readings: { mean: number; max: number; p99: number; count?: number } = { + mean: 2e6, + max: 9e6, + p99: 7e6, + count: 3, + }, + opts: { disable?: boolean } = {}, +) { + const state = { monitor: 0, enabled: false, enables: 0, disables: 0, resets: 0 }; + const histogram = { + enable() { + state.enabled = true; + state.enables += 1; + }, + ...(opts.disable === false + ? {} + : { + disable() { + state.enabled = false; + state.disables += 1; + }, + }), + reset() { + state.resets += 1; + }, + get mean() { + return readings.mean; + }, + get max() { + return readings.max; + }, + percentile() { + return readings.p99; + }, + }; + // a LIVE getter (a spread would snapshot it), absent entirely when the runtime has no count + if (readings.count !== undefined) { + Object.defineProperty(histogram, 'count', { get: () => readings.count, enumerable: true }); + } + const monitor = () => { + state.monitor += 1; + return histogram; + }; + return { state, monitor, histogram }; +} + +describe('createGuardedEventLoopSource', () => { + it('constructs nothing and enables nothing until start()', () => { + const { state, monitor } = fakePerfHooks(); + createGuardedEventLoopSource(monitor); + expect(state.monitor).toBe(0); + expect(state.enables).toBe(0); + }); + + it('start() constructs + enables once and is idempotent on the histogram; restart reuses it', () => { + const { state, monitor } = fakePerfHooks(); + const src = createGuardedEventLoopSource(monitor); + src.start(); + src.start(); + expect(state.monitor).toBe(1); + expect(state.enabled).toBe(true); + src.stop(); + expect(state.enabled).toBe(false); + expect(state.disables).toBe(1); + src.start(); + expect(state.monitor).toBe(1); + expect(state.enabled).toBe(true); + }); + + it('start() resets the histogram so stale samples are not read', () => { + const { state, monitor } = fakePerfHooks(); + createGuardedEventLoopSource(monitor).start(); + expect(state.resets).toBe(1); + }); + + it('stop() tolerates a histogram without disable() and one whose disable throws', () => { + const a = fakePerfHooks(undefined, { disable: false }); + const s1 = createGuardedEventLoopSource(a.monitor); + s1.start(); + expect(() => s1.stop()).not.toThrow(); + const b = fakePerfHooks(); + b.histogram.disable = () => { + throw new Error('boom'); + }; + const s2 = createGuardedEventLoopSource(b.monitor); + s2.start(); + expect(() => s2.stop()).not.toThrow(); + }); + + it('stop() before start() is a no-op', () => { + const { monitor } = fakePerfHooks(); + expect(() => createGuardedEventLoopSource(monitor).stop()).not.toThrow(); + }); + + it('read() is undefined before start(), then ns to ms, and resets the histogram', () => { + const { state, monitor } = fakePerfHooks({ mean: 2e6, max: 9e6, p99: 7e6, count: 1 }); + const src = createGuardedEventLoopSource(monitor); + expect(src.read()).toBeUndefined(); + src.start(); + const before = state.resets; + expect(src.read()).toEqual({ meanMs: 2, maxMs: 9, p99Ms: 7 }); + expect(state.resets).toBe(before + 1); + }); + + it('treats a non-finite reading of a SAMPLED histogram as zero lag', () => { + const { monitor } = fakePerfHooks({ mean: 5e6, max: Number.NaN, p99: Number.NaN, count: 1 }); + const src = createGuardedEventLoopSource(monitor); + src.start(); + expect(src.read()).toEqual({ meanMs: 5, maxMs: 0, p99Ms: 0 }); + }); + + it('NO FABRICATED BASELINE: read() is undefined until the histogram has a sample (count 0), and does not reset it', () => { + const readings = { mean: Number.NaN, max: 0, p99: 0, count: 0 }; + const { state, monitor } = fakePerfHooks(readings); + const src = createGuardedEventLoopSource(monitor); + src.start(); + const resets = state.resets; + expect(src.read()).toBeUndefined(); + expect(state.resets).toBe(resets); + // count is authoritative even when mean is finite + readings.mean = 1e6; + expect(src.read()).toBeUndefined(); + Object.assign(readings, { count: 4, mean: 3e6, max: 8e6, p99: 6e6 }); + expect(src.read()).toEqual({ meanMs: 3, maxMs: 8, p99Ms: 6 }); + }); + + it('NO FABRICATED BASELINE without a count property: a non-finite mean means "no sample yet"', () => { + const readings = { mean: Number.NaN, max: 0, p99: 0 }; + const { monitor } = fakePerfHooks(readings); + const src = createGuardedEventLoopSource(monitor); + src.start(); + expect(src.read()).toBeUndefined(); + readings.mean = 2e6; + expect(src.read()).toEqual({ meanMs: 2, maxMs: 0, p99Ms: 0 }); + }); + + it('real node histogram: undefined right after start(), real values once it has sampled', async () => { + const src = createGuardedEventLoopSource(realPerfHooks.monitorEventLoopDelay); + src.start(); + try { + expect(src.read()).toBeUndefined(); + let out = src.read(); + for (let i = 0; i < 100 && out === undefined; i += 1) { + await new Promise((r) => setTimeout(r, 20)); + out = src.read(); + } + expect(out).toBeDefined(); + expect(out?.meanMs).toBeGreaterThan(0); + expect(Number.isFinite(out?.maxMs)).toBe(true); + expect(Number.isFinite(out?.p99Ms)).toBe(true); + } finally { + src.stop(); + } + }); + + it('enable() succeeded but reset() threw: the histogram is disabled before it is dropped', () => { + const { state, histogram } = fakePerfHooks(); + histogram.reset = () => { + throw new Error('reset refused'); + }; + const src = createGuardedEventLoopSource(() => histogram); + expect(() => src.start()).not.toThrow(); + expect(state.enables).toBe(1); + expect(state.disables).toBe(1); + expect(state.enabled).toBe(false); + expect(src.read()).toBeUndefined(); + }); + + it('a failed start tolerates a disable() that also throws (and one that is absent)', () => { + const a = fakePerfHooks(); + a.histogram.reset = () => { + throw new Error('reset refused'); + }; + a.histogram.disable = () => { + throw new Error('disable refused'); + }; + expect(() => createGuardedEventLoopSource(() => a.histogram).start()).not.toThrow(); + const b = fakePerfHooks(undefined, { disable: false }); + b.histogram.reset = () => { + throw new Error('reset refused'); + }; + const src = createGuardedEventLoopSource(() => b.histogram); + expect(() => src.start()).not.toThrow(); + expect(src.read()).toBeUndefined(); + }); + + it('a throwing monitorEventLoopDelay: start() does not throw, read() stays undefined, no retry', () => { + let calls = 0; + const src = createGuardedEventLoopSource(() => { + calls += 1; + throw new Error('unsupported'); + }); + expect(() => src.start()).not.toThrow(); + expect(src.read()).toBeUndefined(); + src.start(); + expect(calls).toBe(1); + expect(() => src.stop()).not.toThrow(); + }); + + it('a throwing enable(): start() does not throw and read() stays undefined', () => { + const { histogram } = fakePerfHooks(); + histogram.enable = () => { + throw new Error('refused'); + }; + const src = createGuardedEventLoopSource(() => histogram); + expect(() => src.start()).not.toThrow(); + expect(src.read()).toBeUndefined(); + }); + + it('a histogram READ that throws yields undefined, not a throw', () => { + const throwing = { + enable() {}, + reset() {}, + get mean(): number { + throw new Error('read failed'); + }, + get max() { + return 0; + }, + percentile() { + return 0; + }, + }; + const src = createGuardedEventLoopSource(() => throwing); + src.start(); + expect(src.read()).toBeUndefined(); + }); +}); + +describe('createGuardedElu', () => { + const fakeElu = + (delta: number) => + (a?: { utilization: number }): { utilization: number; idle: number; active: number } => + a === undefined + ? { utilization: 0, idle: 5, active: 5 } + : { utilization: delta, idle: 5, active: 5 }; + + it('returns the utilization delta when the runtime answers honestly', () => { + expect(createGuardedElu(fakeElu(0.42))()).toBe(0.42); + }); + + it('is undefined on the Bun/Deno shape (idle and active both 0)', () => { + expect(createGuardedElu(() => ({ idle: 0, active: 0, utilization: 0 }))()).toBeUndefined(); + }); + + it('keeps a genuine zero when idle time actually passed', () => { + expect(createGuardedElu(() => ({ idle: 1000, active: 0, utilization: 0 }))()).toBe(0); + }); + + it('is undefined when the initial call throws', () => { + expect( + createGuardedElu(() => { + throw new Error('unsupported'); + })(), + ).toBeUndefined(); + }); + + it('is undefined when the delta READ throws', () => { + const elu = (a?: { utilization: number }): { utilization: number } => { + if (a !== undefined) throw new Error('delta failed'); + return { utilization: 0 }; + }; + expect(createGuardedElu(elu)()).toBeUndefined(); + }); + + it('advances the baseline between reads', () => { + const snapshots: Array<{ utilization: number; idle: number; active: number }> = []; + const prevSeen: unknown[] = []; + const elu = (a?: { utilization: number }, b?: { utilization: number }) => { + if (a !== undefined) { + prevSeen.push(b); + return { utilization: 0.5, idle: 1, active: 1 }; + } + const snap = { utilization: snapshots.length, idle: 1, active: 1 }; + snapshots.push(snap); + return snap; + }; + const read = createGuardedElu(elu); + read(); + read(); + // init snapshot[0]; read1 takes snapshot[1] (prev = [0]); read2 takes snapshot[2] (prev = [1]). + expect(prevSeen[0]).toBe(snapshots[0]); + expect(prevSeen[1]).toBe(snapshots[1]); + }); +}); + +describe('realPerfHooks + eluUnavailable re-export', () => { + it('wraps the live node:perf_hooks', () => { + const h = realPerfHooks.monitorEventLoopDelay(); + expect(typeof h.enable).toBe('function'); + const e = realPerfHooks.eventLoopUtilization(); + expect(typeof e.utilization).toBe('number'); + expect( + typeof realPerfHooks.eventLoopUtilization(realPerfHooks.eventLoopUtilization(), e) + .utilization, + ).toBe('number'); + }); + it('re-exports eluUnavailable', () => { + expect(eluUnavailable({ idle: 0, active: 0 })).toBe(true); + expect(eluUnavailable({ idle: 1, active: 0 })).toBe(false); + }); +}); diff --git a/packages/node/src/trackers/guarded-event-loop.ts b/packages/node/src/trackers/guarded-event-loop.ts new file mode 100644 index 00000000..97efd0bc --- /dev/null +++ b/packages/node/src/trackers/guarded-event-loop.ts @@ -0,0 +1,115 @@ +import { monitorEventLoopDelay, performance } from 'node:perf_hooks'; +import { eluUnavailable } from '../system-metrics'; +import type { EventLoopSource } from './process-stats-tracker'; + +// Guarded, LAZY event-loop sources for node-family runtimes with PARTIAL perf_hooks (Bun, Deno). Nothing is +// constructed or enabled until start(): a source that is never started costs nothing, and a runtime whose +// monitorEventLoopDelay throws degrades to "lag omitted" rather than aborting SDK startup. + +/** The slice of an event-loop-delay histogram the lag metrics read. */ +export interface DelayHistogram { + enable(): void; + disable?(): void; + reset(): void; + /** Samples taken since the last reset (Node). Absent on runtimes that do not expose it. */ + readonly count?: number; + readonly mean: number; + readonly max: number; + percentile(p: number): number; +} +export interface Elu { + utilization: number; + /** Present on a real implementation; used to tell "not implemented" from "idle" — see `eluUnavailable`. */ + idle?: number; + active?: number; +} +/** The two perf_hooks primitives the event-loop metrics need (partial on Bun/Deno, so injected). */ +export interface PerfHooks { + monitorEventLoopDelay: () => DelayHistogram; + eventLoopUtilization: (a?: Elu, b?: Elu) => Elu; +} + +export const realPerfHooks: PerfHooks = { + monitorEventLoopDelay: () => monitorEventLoopDelay(), + eventLoopUtilization: (a, b) => + (performance.eventLoopUtilization as (a?: Elu, b?: Elu) => Elu)(a, b), +}; + +export { eluUnavailable }; + +/** ns → ms; 0 when a sampled histogram still reports a non-finite field. */ +const lagMs = (ns: number): number => (Number.isFinite(ns) ? ns / 1e6 : 0); + +/** + * Has the histogram taken no sample yet? Right after enable()+reset() its mean is NaN and max/p99 are ~0 — + * reading it then would fabricate a "healthy loop" baseline. `count` is authoritative when the runtime + * exposes it; otherwise a non-finite mean is the tell. + */ +const isEmpty = (h: DelayHistogram): boolean => + typeof h.count === 'number' ? h.count === 0 : !Number.isFinite(h.mean); + +export function createGuardedEventLoopSource(monitor: () => DelayHistogram): EventLoopSource { + let histogram: DelayHistogram | undefined; + let unusable = false; + return { + start() { + if (unusable) return; + try { + histogram ??= monitor(); + histogram.enable(); + histogram.reset(); + } catch { + unusable = true; // a partial runtime must not abort SDK startup + try { + histogram?.disable?.(); // enable() may have succeeded: never leave a dropped histogram running + } catch { + /* best-effort */ + } + histogram = undefined; + } + }, + stop() { + try { + histogram?.disable?.(); + } catch { + /* best-effort */ + } + }, + read() { + if (histogram === undefined) return undefined; // not started: omit, never zero + try { + if (isEmpty(histogram)) return undefined; // nothing measured yet: omit, never a fake zero + const out = { + meanMs: lagMs(histogram.mean), + maxMs: lagMs(histogram.max), + p99Ms: lagMs(histogram.percentile(99)), + }; + histogram.reset(); + return out; + } catch { + return undefined; + } + }, + }; +} + +// A utilization reader (delta 0..1). `undefined` — NOT zero — whenever the runtime cannot give a real +// reading: it threw, or it answered the Bun/Deno way (idle and active both zero for ever). +export function createGuardedElu(elu: PerfHooks['eventLoopUtilization']): () => number | undefined { + let prev: Elu; + try { + prev = elu(); + } catch { + return () => undefined; + } + return () => { + try { + const current = elu(); + const delta = elu(current, prev); + prev = current; + return eluUnavailable(delta) ? undefined : delta.utilization; + } catch { + return undefined; + } + }; +} diff --git a/packages/node/src/trackers/process-stats-tracker.test.ts b/packages/node/src/trackers/process-stats-tracker.test.ts new file mode 100644 index 00000000..8e488b60 --- /dev/null +++ b/packages/node/src/trackers/process-stats-tracker.test.ts @@ -0,0 +1,446 @@ +import type { Scheduler } from '@bugsee/core'; +import { describe, expect, it, vi } from 'vitest'; +import { + type EventLoopSource, + type ProcessStats, + ProcessStatsTracker, +} from './process-stats-tracker'; + +// Every handle is unique, and clearInterval must be handed exactly a live handle setInterval returned — +// so clearing `undefined`/`null` (a handle dropped too early) or the same handle twice fails the test. +function fakeScheduler() { + let cb: (() => void) | null = null; + let cleared = 0; + let sets = 0; + const live = new Set(); + const scheduler: Scheduler = { + setInterval: (c) => { + cb = c; + sets += 1; + const handle = { id: sets }; + live.add(handle); + return handle; + }, + clearInterval: (h) => { + expect(live.has(h), `clearInterval(${JSON.stringify(h)}) is not a live handle`).toBe(true); + live.delete(h); + cleared += 1; + }, + }; + return { scheduler, tick: () => cb?.(), cleared: () => cleared, sets: () => sets }; +} +const mem = { rss: 1, heapTotal: 2, heapUsed: 3, external: 4, arrayBuffers: 5 }; +function mk(over: Partial[0]> = {}) { + let cpu = { user: 0, system: 0 }; + let wall = 0; + const fs = fakeScheduler(); + const tracker = new ProcessStatsTracker({ + memoryUsage: () => mem, + systemMemory: () => ({ total: 100, free: 40 }), + cpuUsage: () => cpu, + cpuCount: () => 2, + monotonicNowMs: () => wall, + scheduler: fs.scheduler, + intervalMs: 1000, + ...over, + }); + return { + tracker, + fs, + advance: (c: { user: number; system: number }, w: number) => { + cpu = c; + wall = w; + }, + }; +} + +describe('ProcessStatsTracker', () => { + it('attaches NOTHING at construction (no timer, no sources started)', () => { + const started: string[] = []; + const eventLoop: EventLoopSource = { + read: () => undefined, + start: () => void started.push('start'), + stop: () => void started.push('stop'), + }; + const { fs } = mk({ eventLoop }); + expect(fs.sets()).toBe(0); + expect(started).toEqual([]); + }); + + it('the first subscriber activates it: one timer + event-loop source started; the last one leaves → released', () => { + const calls: string[] = []; + const eventLoop: EventLoopSource = { + read: () => undefined, + start: () => void calls.push('start'), + stop: () => void calls.push('stop'), + }; + const { tracker, fs } = mk({ eventLoop }); + const off1 = tracker.on('stats', () => {}); + const off2 = tracker.on('stats', () => {}); + expect(fs.sets()).toBe(1); + expect(calls).toEqual(['start']); + off1(); + expect(fs.cleared()).toBe(0); // still one subscriber + off2(); + expect(fs.cleared()).toBe(1); + expect(calls).toEqual(['start', 'stop']); + }); + + it('emits stats on each tick with cpu deltas over the wall window (normalized by cores)', () => { + const { tracker, fs, advance } = mk(); + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); + advance({ user: 400_000, system: 200_000 }, 1000); // 600 000 µs busy over 1 000 ms on 2 cores + fs.tick(); + expect(got).toHaveLength(1); + expect(got[0]?.memory).toEqual(mem); + expect(got[0]?.systemMemory).toEqual({ total: 100, free: 40 }); + expect(got[0]?.cpu).toEqual({ userDelta: 400_000, systemDelta: 200_000, processPct: 30 }); + }); + + it('current() returns the cached stats and does NOT advance the cpu baseline (shared by many producers)', () => { + const { tracker, advance } = mk(); + tracker.on('stats', () => {}); + const a = tracker.current(); + advance({ user: 500_000, system: 0 }, 1000); + const b = tracker.current(); + const c = tracker.current(); + expect(b).toBe(a); + expect(c).toBe(a); // same object until the next poll + expect(a.cpu.userDelta).toBe(0); // baseline: no delta yet + }); + + it('a zero wall window yields processPct 0 (no divide by zero)', () => { + const { tracker, fs } = mk(); + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); + fs.tick(); // wall unchanged + expect(got[0]?.cpu.processPct).toBe(0); + }); + + it('includes eventLoop / utilization only when their sources answer (omitted, never zeroed)', () => { + const { tracker, fs } = mk({ + eventLoop: { + read: () => ({ meanMs: 1, maxMs: 2, p99Ms: 3 }), + start: () => {}, + stop: () => {}, + }, + eventLoopUtilization: () => undefined, + }); + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); + fs.tick(); + expect(got[0]?.eventLoop).toEqual({ meanMs: 1, maxMs: 2, p99Ms: 3 }); + expect('utilization' in (got[0] ?? {})).toBe(false); + }); + + it('restart: re-attaches and re-baselines (no stale cache)', () => { + const { tracker, fs, advance } = mk(); + const off = tracker.on('stats', () => {}); + advance({ user: 1, system: 1 }, 10); + off(); + tracker.on('stats', () => {}); + expect(fs.sets()).toBe(2); + expect(tracker.current().cpu.userDelta).toBe(0); + }); + + it('a throwing reader is reported via onError and does not break the timer', () => { + const errors: unknown[] = []; + let boom = false; + const { tracker, fs } = mk({ + memoryUsage: () => { + if (boom) throw new Error('read boom'); + return mem; + }, + onError: (e) => errors.push(e), + }); + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); + boom = true; + expect(() => fs.tick()).not.toThrow(); + expect(got).toHaveLength(0); + expect((errors[0] as Error).message).toBe('read boom'); + }); + + it('an explicit undefined dep falls back to the default (not clobbered)', () => { + const { tracker } = mk({ memoryUsage: undefined, cpuCount: undefined }); + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); + const s = tracker.current(); + expect(s.memory.rss).not.toBe(1); // the injected-default 'mem' is not used; real reader is + expect(typeof s.memory.heapUsed).toBe('number'); + }); + + it("uses real defaults (os/perf_hooks/process) and an unref'd default scheduler", () => { + const tracker = new ProcessStatsTracker({ intervalMs: 5 }); + const off = tracker.on('stats', () => {}); + const s = tracker.current(); + expect(s.systemMemory.total).toBeGreaterThan(0); + expect(s.cpu.processPct).toBeGreaterThanOrEqual(0); + off(); // clears the real interval + }); + + it('the default scheduler delivers ticks, and is silent after the last unsubscribe', () => { + vi.useFakeTimers(); + try { + const tracker = new ProcessStatsTracker({ intervalMs: 5 }); + const got: ProcessStats[] = []; + const off = tracker.on('stats', (s) => got.push(s)); + vi.advanceTimersByTime(5); + expect(got).toHaveLength(1); + vi.advanceTimersByTime(10); + expect(got).toHaveLength(3); + off(); + expect(vi.getTimerCount()).toBe(0); // the real interval was cleared + vi.advanceTimersByTime(100); + expect(got).toHaveLength(3); + } finally { + vi.useRealTimers(); + } + }); + + it('reports utilization when the source answers', () => { + const { tracker, fs } = mk({ eventLoopUtilization: () => 0.5 }); + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); + fs.tick(); + expect(got[0]?.utilization).toBe(0.5); + }); + + it('onError is optional: a throwing reader without onError is swallowed', () => { + let boom = false; + const { tracker, fs } = mk({ + memoryUsage: () => { + if (boom) throw new Error('x'); + return mem; + }, + }); + tracker.on('stats', () => {}); + boom = true; + expect(() => fs.tick()).not.toThrow(); + }); + + it('a deactivate with no event-loop source is fine', () => { + const { tracker, fs } = mk(); + tracker.on('stats', () => {})(); + expect(fs.cleared()).toBe(1); + }); + + it('current() inside a stats listener already returns the emitted stats (cached before emit)', () => { + const { tracker, fs } = mk({ memoryUsage: () => ({ ...mem }) }); + let seen: ProcessStats | undefined; + let inside: ProcessStats | undefined; + tracker.on('stats', (s) => { + seen = s; + inside = tracker.current(); + }); + fs.tick(); + expect(inside).toBe(seen); + }); + + it('deactivation drops the cache: current() afterwards is a fresh read', () => { + const { tracker, fs } = mk({ memoryUsage: () => ({ ...mem }) }); + const off = tracker.on('stats', () => {}); + fs.tick(); + const before = tracker.current(); + off(); + expect(tracker.current()).not.toBe(before); + }); + + it('activation discards a cache taken before any subscriber', () => { + const { tracker } = mk({ memoryUsage: () => ({ ...mem }) }); + const early = tracker.current(); + tracker.on('stats', () => {}); + expect(tracker.current()).not.toBe(early); + }); + + it('a throwing reader AT ACTIVATION: reported via onError, on() does not throw, nothing left attached, and a later activation retries', () => { + const errors: unknown[] = []; + const calls: string[] = []; + let broken = true; + const eventLoop: EventLoopSource = { + read: () => undefined, + start: () => void calls.push('start'), + stop: () => void calls.push('stop'), + }; + const { tracker, fs } = mk({ + cpuUsage: () => { + if (broken) throw new Error('cpu boom'); + return { user: 0, system: 0 }; + }, + eventLoop, + onError: (e) => errors.push(e), + }); + let off: (() => void) | undefined; + expect(() => { + off = tracker.on('stats', () => {}); + }).not.toThrow(); + expect((errors[0] as Error).message).toBe('cpu boom'); + expect(fs.sets()).toBe(0); // no timer was registered + off?.(); + broken = false; + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); + expect(fs.sets()).toBe(1); + expect(calls).toEqual(['start']); + fs.tick(); + expect(got).toHaveLength(1); + expect(errors).toHaveLength(1); + }); + + it('current() after a failed activation RETRIES it (timer + event-loop source) instead of accepting a baseline from an unarmed tracker', () => { + const errors: unknown[] = []; + const calls: string[] = []; + let broken = true; + const eventLoop: EventLoopSource = { + read: () => undefined, + start: () => void calls.push('start'), + stop: () => void calls.push('stop'), + }; + const { tracker, fs } = mk({ + cpuUsage: () => { + if (broken) throw new Error('cpu boom'); + return { user: 0, system: 0 }; + }, + eventLoop, + onError: (e) => errors.push(e), + }); + const got: ProcessStats[] = []; + tracker.on('stats', (s) => got.push(s)); // activation fails, subscriber stays registered + expect(fs.sets()).toBe(0); + broken = false; + expect(tracker.current().memory).toEqual(mem); // the retry succeeds before the baseline is read + expect(fs.sets()).toBe(1); + expect(calls).toEqual(['start']); + fs.tick(); + expect(got).toHaveLength(1); // the already-registered subscriber now receives ticks + expect(tracker.current()).toBe(got[0]); + expect(errors).toHaveLength(1); + // the retry happens once: a further current() does not re-activate + tracker.current(); + expect(fs.sets()).toBe(1); + expect(calls).toEqual(['start']); + }); + + it('current() after a failed activation whose retry ALSO fails propagates the failure (and a later current() retries again)', () => { + const errors: unknown[] = []; + let broken = true; + const { tracker, fs } = mk({ + cpuUsage: () => { + if (broken) throw new Error('cpu boom'); + return { user: 0, system: 0 }; + }, + onError: (e) => errors.push(e), + }); + tracker.on('stats', () => {}); + expect(() => tracker.current()).toThrow('cpu boom'); + expect(fs.sets()).toBe(0); + broken = false; + expect(() => tracker.current()).not.toThrow(); + expect(fs.sets()).toBe(1); + }); + + it('a pending activation failure is cleared on deactivation: current() with no subscribers does not activate', () => { + let broken = true; + const { tracker, fs } = mk({ + cpuUsage: () => { + if (broken) throw new Error('cpu boom'); + return { user: 0, system: 0 }; + }, + onError: () => {}, + }); + const off = tracker.on('stats', () => {}); + off(); + broken = false; + tracker.current(); + expect(fs.sets()).toBe(0); // no subscriber → no retry, no timer + }); + + it('a throwing setInterval at activation stops the already-started event-loop source', () => { + const errors: unknown[] = []; + const calls: string[] = []; + let refuse = true; + const eventLoop: EventLoopSource = { + read: () => undefined, + start: () => void calls.push('start'), + stop: () => void calls.push('stop'), + }; + let sets = 0; + let cleared = 0; + const scheduler: Scheduler = { + setInterval: () => { + if (refuse) throw new Error('no timers'); + sets += 1; + return 'h'; + }, + clearInterval: () => { + cleared += 1; + }, + }; + const { tracker } = mk({ eventLoop, scheduler, onError: (e) => errors.push(e) }); + const off = tracker.on('stats', () => {}); + expect((errors[0] as Error).message).toBe('no timers'); + expect(calls).toEqual(['start', 'stop']); + off(); + refuse = false; + tracker.on('stats', () => {}); + expect(sets).toBe(1); + expect(calls).toEqual(['start', 'stop', 'start']); // the unwound source is not stopped twice + expect(cleared).toBe(0); // the failed activation registered no timer, so there was none to clear + }); + + it('an activation failure before the event-loop source started does not stop it, and onError is optional', () => { + const calls: string[] = []; + const eventLoop: EventLoopSource = { + read: () => undefined, + start: () => void calls.push('start'), + stop: () => void calls.push('stop'), + }; + const { tracker } = mk({ + monotonicNowMs: () => { + throw new Error('clock boom'); + }, + eventLoop, + }); + expect(() => tracker.on('stats', () => {})).not.toThrow(); + expect(calls).toEqual([]); + }); + + it('a throwing event-loop stop() during the activation unwind is swallowed (the original error is reported)', () => { + const errors: unknown[] = []; + const eventLoop: EventLoopSource = { + read: () => undefined, + start: () => {}, + stop: () => { + throw new Error('stop boom'); + }, + }; + const scheduler: Scheduler = { + setInterval: () => { + throw new Error('no timers'); + }, + clearInterval: () => {}, + }; + const { tracker } = mk({ eventLoop, scheduler, onError: (e) => errors.push(e) }); + expect(() => tracker.on('stats', () => {})).not.toThrow(); + expect(errors.map((e) => (e as Error).message)).toEqual(['no timers']); + }); + + it('an event-loop source whose start() throws is reported and is NOT stopped (it never started)', () => { + const errors: unknown[] = []; + const calls: string[] = []; + const eventLoop: EventLoopSource = { + read: () => undefined, + start: () => { + calls.push('start'); + throw new Error('start boom'); + }, + stop: () => void calls.push('stop'), + }; + const { tracker, fs } = mk({ eventLoop, onError: (e) => errors.push(e) }); + tracker.on('stats', () => {})(); + expect((errors[0] as Error).message).toBe('start boom'); + expect(calls).toEqual(['start']); + expect(fs.sets()).toBe(0); + }); +}); diff --git a/packages/node/src/trackers/process-stats-tracker.ts b/packages/node/src/trackers/process-stats-tracker.ts new file mode 100644 index 00000000..62a5212b --- /dev/null +++ b/packages/node/src/trackers/process-stats-tracker.ts @@ -0,0 +1,197 @@ +import { cpus, freemem, totalmem } from 'node:os'; +import { performance } from 'node:perf_hooks'; +import { InterceptorBase, type Scheduler } from '@bugsee/core'; + +// Shared node process-stats TRACKER (mirrors the mobile tracker/producer split): ONE timer aggregates +// memory / system RAM / CPU deltas (+ optional event-loop sources) into a ProcessStats object that many +// per-trace producers read. Attaches nothing until its first subscriber; the last unsubscribe releases +// the timer and stops the event-loop source. The cpu% arithmetic is identical to system-metrics.ts. + +export interface EventLoopSource { + /** Mean/max/p99 lag (ms) since the previous read, or undefined when the runtime cannot measure it. */ + read(): { meanMs: number; maxMs: number; p99Ms: number } | undefined; + start(): void; + stop(): void; +} + +export interface ProcessStats { + memory: { + rss: number; + heapTotal: number; + heapUsed: number; + external: number; + arrayBuffers: number; + }; + systemMemory: { total: number; free: number }; + cpu: { userDelta: number; systemDelta: number; processPct: number }; + eventLoop?: { meanMs: number; maxMs: number; p99Ms: number }; + utilization?: number; +} + +export interface ProcessStatsDeps { + memoryUsage?: () => ProcessStats['memory']; + cpuUsage?: () => { user: number; system: number }; + systemMemory?: () => { total: number; free: number }; + cpuCount?: () => number; + monotonicNowMs?: () => number; + /** Lazily started on activation, stopped on deactivation. Default: none (lag omitted). */ + eventLoop?: EventLoopSource; + /** ELU delta 0..1 per call, or undefined when unavailable. Default: none. */ + eventLoopUtilization?: () => number | undefined; + scheduler?: Scheduler; + intervalMs?: number; + onError?: (error: unknown) => void; +} + +// Unref'd so the tracker never keeps a process alive. +const defaultScheduler: Scheduler = { + setInterval: (cb, ms) => { + const h = setInterval(cb, ms); + (h as { unref?: () => void }).unref?.(); + return h; + }, + clearInterval: (h) => clearInterval(h as ReturnType), +}; + +interface ResolvedDeps { + memoryUsage: () => ProcessStats['memory']; + cpuUsage: () => { user: number; system: number }; + systemMemory: () => { total: number; free: number }; + cpuCount: () => number; + monotonicNowMs: () => number; + scheduler: Scheduler; + intervalMs: number; +} + +export class ProcessStatsTracker extends InterceptorBase<{ stats: ProcessStats }> { + readonly name = 'node-process-stats'; + readonly #d: ResolvedDeps; + readonly #eventLoop: EventLoopSource | undefined; + readonly #eventLoopUtilization: (() => number | undefined) | undefined; + readonly #onError: ((error: unknown) => void) | undefined; + #timer: unknown = null; + #eventLoopRunning = false; + #lastCpu = { user: 0, system: 0 }; + #lastWallMs = 0; + #cached: ProcessStats | undefined; + #activationFailed = false; + + constructor(deps: ProcessStatsDeps = {}) { + super(deps.onError); + // Per-field `??` (not a spread): an explicit `undefined` must fall back to the default. + this.#d = { + memoryUsage: deps.memoryUsage ?? (() => process.memoryUsage()), + cpuUsage: deps.cpuUsage ?? (() => process.cpuUsage()), + systemMemory: deps.systemMemory ?? (() => ({ total: totalmem(), free: freemem() })), + cpuCount: deps.cpuCount ?? (() => cpus().length), + monotonicNowMs: deps.monotonicNowMs ?? (() => performance.now()), + scheduler: deps.scheduler ?? defaultScheduler, + intervalMs: deps.intervalMs ?? 1000, + }; + this.#eventLoop = deps.eventLoop; + this.#eventLoopUtilization = deps.eventLoopUtilization; + this.#onError = deps.onError; + } + + /** + * Latest stats (read once on activation, then replaced on every poll). Safe for many callers. If the + * activation failed, this RETRIES it first (a failure propagates) so a producer never takes a baseline + * from a tracker that has no timer and would otherwise stay silent until every subscriber left. + */ + current(): ProcessStats { + if (this.#activationFailed) { + this.#activate(); + this.#activationFailed = false; + } + this.#cached ??= this.#read(); + return this.#cached; + } + + // Guarded: InterceptorBase marks the tracker active (and the subscriber is already registered) BEFORE + // this runs, so a throw escaping here would leave an "active" tracker with no timer and leak the + // subscribing producer's listener. A failure is reported, everything started is unwound, and the + // failure is remembered so the next current() retries (deactivation clears it; the next activation + // retries too). + protected onActivate(): void { + try { + this.#activate(); + } catch (error) { + this.#activationFailed = true; + this.#onError?.(error); + } + } + + // Starts everything or unwinds what it started and rethrows. + #activate(): void { + try { + this.#lastCpu = this.#d.cpuUsage(); + this.#lastWallMs = this.#d.monotonicNowMs(); + this.#cached = undefined; + if (this.#eventLoop !== undefined) { + this.#eventLoop.start(); + this.#eventLoopRunning = true; + } + this.#timer = this.#d.scheduler.setInterval(() => this.#poll(), this.#d.intervalMs); + } catch (error) { + try { + this.#stopEventLoop(); + } catch { + /* best-effort unwind; the original failure is the one thrown */ + } + throw error; + } + } + + /** Stops the event-loop source only if this activation started it. */ + #stopEventLoop(): void { + if (!this.#eventLoopRunning) return; + this.#eventLoopRunning = false; + this.#eventLoop?.stop(); + } + + protected override onDeactivate(): void { + if (this.#timer !== null) this.#d.scheduler.clearInterval(this.#timer); // none after a failed activation + this.#timer = null; + this.#stopEventLoop(); + this.#cached = undefined; + this.#activationFailed = false; + } + + #poll(): void { + let stats: ProcessStats; + try { + stats = this.#read(); + } catch (error) { + this.#onError?.(error); + return; + } + this.#cached = stats; + this.emit('stats', stats); + } + + #read(): ProcessStats { + const memory = this.#d.memoryUsage(); + const systemMemory = this.#d.systemMemory(); + const cpu = this.#d.cpuUsage(); + const nowMs = this.#d.monotonicNowMs(); + const userDelta = cpu.user - this.#lastCpu.user; + const systemDelta = cpu.system - this.#lastCpu.system; + const wallMsDelta = nowMs - this.#lastWallMs; + this.#lastCpu = cpu; + this.#lastWallMs = nowMs; + // Normalized process CPU %: busy-µs / wall-µs / cores * 100. Guard the zero-window first sample. + const processPct = + wallMsDelta > 0 + ? ((userDelta + systemDelta) / (wallMsDelta * 1000) / this.#d.cpuCount()) * 100 + : 0; + const eventLoop = this.#eventLoop?.read(); + const utilization = this.#eventLoopUtilization?.(); + return { + memory, + systemMemory, + cpu: { userDelta, systemDelta, processPct }, + ...(eventLoop !== undefined ? { eventLoop } : {}), + ...(utilization !== undefined ? { utilization } : {}), + }; + } +} diff --git a/packages/webview/src/launch.test.ts b/packages/webview/src/launch.test.ts index 75810f7c..7f8aaa19 100644 --- a/packages/webview/src/launch.test.ts +++ b/packages/webview/src/launch.test.ts @@ -911,6 +911,114 @@ describe('launch (webview)', () => { expect(traces.some((e) => JSON.stringify(e.p).includes('wv_metric'))).toBe(true); }); + it('streams battery/charging ({state}) from the browser battery producer', async () => { + const fake = fakeGlobal(); + const getBattery = vi.fn(() => + Promise.resolve({ + level: 1, + charging: true, + addEventListener() {}, + removeEventListener() {}, + }), + ); + vi.stubGlobal('navigator', { getBattery }); + try { + track('tok', baseOptions({ global: fake.global })); + await new Promise((r) => setTimeout(r, 0)); + expect(getBattery).toHaveBeenCalledTimes(1); + const traces = entriesOfType(fake.msgs(), 'traces.system').map( + (e) => (e.p as unknown as { data: { name: string; value: unknown } }).data, + ); + expect(traces).toContainEqual(expect.objectContaining({ name: 'battery', value: 100 })); + expect(traces).toContainEqual( + expect.objectContaining({ name: 'charging', value: { state: 3 } }), + ); + } finally { + vi.unstubAllGlobals(); + } + }); + + it.each([ + { maxRecordingTime: 2, refreshMs: 2000 }, // 0.5s clamped up to the 2s floor + { maxRecordingTime: 20, refreshMs: 5000 }, // 20s/4 = 5s + ])('refreshes the held battery trace after $refreshMs ms (recording window $maxRecordingTime s)', async ({ + maxRecordingTime, + refreshMs, + }) => { + vi.useFakeTimers({ toFake: ['Date'] }); + const fake = fakeGlobal(); + vi.stubGlobal('navigator', { + getBattery: () => + Promise.resolve({ + level: 1, + charging: true, + addEventListener() {}, + removeEventListener() {}, + }), + }); + try { + vi.setSystemTime(0); + const ticks: Array<{ cb: () => void; ms: number }> = []; + track( + 'tok', + baseOptions({ + global: fake.global, + maxRecordingTime, + scheduler: { + setInterval: (cb, ms) => { + ticks.push({ cb, ms }); + return 0 as unknown as ReturnType; + }, + clearInterval: () => {}, + }, + } as Partial), + ); + await new Promise((r) => setTimeout(r, 0)); + const battery = () => + entriesOfType(fake.msgs(), 'traces.system').filter( + (e) => (e.p as unknown as { data: { name: string } }).data.name === 'battery', + ).length; + const tick = () => { + for (const t of ticks.filter((x) => x.ms === 1000)) t.cb(); + }; + expect(battery()).toBe(1); + vi.setSystemTime(refreshMs - 1); + tick(); + expect(battery()).toBe(1); + vi.setSystemTime(refreshMs); + tick(); + expect(battery()).toBe(2); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); + + it('a battery manager that throws after getBattery resolves reaches options.onError', async () => { + const fake = fakeGlobal(); + const getBattery = vi.fn(() => + Promise.resolve({ + level: 1, + charging: true, + addEventListener() { + throw new Error('battery add refused'); + }, + removeEventListener() {}, + }), + ); + vi.stubGlobal('navigator', { getBattery }); + const onError = vi.fn(); + try { + track('tok', baseOptions({ global: fake.global, onError })); + await new Promise((r) => setTimeout(r, 0)); + expect(onError.mock.calls.map((c) => (c[0] as Error).message)).toContain( + 'battery add refused', + ); + } finally { + vi.unstubAllGlobals(); + } + }); + it('streams a logException as a crash ENTRY (always) but NO report trigger when the gate is off', async () => { const fake = fakeGlobal(); const client = track('tok', baseOptions({ global: fake.global })); diff --git a/packages/webview/src/launch.ts b/packages/webview/src/launch.ts index da4c6929..25642ba8 100644 --- a/packages/webview/src/launch.ts +++ b/packages/webview/src/launch.ts @@ -2,6 +2,7 @@ import { bindInputBlackout, createBrowserInputSource, createBrowserSystemEventsSource, + createBrowserSystemTraceProducers, createBrowserSystemTracesSampler, createUiBreadcrumbProvider, createUiBreadcrumbSource, @@ -17,6 +18,7 @@ import { createLogCaptureProvider, createSystemEventsProvider, createSystemTracesProvider, + deriveRefreshMs, installNetworkCapture, type TraceSample, } from '@bugsee/capture'; @@ -671,6 +673,9 @@ export function launchWithSeams( client.addCaptureProvider( createSystemTracesProvider({ sample: options.systemMetricsSampler ?? createBrowserSystemTracesSampler(), + producers: createBrowserSystemTraceProducers(undefined, options.onError), + refreshMs: deriveRefreshMs(resolved.options.get(BugseeOption.Duration, 60) * 1000), + ...(options.onError !== undefined ? { onError: options.onError } : {}), ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), }), );