From 324ef0fe620690c4842b8a6b509fb964f150977e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 15:03:07 +0500 Subject: [PATCH 01/20] =?UTF-8?q?docs(design):=20ST0=20implementation=20pl?= =?UTF-8?q?an=20=E2=80=94=20sampler=20lifecycle=20seam=20+=20charging=20fi?= =?UTF-8?q?x?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/system-traces-st0-plan.md | 1061 +++++++++++++++++++++++++ 1 file changed, 1061 insertions(+) create mode 100644 docs/design/system-traces-st0-plan.md diff --git a/docs/design/system-traces-st0-plan.md b/docs/design/system-traces-st0-plan.md new file mode 100644 index 00000000..b2d8c30d --- /dev/null +++ b/docs/design/system-traces-st0-plan.md @@ -0,0 +1,1061 @@ +# ST0 β€” Sampler Lifecycle Seam + `charging` Fix β€” 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:** Give the `traces.system` platform samplers an explicit `{start, stop, sample}` lifecycle owned by the traces provider (so standing resources attach only when `captureSystemTraces` is on and are released on `stop()`), migrate the two pieces of factory-time work that already violate that rule (the node/Bun/Deno delay histogram and the browser `getBattery()` fetch), and fix the shipped `charging` trace so the viewer can render it. + +**Architecture:** `@bugsee/capture` defines `LifecycleSampler` and a `SystemTracesSampler = (() => samples) | LifecycleSampler` union; `createSystemTracesProvider` accepts the union, wraps a bare function as sample-only, and calls `start()` at the top of `onStart` and `stop()` in `onStop` β€” i.e. only after the coordinator's `captureSystemTraces` gate. The three platform factories (`createNodeSystemMetricsSampler`, `createGuardedSystemMetricsSampler`, `createBrowserSystemTracesSampler`) stop returning bare functions and return `LifecycleSampler` objects; the launch files only *pass the object through*. No new traces are added in ST0. + +**Tech Stack:** TypeScript (`module: ESNext`, `verbatimModuleSyntax`), Vitest, pnpm + turbo, Biome. Runtimes: Node β‰₯18, Bun, Deno, browsers. + +**Spec:** `docs/design/system-traces-expansion.md` (PR #53 β€” read it first). Relevant sections: Β§5 *Lifecycle seam* and *Per-runtime gates*, Β§9 ST0, Β§12 items 1/5 (only item 5 is in ST0), Β§13.4 (`charging`), Β§13.5. This plan file lives on its own branch off `main`, so the spec file is only present once PR #53 merges; until then read it from that branch. + +## Global Constraints + +- **Coverage gate per package:** 100% line / function / statement, β‰₯ 90% branch (vitest v8). Unreachable code needs an explicit `/* v8 ignore … */` with a one-line justification. +- **Test-first, mutator loop (`docs/implementation-standards.md` Β§2):** for every new/changed method write the failing test, implement, then inject up to 10 different bugs into the new code, confirm the test fails each time, and **roll back by restoring a saved copy (`cp`), never `git checkout`**. Never commit a mutation. +- **Typecheck is separate from tests:** vitest does not typecheck. Run `pnpm --filter @bugsee/ exec tsc --noEmit` before every commit. +- **Gate before pushing:** `pnpm ci:local` (not just `pnpm test`). +- **Shared tiers are runtime-portable:** `@bugsee/capture` and `@bugsee/core` never import `node:*` or DOM types; reach globals only through `globalThis as unknown as {…}` casts. +- **Never fabricate a reading** (CLAUDE.md, ELU/lag precedents): when a source cannot answer, the trace is **omitted**, never zeroed. +- **Bun/Deno honesty:** "the call succeeded" is not evidence. The Bun/Deno default sampler must be a real lifecycle object, never a bare function that a `typeof === 'function'` adapter would silently turn into a no-op `start()` (which would leave `event_loop_lag_*` at the idle floor through a stall). +- **Wire shape:** the `charging` trace becomes `{ state: 1 | 2 | 3 }` (viewer table keys `"0"`–`"3"`: unknown, unplugged, charging, full), `source` **omitted, never invented**. `environment.hardware.charging` stays a boolean β€” do not touch it. +- **Branching:** this work is reviewed as a PR (the design was). Create `feat/st0-lifecycle-sampler` from `main`; one commit per task; open a draft PR after Task 2 and push as you go. Attribution line on every commit/PR: `πŸ€– Generated with [Claude Code](https://claude.com/claude-code)`. + +## Review Focus + +The input classes the spec implies but a straight reading of the tasks would not exercise (each is pinned by a named test in the owning task): + +1. **`sampler.start()` throws, or the first `sample()` throws, inside `onStart`** β†’ the provider must stop the sampler before rethrowing, or a half-started sampler leaks. (Task 1, test "stops the sampler when the initial emit throws".) +2. **`captureSystemTraces: false`** β†’ no histogram is created and no `getBattery()` call is made, through the *real* launch + provider path, for both the node-default and the Bun/Deno guarded sampler. (Task 4, Task 5.) +3. **Start β†’ stop β†’ start (provider cycled across launches)** β†’ the sampler re-attaches and re-releases; no double-enable, no stale battery. (Tasks 1, 2, 3, 5.) +4. **`getBattery()` resolves *after* `stop()`** β†’ the late fulfilment must not cache the manager or surface battery traces. (Task 5.) +5. **`sample()` called before `start()`** (a stub provider, a future caller) β†’ lag traces omitted, not zeroed; no throw. (Tasks 2, 3.) +6. **Bun/Deno wrap hazard** β†’ the default sampler passed through `@bugsee/{bun,deno}` is an object with real `start`/`stop`, and with traces on it enables the histogram exactly once after provider start. (Task 4.) +7. **`charging` at the edges** β†’ `level === 1` while charging is `full` (3), `0.99` is `charging` (2), not charging is `unplugged` (1), and a viewer-unknown state is never emitted. (Task 6.) + +--- + +## File Structure + +| File | Change | Responsibility | +|---|---|---| +| `packages/capture/src/system-traces-provider.ts` | modify | `LifecycleSampler`, `SystemTracesSampler`, `asLifecycleSampler`; provider owns `start()`/`stop()` | +| `packages/capture/src/system-traces-provider.test.ts` | modify | provider lifecycle tests | +| `packages/capture/src/index.ts` | modify | export the new types/helper | +| `packages/node/src/system-metrics.ts` | modify | `EventLoopSource`; node sampler returns `LifecycleSampler`; lazy histogram | +| `packages/node/src/system-metrics.test.ts` | modify | adapt `()` β†’ `.sample()`; lifecycle tests | +| `packages/node/src/guarded-system-metrics.ts` | modify | `guardedEventLoop` returns an `EventLoopSource`; defer `monitor()`+`enable()` to `start()`; guarded factory returns `LifecycleSampler` | +| `packages/node/src/guarded-system-metrics.test.ts` | modify | adapt call sites; lifecycle tests | +| `packages/node/src/launch.ts` | modify | widen `systemMetricsSampler` option type | +| `packages/node/src/launch.test.ts` | modify | end-to-end option-gate tests with the guarded sampler | +| `packages/bun/src/launch.test.ts`, `packages/deno/src/launch.test.ts` | modify | pin that the injected default is a lifecycle object | +| `packages/browser/src/system-metrics.ts` | modify | sampler returns `LifecycleSampler`; `getBattery()` moves to `start()` with an abort token | +| `packages/browser/src/system-metrics.test.ts` | modify | adapt call sites; lifecycle + late-resolve tests | +| `packages/browser/src/system-trace-values.ts` (+ `.test.ts`) | modify | `chargingToWire` | +| `packages/browser/src/launch.ts`, `packages/webview/src/launch.ts` | modify | widen the option type | +| `packages/browser/src/launch.test.ts`, `packages/webview/src/launch.test.ts` | modify | option-gate tests | +| `docs/PROGRESS.md` | modify | one-paragraph record | + +`packages/bun/src/launch.ts` and `packages/deno/src/launch.ts` need **no code change** (they already inject `createGuardedSystemMetricsSampler(...)`; the return type widens underneath them). + +## Cross-task interfaces (defined once, used by every task) + +```ts +// @bugsee/capture (Task 1) +export interface LifecycleSampler { + /** Attach every standing resource (histogram, observer, listener). Called by the provider's onStart, after the captureSystemTraces gate. Idempotent. */ + start(): void; + /** Release everything start() attached. Idempotent; a no-op before start(). */ + stop(): void; + /** Read current samples synchronously. Must be safe to call before start() (omit what is not yet attached). */ + sample(): readonly TraceSample[]; +} +export type SystemTracesSampler = (() => readonly TraceSample[]) | LifecycleSampler; +export function asLifecycleSampler(sampler: SystemTracesSampler): LifecycleSampler; + +// @bugsee/node (Task 2) +export interface EventLoopSource { + read: () => { meanMs: number; maxMs: number; p99Ms: number } | undefined; + start(): void; + stop(): void; +} +// NodeSystemMetricsDeps.eventLoop?: EventLoopReader | EventLoopSource (function form = no lifecycle) +// createNodeSystemMetricsSampler(deps?): LifecycleSampler +// createGuardedSystemMetricsSampler(deps?): LifecycleSampler +// @bugsee/browser (Task 5) +// createBrowserSystemTracesSampler(env?): LifecycleSampler +// @bugsee/browser (Task 6) +// chargingToWire(battery: { charging: boolean; level: number }): { state: 1 | 2 | 3 } +``` + +--- + +### Task 1: Provider owns the sampler lifecycle (`@bugsee/capture`) + +**Files:** +- Modify: `packages/capture/src/system-traces-provider.ts` +- Modify: `packages/capture/src/index.ts` (add exports next to the existing `createSystemTracesProvider` block at line ~64) +- Test: `packages/capture/src/system-traces-provider.test.ts` + +**Interfaces:** +- Produces: `LifecycleSampler`, `SystemTracesSampler`, `asLifecycleSampler`, and `SystemTracesProviderOptions.sample: SystemTracesSampler` (was `() => readonly TraceSample[]`). + +- [ ] **Step 1: Write the failing tests** β€” append to `system-traces-provider.test.ts` (the file already imports `createSystemTracesProvider`, `fakeScheduler`, `buildInit`, `mkStore`, `options`, `drainSystem`; extend the import line to `import { asLifecycleSampler, createSystemTracesProvider, type LifecycleSampler, type TraceSample } from './system-traces-provider';`): + +```ts +// A recording lifecycle sampler: `calls` is the order start/sample/stop were invoked in. +function recordingSampler(samples: TraceSample[] = []): { sampler: LifecycleSampler; calls: string[] } { + const calls: string[] = []; + const sampler: LifecycleSampler = { + start: () => void calls.push('start'), + stop: () => void calls.push('stop'), + sample: () => { + calls.push('sample'); + return samples; + }, + }; + return { sampler, calls }; +} + +describe('asLifecycleSampler', () => { + it('wraps a bare function as a sample-only sampler (start/stop are no-ops)', () => { + const fn = (): TraceSample[] => [{ name: 'a', value: 1 }]; + const wrapped = asLifecycleSampler(fn); + expect(() => { + wrapped.start(); + wrapped.stop(); + }).not.toThrow(); + expect(wrapped.sample()).toEqual([{ name: 'a', value: 1 }]); + }); + + it('returns a lifecycle object unchanged (same reference)', () => { + const { sampler } = recordingSampler(); + expect(asLifecycleSampler(sampler)).toBe(sampler); + }); +}); + +describe('createSystemTracesProvider β€” sampler lifecycle', () => { + it('starts the sampler BEFORE the initial sample, and stops it on provider stop', () => { + const fs = fakeScheduler(); + const { sampler, calls } = recordingSampler(); + const p = createSystemTracesProvider({ sample: sampler, scheduler: fs.scheduler }); + p.init(buildInit(mkStore())); + p.start(options); + expect(calls).toEqual(['start', 'sample']); + p.stop(); + expect(calls).toEqual(['start', 'sample', 'stop']); + }); + + it('never starts the sampler when captureSystemTraces is off (the coordinator gate)', () => { + const store = mkStore(); + const fs = fakeScheduler(); + const { sampler, calls } = recordingSampler(); + const coordinator = createCaptureCoordinator(buildInit(store)); + coordinator.addProvider( + createSystemTracesProvider({ sample: sampler, scheduler: fs.scheduler }), + ); + coordinator.start(createOptionsContainer({ [BugseeOption.CaptureSystemTraces]: false })); + expect(calls).toEqual([]); + coordinator.stop(); + expect(calls).toEqual([]); + }); + + it('re-attaches on a second start after a stop (provider cycled across launches)', () => { + const fs = fakeScheduler(); + const { sampler, calls } = recordingSampler(); + const p = createSystemTracesProvider({ sample: sampler, scheduler: fs.scheduler }); + p.init(buildInit(mkStore())); + p.start(options); + p.stop(); + p.start(options); + expect(calls).toEqual(['start', 'sample', 'stop', 'start', 'sample']); + }); + + it('stops the sampler when the initial emit throws, then rethrows', () => { + const fs = fakeScheduler(); + const calls: string[] = []; + const sampler: LifecycleSampler = { + start: () => void calls.push('start'), + stop: () => void calls.push('stop'), + sample: () => { + throw new Error('sample boom'); + }, + }; + const p = createSystemTracesProvider({ sample: sampler, scheduler: fs.scheduler }); + p.init(buildInit(mkStore())); + expect(() => p.start(options)).toThrow('sample boom'); + expect(calls).toEqual(['start', 'stop']); // released, not leaked + expect(fs.cleared()).toBe(0); // the timer was never created + }); + + it('propagates a start() throw without sampling or scheduling', () => { + const fs = fakeScheduler(); + const sampled: string[] = []; + const sampler: LifecycleSampler = { + start: () => { + throw new Error('start boom'); + }, + stop: () => {}, + sample: () => { + sampled.push('sample'); + return []; + }, + }; + const p = createSystemTracesProvider({ sample: sampler, scheduler: fs.scheduler }); + p.init(buildInit(mkStore())); + expect(() => p.start(options)).toThrow('start boom'); + expect(sampled).toEqual([]); + expect(fs.interval()).toBe(0); + }); +}); +``` + +(`createOptionsContainer` already accepts an initial map in this test file's sibling tests β€” confirm the exact signature at `packages/core/src/options-container.ts` and mirror how the existing "disabled β†’ no sampling" test builds its disabled container; use that form if it differs.) + +- [ ] **Step 2: Run to verify failure** + +Run: `pnpm --filter @bugsee/capture exec vitest run src/system-traces-provider.test.ts` +Expected: FAIL β€” `asLifecycleSampler` / `LifecycleSampler` are not exported. + +- [ ] **Step 3: Implement** β€” in `system-traces-provider.ts`: + +```ts +/** One sampled system trace: a named value at the sampling instant. */ +export interface TraceSample { name: string; value: unknown } // (unchanged) + +/** + * A system-traces sampler that owns standing resources (a delay histogram, an observer, a listener). + * The PROVIDER owns the lifecycle: `start()` runs in the provider's `onStart` β€” i.e. only after the + * coordinator's `captureSystemTraces` gate β€” and `stop()` in `onStop`, so nothing attaches when the + * option is off and everything is released on `client.stop()`. + */ +export interface LifecycleSampler { + start(): void; + stop(): void; + sample(): readonly TraceSample[]; +} + +/** A lifecycle sampler, or a bare function (a test stub / sample-only source) that is wrapped as one. */ +export type SystemTracesSampler = (() => readonly TraceSample[]) | LifecycleSampler; + +/** Wrap ONLY a bare function; a lifecycle object passes through by reference. */ +export function asLifecycleSampler(sampler: SystemTracesSampler): LifecycleSampler { + return typeof sampler === 'function' + ? { start: () => {}, stop: () => {}, sample: sampler } + : sampler; +} +``` + +Change the option and the class: + +```ts +export interface SystemTracesProviderOptions { + /** Reads the current system traces (the runtime-specific seam). A bare function is sample-only; a LifecycleSampler also gets start()/stop() from the provider. Required. */ + sample: SystemTracesSampler; + // intervalMs / scheduler / now unchanged +} + +class SystemTracesProvider extends CaptureProviderBase { + // … + readonly #sampler: LifecycleSampler; + + constructor(options: SystemTracesProviderOptions) { + super(); + this.#sampler = asLifecycleSampler(options.sample); + // … + } + + protected onStart(): void { + this.#sampler.start(); + try { + this.#emit(); // initial snapshot, so a short-lived session still has current values + } catch (error) { + this.#sampler.stop(); // do not leak a half-started sampler + throw error; + } + this.#timer = this.#scheduler.setInterval(() => this.#emit(), this.#intervalMs); + } + + protected override onStop(): void { + this.#scheduler.clearInterval(this.#timer); + this.#timer = null; + this.#sampler.stop(); + } + + #emit(): void { + const timestamp = this.#now(); + for (const { name, value } of this.#sampler.sample()) { + this.capture('traces.system', timestamp, { timestamp, name, value }); + } + } +} +``` + +(Remove the now-unused `#sample` field.) In `index.ts` change the export block to: + +```ts +export { + asLifecycleSampler, + createSystemTracesProvider, + type LifecycleSampler, + type SystemTracesProviderOptions, + type SystemTracesSampler, + type TraceSample, +} from './system-traces-provider'; +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `pnpm --filter @bugsee/capture exec vitest run src/system-traces-provider.test.ts && pnpm --filter @bugsee/capture exec tsc --noEmit` +Expected: PASS, no type errors. (The existing function-form tests still pass untouched β€” that is the compatibility check.) + +- [ ] **Step 5: Mutator loop** β€” save `cp system-traces-provider.ts /tmp/stp.bak`; inject, one at a time, and confirm a test fails each time: (1) delete `this.#sampler.start()`; (2) move `start()` after `#emit()`; (3) drop the `stop()` in the catch; (4) drop the `stop()` in `onStop`; (5) wrap `asLifecycleSampler` for objects too (return a copy) β†’ the "same reference" test; (6) change `typeof sampler === 'function'` to `=== 'object'`; (7) call `start()` in the constructor. Restore with `cp /tmp/stp.bak system-traces-provider.ts`. + +- [ ] **Step 6: Coverage + commit** + +Run: `pnpm --filter @bugsee/capture exec vitest run --coverage` β†’ 100% lines for this file. + +```bash +git add packages/capture/src/system-traces-provider.ts packages/capture/src/system-traces-provider.test.ts packages/capture/src/index.ts +git commit -m "feat(capture): traces provider owns the sampler lifecycle (start/stop) + +πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +### Task 2: Node sampler returns a `LifecycleSampler` with a lazy delay histogram + +**Files:** +- Modify: `packages/node/src/system-metrics.ts` +- Test: `packages/node/src/system-metrics.test.ts` + +**Interfaces:** +- Consumes: `LifecycleSampler`, `TraceSample` from `@bugsee/capture` (Task 1). +- Produces: `EventLoopSource`; `NodeSystemMetricsDeps.eventLoop?: EventLoopReader | EventLoopSource`; `createNodeSystemMetricsSampler(deps): LifecycleSampler`. + +- [ ] **Step 1: Adapt existing call sites** β€” every `createNodeSystemMetricsSampler(deps(...))()` in `system-metrics.test.ts` (11 call sites) becomes `createNodeSystemMetricsSampler(deps(...)).sample()`. Do this mechanically first; the tests fail to typecheck/run until Step 3 β€” that is the expected red. + +- [ ] **Step 2: Write the new failing tests** (append): + +```ts +import type { LifecycleSampler } from '@bugsee/capture'; +import type { EventLoopSource } from './system-metrics'; + +describe('createNodeSystemMetricsSampler β€” lifecycle', () => { + it('returns a lifecycle sampler (start/stop/sample)', () => { + const s: LifecycleSampler = createNodeSystemMetricsSampler(deps()); + expect(typeof s.start).toBe('function'); + expect(typeof s.stop).toBe('function'); + expect(typeof s.sample).toBe('function'); + }); + + it('forwards start/stop to an injected EventLoopSource and reads through it', () => { + const calls: string[] = []; + const source: EventLoopSource = { + read: () => ({ meanMs: 1, maxMs: 2, p99Ms: 3 }), + start: () => void calls.push('start'), + stop: () => void calls.push('stop'), + }; + const s = createNodeSystemMetricsSampler(deps({ eventLoop: source })); + expect(calls).toEqual([]); // nothing attached at construction + s.start(); + s.stop(); + expect(calls).toEqual(['start', 'stop']); + expect(byName([...s.sample()]).get('event_loop_lag_max_ms')).toBe(2); + }); + + it('treats a bare-function eventLoop dep as lifecycle-free (start/stop are no-ops)', () => { + const s = createNodeSystemMetricsSampler(deps({ eventLoop: () => ({ meanMs: 4, maxMs: 5, p99Ms: 6 }) })); + expect(() => { + s.start(); + s.stop(); + }).not.toThrow(); + expect(byName([...s.sample()]).get('event_loop_lag_ms')).toBe(4); + }); + + it('default source: no lag traces before start(), lag traces after start()', () => { + const { eventLoop: _omit, ...rest } = deps(); + const s = createNodeSystemMetricsSampler(rest); // real perf_hooks source + const before = byName([...s.sample()]); + expect(before.has('event_loop_lag_ms')).toBe(false); // omitted, NOT zeroed + s.start(); + try { + const after = byName([...s.sample()]); + for (const k of ['event_loop_lag_ms', 'event_loop_lag_max_ms', 'event_loop_lag_p99_ms']) { + expect(after.has(k)).toBe(true); + } + } finally { + s.stop(); + } + }); + + it('default source: start() twice and stop() before start() are harmless; restart works', () => { + const { eventLoop: _omit, ...rest } = deps(); + const s = createNodeSystemMetricsSampler(rest); + expect(() => s.stop()).not.toThrow(); // before start + s.start(); + s.start(); // idempotent + s.stop(); + s.start(); // restart + expect(byName([...s.sample()]).has('event_loop_lag_ms')).toBe(true); + s.stop(); + }); +}); +``` + +- [ ] **Step 3: Run to verify failure** + +Run: `pnpm --filter @bugsee/node exec vitest run src/system-metrics.test.ts` +Expected: FAIL (`.sample` is not a function / `EventLoopSource` not exported). + +- [ ] **Step 4: Implement** β€” in `system-metrics.ts`: + +```ts +import type { LifecycleSampler, TraceSample } from '@bugsee/capture'; + +/** + * An event-loop reader that owns a STANDING resource (a perf_hooks delay histogram). The histogram is + * created and enabled in `start()` β€” never at construction β€” so it only exists between the traces + * provider's start and stop (i.e. only when `captureSystemTraces` is on). `read()` answers `undefined` + * (lag traces omitted, never zeroed) until `start()` has attached it. + */ +export interface EventLoopSource { + read: EventLoopReader; + start(): void; + stop(): void; +} +const toEventLoopSource = (e: EventLoopReader | EventLoopSource): EventLoopSource => + typeof e === 'function' ? { read: e, start: () => {}, stop: () => {} } : e; + +const createEventLoopSource = (): EventLoopSource => { + let histogram: ReturnType | undefined; + return { + start() { + histogram ??= monitorEventLoopDelay(); + histogram.enable(); + histogram.reset(); // drop anything recorded before a previous stop + }, + stop() { + histogram?.disable(); + }, + read() { + if (histogram === undefined) return undefined; // not started β†’ omit, never zero + const out = { + meanMs: eventLoopLagFromMeanNs(histogram.mean), + maxMs: eventLoopLagFromMeanNs(histogram.max), + p99Ms: eventLoopLagFromMeanNs(histogram.percentile(99)), + }; + histogram.reset(); + return out; + }, + }; +}; +``` + +Replace `createEventLoopReader` with the above; change the dep type to `eventLoop?: EventLoopReader | EventLoopSource;` and update its doc comment ("a bare function has no lifecycle"). Change the factory: + +```ts +export function createNodeSystemMetricsSampler(deps: NodeSystemMetricsDeps = {}): LifecycleSampler { + // …unchanged readers… + const loop: EventLoopSource = toEventLoopSource(deps.eventLoop ?? createEventLoopSource()); + // … + return { + start: () => loop.start(), + stop: () => loop.stop(), + sample: (): TraceSample[] => { + // body of the previous returned arrow, with `const lag = loop.read();` in place of `eventLoop()` + }, + }; +} +``` + +Keep the CPU baseline (`lastCpu`/`lastWallMs`) and ELU baseline at construction β€” they are values, not standing resources (YAGNI: no re-baseline on restart; the sampler is rebuilt per launch). + +- [ ] **Step 5: Run to verify pass** + +Run: `pnpm --filter @bugsee/node exec vitest run src/system-metrics.test.ts && pnpm --filter @bugsee/node exec tsc --noEmit` +Expected: PASS (tsc will flag `guarded-system-metrics.ts` and `launch.ts` β€” fixed in Tasks 3–4; run only this file's tests here and defer the package-wide `tsc` to Task 4). + +- [ ] **Step 6: Mutator loop** β€” back up `system-metrics.ts`; mutate: (1) create the histogram in the factory body again (construction-time) β†’ "before start() omitted" test; (2) `read()` returns zeros instead of `undefined` when not started; (3) drop `histogram.disable()`; (4) drop `reset()` in `start()`; (5) `toEventLoopSource` returns `e` for functions; (6) `start: () => {}` in the returned sampler (forwarding removed). Confirm each fails; restore from the backup. + +- [ ] **Step 7: Commit** + +```bash +git add packages/node/src/system-metrics.ts packages/node/src/system-metrics.test.ts +git commit -m "feat(node): node system-metrics sampler is a lifecycle sampler; delay histogram attaches on start + +πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +### Task 3: Guarded (Bun/Deno) sampler β€” defer `monitor()` + `enable()` to `start()` + +**Files:** +- Modify: `packages/node/src/guarded-system-metrics.ts` (`guardedEventLoop` lines ~52-79; `DelayHistogram` interface; factory lines ~116-128) +- Test: `packages/node/src/guarded-system-metrics.test.ts` + +**Interfaces:** +- Consumes: `EventLoopSource` (Task 2), `LifecycleSampler` (Task 1). +- Produces: `createGuardedSystemMetricsSampler(deps): LifecycleSampler`. + +- [ ] **Step 1: Adapt existing call sites** β€” the 16 uses of `createGuardedSystemMetricsSampler(...)()` become `.sample()`; where an existing test asserts `enable` happened right after construction, change it to call `s.start()` first (those assertions *encode the old bug* β€” update them, do not preserve them). + +- [ ] **Step 2: Write the failing tests** (append; reuse the file's existing fake-histogram helper if present, else this local one): + +```ts +function fakePerfHooks(opts: { monitorThrows?: boolean; enableThrows?: boolean } = {}) { + const log = { monitor: 0, enable: 0, disable: 0, reset: 0 }; + const histogram = { + enable: () => { + log.enable += 1; + if (opts.enableThrows) throw new Error('enable boom'); + }, + disable: () => void (log.disable += 1), + reset: () => void (log.reset += 1), + mean: 2e6, + max: 5e6, + percentile: () => 4e6, + }; + const perfHooks = { + monitorEventLoopDelay: () => { + log.monitor += 1; + if (opts.monitorThrows) throw new Error('monitor boom'); + return histogram; + }, + eventLoopUtilization: () => ({ utilization: 0.5, idle: 1, active: 1 }), + }; + return { perfHooks, log }; +} + +describe('createGuardedSystemMetricsSampler β€” lifecycle', () => { + it('does NOT construct or enable the histogram at construction or on sample() before start()', () => { + const { perfHooks, log } = fakePerfHooks(); + const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); + expect(log).toMatchObject({ monitor: 0, enable: 0 }); + expect(byName([...s.sample()]).has('event_loop_lag_ms')).toBe(false); // omitted, not zeroed + expect(log).toMatchObject({ monitor: 0, enable: 0 }); + }); + + it('start() constructs + enables exactly once; lag traces then appear', () => { + const { perfHooks, log } = fakePerfHooks(); + const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); + s.start(); + s.start(); // idempotent + expect(log.monitor).toBe(1); + expect(log.enable).toBeGreaterThanOrEqual(1); + const t = byName([...s.sample()]); + expect(t.get('event_loop_lag_ms')).toBe(2); + expect(t.get('event_loop_lag_max_ms')).toBe(5); + expect(t.get('event_loop_lag_p99_ms')).toBe(4); + }); + + it('stop() disables the histogram; a restart re-enables the same histogram', () => { + const { perfHooks, log } = fakePerfHooks(); + const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); + s.start(); + s.stop(); + expect(log.disable).toBe(1); + s.start(); + expect(log.monitor).toBe(1); // reused, not rebuilt + }); + + it('stop() before start() is a no-op', () => { + const { perfHooks, log } = fakePerfHooks(); + const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); + expect(() => s.stop()).not.toThrow(); + expect(log.disable).toBe(0); + }); + + it('measuresEventLoopDelay:false never constructs a histogram, even after start()', () => { + const { perfHooks, log } = fakePerfHooks(); + const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks, measuresEventLoopDelay: false }); + s.start(); + s.stop(); + expect(log.monitor).toBe(0); + expect(byName([...s.sample()]).has('event_loop_lag_ms')).toBe(false); + }); + + it('a throwing monitorEventLoopDelay degrades to omitted lag; start() does not throw', () => { + const { perfHooks } = fakePerfHooks({ monitorThrows: true }); + const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); + expect(() => s.start()).not.toThrow(); + expect(byName([...s.sample()]).has('event_loop_lag_ms')).toBe(false); + }); + + it('a throwing enable() degrades to omitted lag; start() does not throw', () => { + const { perfHooks } = fakePerfHooks({ enableThrows: true }); + const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); + expect(() => s.start()).not.toThrow(); + expect(byName([...s.sample()]).has('event_loop_lag_ms')).toBe(false); + }); + + it('a runtime histogram with no disable() (Bun/Deno shape) stops cleanly', () => { + const { perfHooks } = fakePerfHooks(); + const bare = { ...perfHooks, monitorEventLoopDelay: () => { + const h = perfHooks.monitorEventLoopDelay(); + const { disable: _d, ...noDisable } = h; + return noDisable; + } }; + const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks: bare }); + s.start(); + expect(() => s.stop()).not.toThrow(); + }); +}); +``` + +`baseDeps()` = the fully-injected `NodeSystemMetricsDeps` minus `eventLoop`/`eventLoopUtilization` (copy the helper at the top of this test file or `system-metrics.test.ts`'s `deps()`). + +- [ ] **Step 3: Run to verify failure** + +Run: `pnpm --filter @bugsee/node exec vitest run src/guarded-system-metrics.test.ts` +Expected: FAIL. + +- [ ] **Step 4: Implement** β€” in `guarded-system-metrics.ts`: + +```ts +import type { LifecycleSampler, TraceSample } from '@bugsee/capture'; +import { createNodeSystemMetricsSampler, type EventLoopSource, eluUnavailable, type NodeSystemMetricsDeps } from './system-metrics'; + +interface DelayHistogram { + enable(): void; + /** Absent on some runtimes (Bun/Deno histograms) β€” stop() tolerates that. */ + disable?(): void; + reset(): void; + readonly mean: number; + readonly max: number; + percentile(p: number): number; +} + +const unavailableLoop = (): undefined => undefined; +const unavailableSource: EventLoopSource = { read: unavailableLoop, start: () => {}, stop: () => {} }; + +// Builds the lag source. NOTHING is constructed or enabled until start(): a histogram that exists at +// factory time runs with captureSystemTraces:false and survives client.stop(). Answers `undefined` β€” +// never zero β€” when the runtime cannot give a real reading, or before start(). +function guardedEventLoop(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 + histogram = undefined; + } + }, + stop() { + try { + histogram?.disable?.(); + } catch { + // releasing is best-effort + } + }, + read() { + if (histogram === undefined) return undefined; + try { + const out = { + meanMs: lagMs(histogram.mean), + maxMs: lagMs(histogram.max), + p99Ms: lagMs(histogram.percentile(99)), + }; + histogram.reset(); + return out; + } catch { + return undefined; + } + }, + }; +} +``` + +Factory: `: LifecycleSampler`, and `eventLoop: measuresEventLoopDelay ? guardedEventLoop(perf.monitorEventLoopDelay) : unavailableSource,`. Leave `guardedElu` unchanged (a baseline value, not a standing resource). + +- [ ] **Step 5: Run to verify pass** + +Run: `pnpm --filter @bugsee/node exec vitest run src/guarded-system-metrics.test.ts src/system-metrics.test.ts` +Expected: PASS. + +- [ ] **Step 6: Mutator loop** β€” backup; mutate: (1) call `monitor()` at factory time; (2) swallow nothing in `start()` (remove try/catch) β†’ throw tests; (3) drop `unusable` guard (retry every start); (4) `stop()` without `?.disable?.()`; (5) `read()` returns zero object when `histogram === undefined`; (6) use `unavailableSource` regardless of the flag (inverted ternary); (7) remove `reset()` in `start()`. Confirm each is caught; restore. + +- [ ] **Step 7: Commit** + +```bash +git add packages/node/src/guarded-system-metrics.ts packages/node/src/guarded-system-metrics.test.ts +git commit -m "feat(node): guarded sampler defers the delay histogram to start() and releases it on stop() + +πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +### Task 4: Wire node / Bun / Deno launch and pin the gate end-to-end + +**Files:** +- Modify: `packages/node/src/launch.ts:385` (option type), import line near `:8` +- Test: `packages/node/src/launch.test.ts`, `packages/bun/src/launch.test.ts`, `packages/deno/src/launch.test.ts` + +**Interfaces:** +- Consumes: `SystemTracesSampler`, `LifecycleSampler` (Task 1); `createGuardedSystemMetricsSampler` (Task 3). + +- [ ] **Step 1: Write the failing tests.** In `node/src/launch.test.ts` add (reuse `baseOptions`, `launchTracked`, `fakeScheduler`, `memStore`, `fixedClock` already in the file; `perfHooks` fake as in Task 3 β€” import `createGuardedSystemMetricsSampler` from `./guarded-system-metrics`): + +```ts +describe('systemMetricsSampler lifecycle through launch()', () => { + it('attaches the delay histogram only after the provider starts, and releases it on client.stop()', async () => { + const { perfHooks, log } = fakePerfHooks(); // the Task-3 helper, copied locally + const client = launchTracked('tok', baseOptions({ + captureStore: memStore(), clock: fixedClock, scheduler: fakeScheduler().scheduler, + systemMetricsSampler: createGuardedSystemMetricsSampler({ perfHooks }), + })); + expect(log.monitor).toBe(1); + expect(log.enable).toBeGreaterThanOrEqual(1); + await client.stop(); + expect(log.disable).toBe(1); + }); + + it('creates NO histogram when captureSystemTraces is false', async () => { + const { perfHooks, log } = fakePerfHooks(); + const client = launchTracked('tok', baseOptions({ + captureStore: memStore(), clock: fixedClock, scheduler: fakeScheduler().scheduler, + captureSystemTraces: false, + systemMetricsSampler: createGuardedSystemMetricsSampler({ perfHooks }), + })); + expect(log.monitor).toBe(0); + expect(log.enable).toBe(0); + await client.stop(); + expect(log.disable).toBe(0); + }); + + it('calls start() before the first sample and stop() on client.stop() for a lifecycle sampler', async () => { + const calls: string[] = []; + const client = launchTracked('tok', baseOptions({ + captureStore: memStore(), clock: fixedClock, scheduler: fakeScheduler().scheduler, + systemMetricsSampler: { + start: () => void calls.push('start'), + stop: () => void calls.push('stop'), + sample: () => (calls.push('sample'), []), + }, + })); + expect(calls.slice(0, 2)).toEqual(['start', 'sample']); + await client.stop(); + expect(calls.at(-1)).toBe('stop'); + }); + + it('still accepts a bare-function sampler (test stub)', () => { + const client = launchTracked('tok', baseOptions({ captureStore: memStore(), systemMetricsSampler: () => [] })); + expect(client.isLaunched()).toBe(true); + }); +}); +``` + +In `bun/src/launch.test.ts` and `deno/src/launch.test.ts` (both already `vi.mock` the guarded factory with a pass-through spy) add: + +```ts +it('injects a REAL lifecycle sampler, not a bare function a typeof-adapter would turn into a no-op start()', () => { + track(launchCore('tok', base()).client); + const sampler = vi.mocked(createGuardedSystemMetricsSampler).mock.results.at(-1)?.value; + expect(typeof sampler).toBe('object'); + expect(typeof sampler.start).toBe('function'); + expect(typeof sampler.stop).toBe('function'); +}); +``` + +- [ ] **Step 2: Run to verify failure** β€” `pnpm --filter @bugsee/node exec vitest run src/launch.test.ts` (type error on the object-valued option) and the bun/deno files. + +- [ ] **Step 3: Implement** β€” `node/src/launch.ts`: `import type { SystemTracesSampler, TraceSample } from '@bugsee/capture';` (drop `TraceSample` from the import if now unused) and + +```ts + /** Reads the current system traces. A LifecycleSampler gets start()/stop() from the traces provider (so nothing attaches when captureSystemTraces is off); a bare function is sample-only (tests). */ + systemMetricsSampler?: SystemTracesSampler; +``` + +Line ~866 (`sample: options.systemMetricsSampler ?? createNodeSystemMetricsSampler(),`) is unchanged. + +- [ ] **Step 4: Run to verify pass + package typecheck** + +Run: `pnpm --filter @bugsee/node exec tsc --noEmit && pnpm --filter @bugsee/node exec vitest run && pnpm --filter @bugsee/bun exec tsc --noEmit && pnpm --filter @bugsee/bun exec vitest run && pnpm --filter @bugsee/deno exec tsc --noEmit && pnpm --filter @bugsee/deno exec vitest run` +Expected: PASS. If `tsc` flags another consumer of the old function return type (e.g. `node/src/index.test.ts`, the electron package), adapt it to `.sample()` and note it in the commit message. + +- [ ] **Step 5: Mutator loop** β€” in `launch.ts`/provider wiring mutate: (1) pass `() => sampler.sample()` instead of the object (loses start) β†’ the guarded "log.monitor toBe(1)" test fails; (2) call `systemMetricsSampler.start()` eagerly at launch β†’ the `captureSystemTraces:false` test fails. Restore. + +- [ ] **Step 6: Commit** + +```bash +git add packages/node/src/launch.ts packages/node/src/launch.test.ts packages/bun/src/launch.test.ts packages/deno/src/launch.test.ts +git commit -m "feat(node,bun,deno): launch passes the lifecycle sampler through; pin the captureSystemTraces gate + +πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" +``` + +- [ ] **Step 7: Real-runtime check (not in `pnpm test`)** β€” `pnpm test:e2e` (boots the real SDK in node/bun/deno): confirm `event_loop_lag_*` are still present in the uploaded bundle on node and bun and **absent on deno**. This is the regression the whole seam exists to prevent. + +--- + +### Task 5: Browser sampler β€” `getBattery()` moves to `start()`, with an abort token + +**Files:** +- Modify: `packages/browser/src/system-metrics.ts` (sampler head at lines ~94-105; return) +- Modify: `packages/browser/src/launch.ts:411`, `packages/webview/src/launch.ts:256` (option type) +- Test: `packages/browser/src/system-metrics.test.ts`, `packages/browser/src/launch.test.ts`, `packages/webview/src/launch.test.ts` + +**Interfaces:** +- Consumes: `LifecycleSampler` (Task 1). Produces: `createBrowserSystemTracesSampler(env): LifecycleSampler`. + +- [ ] **Step 1: Adapt existing call sites** β€” 8 uses of `createBrowserSystemTracesSampler(...)()` β†’ `.sample()`; tests that relied on the battery being fetched at construction now call `sampler.start()` before awaiting (`await Promise.resolve()` as before). + +- [ ] **Step 2: Write the failing tests** (append to `system-metrics.test.ts`): + +```ts +describe('createBrowserSystemTracesSampler β€” lifecycle', () => { + it('does not call getBattery() at construction; start() calls it once (idempotent)', () => { + let calls = 0; + const s = createBrowserSystemTracesSampler({ + navigator: { getBattery: () => (calls++, Promise.resolve({ level: 1, charging: false })) }, + }); + expect(calls).toBe(0); + s.start(); + s.start(); + expect(calls).toBe(1); + }); + + it('reports battery once the manager resolves after start()', async () => { + const s = createBrowserSystemTracesSampler({ + navigator: { getBattery: () => Promise.resolve({ level: 0.5, charging: false }) }, + }); + s.start(); + await Promise.resolve(); + expect(byName([...s.sample()]).get('battery')).toBe(50); + }); + + it('IGNORES a getBattery() that resolves AFTER stop() (no cache, no battery trace)', async () => { + let resolve!: (m: { level: number; charging: boolean }) => void; + const s = createBrowserSystemTracesSampler({ + navigator: { getBattery: () => new Promise((r) => (resolve = r)) }, + }); + s.start(); + s.stop(); + resolve({ level: 0.9, charging: true }); + await Promise.resolve(); + expect(byName([...s.sample()]).has('battery')).toBe(false); + }); + + it('a stale generation cannot overwrite a newer start (stop β†’ start β†’ old promise resolves late)', async () => { + const resolvers: Array<(m: { level: number; charging: boolean }) => void> = []; + const s = createBrowserSystemTracesSampler({ + navigator: { getBattery: () => new Promise((r) => resolvers.push(r)) }, + }); + s.start(); + s.stop(); + s.start(); + resolvers[0]?.({ level: 0.1, charging: false }); // the OLD fetch + await Promise.resolve(); + expect(byName([...s.sample()]).has('battery')).toBe(false); + resolvers[1]?.({ level: 0.7, charging: false }); // the CURRENT fetch + await Promise.resolve(); + expect(byName([...s.sample()]).get('battery')).toBe(70); + }); + + it('stop() drops a cached manager; a restart fetches again', async () => { + let calls = 0; + const s = createBrowserSystemTracesSampler({ + navigator: { getBattery: () => (calls++, Promise.resolve({ level: 0.5, charging: false })) }, + }); + s.start(); + await Promise.resolve(); + s.stop(); + expect(byName([...s.sample()]).has('battery')).toBe(false); + s.start(); + expect(calls).toBe(2); + }); + + it('a synchronously-throwing getBattery() does not break start()', () => { + const s = createBrowserSystemTracesSampler({ + navigator: { getBattery: () => { throw new Error('SecurityError'); } }, + }); + expect(() => s.start()).not.toThrow(); + }); + + it('sample() before start() omits battery and never throws', () => { + const s = createBrowserSystemTracesSampler({ navigator: { getBattery: () => Promise.resolve({ level: 1, charging: true }) } }); + expect(byName([...s.sample()]).has('battery')).toBe(false); + }); +}); +``` + +In `browser/src/launch.test.ts` add (reusing its `baseOptions`/`launchTracked`/`vi.stubGlobal` pattern): + +```ts +it('does not fetch the BatteryManager when captureSystemTraces is false, and does when it is true', async () => { + const getBattery = vi.fn(() => Promise.resolve({ level: 1, charging: false })); + vi.stubGlobal('navigator', { userAgent: 'UA', getBattery }); + const off = await launchTracked('tok', baseOptions({ systemMetricsSampler: undefined, captureSystemTraces: false })); + expect(getBattery).not.toHaveBeenCalled(); + await off.stop(); + const on = await launchTracked('tok', baseOptions({ systemMetricsSampler: undefined })); + expect(getBattery).toHaveBeenCalledTimes(1); + await on.stop(); +}); +``` + +Add the equivalent lifecycle-sampler pass-through test to `webview/src/launch.test.ts` mirroring Task 4's "calls start() before the first sample" test (webview has its own `baseOptions`). + +- [ ] **Step 3: Run to verify failure** β€” `pnpm --filter @bugsee/browser exec vitest run src/system-metrics.test.ts`. + +- [ ] **Step 4: Implement** β€” `system-metrics.ts`: + +```ts +import type { LifecycleSampler, TraceSample } from '@bugsee/capture'; + +/** Build a system-traces sampler over the browser context APIs (default the real globals). */ +export function createBrowserSystemTracesSampler( + env: BrowserTracesEnv = realEnv(), +): LifecycleSampler { + // The BatteryManager is fetched in start() β€” NOT at construction β€” so it is never requested when + // captureSystemTraces is off. `generation` makes a fetch that resolves after stop() (or after a + // stopβ†’start cycle) a no-op: a late fulfilment must not cache a manager or surface battery traces. + let battery: BatteryManager | undefined; + let started = false; + let generation = 0; + return { + start() { + if (started) return; + started = true; + const mine = ++generation; + try { + env.navigator?.getBattery?.().then( + (manager) => { + if (started && generation === mine) battery = manager; + }, + () => {}, // unsupported / permission denied β†’ no battery traces + ); + } catch { + // a synchronous throw (e.g. a SecurityError in a sandboxed frame) just means no battery traces + } + }, + stop() { + started = false; + generation += 1; + battery = undefined; + }, + sample: (): TraceSample[] => { + // …the existing returned arrow's body, unchanged… + }, + }; +} +``` + +Widen `systemMetricsSampler?: SystemTracesSampler;` in `browser/src/launch.ts:411` and `webview/src/launch.ts:256` (`import type { SystemTracesSampler } from '@bugsee/capture'`; leave webview's `:372` drop-list alone). + +- [ ] **Step 5: Run to verify pass + typecheck** + +Run: `pnpm --filter @bugsee/browser exec tsc --noEmit && pnpm --filter @bugsee/browser exec vitest run && pnpm --filter @bugsee/webview exec tsc --noEmit && pnpm --filter @bugsee/webview exec vitest run` +Expected: PASS. + +- [ ] **Step 6: Mutator loop** β€” backup; mutate: (1) call `getBattery` at construction again; (2) drop `generation === mine`; (3) drop `started &&`; (4) drop `battery = undefined` in `stop()`; (5) drop the `try/catch`; (6) remove the `if (started) return` guard; (7) `stop()` without `generation += 1`. Confirm each fails; restore. + +- [ ] **Step 7: Commit** + +```bash +git add packages/browser/src packages/webview/src +git commit -m "feat(browser,webview): browser sampler is a lifecycle sampler; getBattery() moves to start() with an abort token + +πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +### Task 6: `charging` trace β†’ `{ state }` (the viewer-renderable shape) + +**Files:** +- Modify: `packages/browser/src/system-trace-values.ts` (add `chargingToWire` after `orientationToWire`), `packages/browser/src/system-metrics.ts` (the `charging` sample) +- Test: `packages/browser/src/system-trace-values.test.ts`, `packages/browser/src/system-metrics.test.ts` (lines ~114-180 assert the boolean) + +**Interfaces:** Produces `chargingToWire(battery: { charging: boolean; level: number }): { state: 1 | 2 | 3 }`. + +Why: the viewer's `charging` table is keyed `"0"`–`"3"` and `processCharging` takes an int or `{state, …}`. A JS boolean never matches (`false` is dropped by a falsy guard, `true` looks up `states["true"]`), so the shipped trace renders nothing (spec Β§13.4). `source` has no Battery-API equivalent and is omitted. + +- [ ] **Step 1: Write the failing tests.** `system-trace-values.test.ts`: + +```ts +describe('chargingToWire', () => { + it('not charging β†’ unplugged (1)', () => { + expect(chargingToWire({ charging: false, level: 0.4 })).toEqual({ state: 1 }); + }); + it('charging below full β†’ charging (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 level 1 β†’ full (3)', () => { + expect(chargingToWire({ charging: true, level: 1 })).toEqual({ state: 3 }); + }); + it('never invents a source, and never emits unknown (0)', () => { + for (const b of [{ charging: false, level: 0 }, { charging: true, level: 0 }, { charging: true, level: 1 }]) { + const v = chargingToWire(b); + expect(Object.keys(v)).toEqual(['state']); + expect([1, 2, 3]).toContain(v.state); + } + }); +}); +``` + +Update `system-metrics.test.ts` so every `{ name: 'charging', value: true|false }` expectation becomes `{ name: 'charging', value: { state: … } }` (`charging: true, level: 0.426` β†’ `{ state: 2 }`; `charging: false` β†’ `{ state: 1 }`; the file's `level: 1, charging: false` case β†’ `{ state: 1 }`; add one `level: 1, charging: true` case β†’ `{ state: 3 }`). + +- [ ] **Step 2: Run to verify failure** β€” `pnpm --filter @bugsee/browser exec vitest run src/system-trace-values.test.ts src/system-metrics.test.ts`. + +- [ ] **Step 3: Implement.** + +```ts +/** + * The `charging` trace in the vocabulary the viewer's table is keyed by (Android/iOS state ints: + * 0 unknown, 1 unplugged, 2 charging, 3 full) β€” an `{ state }` object, `source` omitted because the + * Battery API cannot say AC vs USB vs wireless and an invented one would be a lie. Unknown (0) is never + * emitted: with no BatteryManager the trace is omitted entirely. + */ +export function chargingToWire(battery: { charging: boolean; level: number }): { state: 1 | 2 | 3 } { + if (!battery.charging) return { state: 1 }; + return { state: battery.level >= 1 ? 3 : 2 }; +} +``` + +In `system-metrics.ts`: `{ name: 'charging', value: chargingToWire(battery) }` (import it next to `connectionTypeToWire`). Update the header comment's `battery (0-100) + charging` line. Do **not** touch `environment.hardware.charging`. + +- [ ] **Step 4: Run to verify pass** β€” same command, plus `pnpm --filter @bugsee/browser exec tsc --noEmit`. Also `grep -rn "charging" packages/*/src e2e` and fix any other assertion on the boolean (only `system-metrics.test.ts` was found at planning time). + +- [ ] **Step 5: Mutator loop** β€” mutate: (1) `>= 1` β†’ `> 1`; (2) `>= 1` β†’ `>= 0.99`; (3) swap 1/2; (4) return `{ state: 2, source: 'ac' }`; (5) invert `!battery.charging`; (6) always `{state: 2}`. Each must fail; restore. + +- [ ] **Step 6: Commit** + +```bash +git add packages/browser/src/system-trace-values.ts packages/browser/src/system-trace-values.test.ts packages/browser/src/system-metrics.ts packages/browser/src/system-metrics.test.ts +git commit -m "fix(browser): charging trace is { state } (viewer table keys 1/2/3), not a boolean the viewer cannot render + +πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" +``` + +--- + +### Task 7: Gates, record, review, PR + +- [ ] **Step 1: Full local gate** β€” `pnpm ci:local` (lint β†’ typecheck β†’ cycles β†’ per-package coverage β†’ e2e). Expected: green. Fix any coverage gap by adding a test, never by lowering a threshold. +- [ ] **Step 2: Docs** β€” add a short paragraph to `docs/PROGRESS.md` under the traces section: "ST0 done: traces provider owns `LifecycleSampler.start()/stop()`; node/guarded/browser samplers are lifecycle objects; delay histogram and `getBattery()` attach on start; `charging` is `{state}`." Commit: `docs: record ST0 (sampler lifecycle seam)`. +- [ ] **Step 3: Convergent review (`docs/implementation-standards.md` Β§7)** β€” run a multi-agent review in parallel over the branch diff: one agent per concern β€” (a) provider/sampler lifecycle correctness, (b) Bun/Deno honesty + gate wiring, (c) browser late-resolve/abort and `charging`, (d) tests (do they assert outcomes, do mutations die). Agents are read-only and must cite `file:line`. Triage, fix real findings test-first, re-run a *fresh* review until a round yields zero new real findings. +- [ ] **Step 4: Open the PR** β€” `gh pr create --draft --base main --title "feat(capture,node,browser): traces sampler lifecycle seam + charging fix (ST0)"`; body: summary of Tasks 1–6, link to `docs/design/system-traces-expansion.md` (PR #53), the test evidence (`pnpm ci:local` output, e2e on node/bun/deno), and the Review Focus list with the test that pins each item. + +--- + +## Self-Review + +**Spec coverage (design doc Β§9 ST0, Β§5, Β§12 item 5, Β§13.4):** lifecycle seam + provider owns start/stop β†’ Task 1; existing factory-time work moved (`monitorEventLoopDelay().enable()` in both the node site and the Bun/Deno `guardedEventLoop` site, `getBattery()`) β†’ Tasks 2, 3, 5; bare-function wrap only for `typeof === 'function'`, production factories return objects β†’ Tasks 1–3; three composition roots (node/browser/webview) pass the object through, Bun/Deno inject the guarded sampler β†’ Tasks 4, 5; late-resolve abort token β†’ Task 5; `charging` decision β†’ Task 6. Out of ST0 by design: browser `window`/`document` env threading and rAF (ST4), the events source and `matchMedia` (ST4b), Electron wrap (ST6), `measures*` flags beyond the existing `measuresEventLoopDelay` (ST1/ST2). **WebView:** covered for the option type and the lifecycle pass-through test; its default sampler is the same browser factory, so Task 5's behaviour tests cover it. + +**Placeholder scan:** none; every code step carries its code. Two steps say "confirm the exact signature/helper name in the file and mirror it" (`createOptionsContainer` in Task 1, the fake-histogram helper in Task 3) β€” both name the exact file to read. + +**Type consistency:** `LifecycleSampler{start,stop,sample}`, `SystemTracesSampler`, `asLifecycleSampler`, `EventLoopSource{read,start,stop}`, `chargingToWire` are used with the same names/signatures in every task; the provider option stays named `sample` (accepting the union) to keep the diff small. + +**Known risks to watch during execution:** (1) `tsc` may surface other consumers of the old function return type (the electron package, `node/src/index.test.ts`) β€” adapt them to `.sample()`; (2) ELU/CPU baselines stay at construction (deliberate, YAGNI); (3) `histogram.reset()` on a Deno/Bun histogram is assumed present as today (`guardedEventLoop` already called it); (4) all runtime facts are macOS-measured β€” the e2e step (Task 4 Step 7) is the real-runtime confirmation. From 3bd7ff322a52f58d184c98cccbacd6521dffcb47 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:07:12 +0500 Subject: [PATCH 02/20] docs(design): rewrite ST0 plan around the tracker/producer structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- docs/design/system-traces-st0-plan.md | 1730 +++++++++++++------------ 1 file changed, 909 insertions(+), 821 deletions(-) diff --git a/docs/design/system-traces-st0-plan.md b/docs/design/system-traces-st0-plan.md index b2d8c30d..5d543a0b 100644 --- a/docs/design/system-traces-st0-plan.md +++ b/docs/design/system-traces-st0-plan.md @@ -1,38 +1,42 @@ -# ST0 β€” Sampler Lifecycle Seam + `charging` Fix β€” Implementation Plan +# 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:** Give the `traces.system` platform samplers an explicit `{start, stop, sample}` lifecycle owned by the traces provider (so standing resources attach only when `captureSystemTraces` is on and are released on `stop()`), migrate the two pieces of factory-time work that already violate that rule (the node/Bun/Deno delay histogram and the browser `getBattery()` fetch), and fix the shipped `charging` trace so the viewer can render it. +**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:** `@bugsee/capture` defines `LifecycleSampler` and a `SystemTracesSampler = (() => samples) | LifecycleSampler` union; `createSystemTracesProvider` accepts the union, wraps a bare function as sample-only, and calls `start()` at the top of `onStart` and `stop()` in `onStop` β€” i.e. only after the coordinator's `captureSystemTraces` gate. The three platform factories (`createNodeSystemMetricsSampler`, `createGuardedSystemMetricsSampler`, `createBrowserSystemTracesSampler`) stop returning bare functions and return `LifecycleSampler` objects; the launch files only *pass the object through*. No new traces are added in ST0. +**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. Runtimes: Node β‰₯18, Bun, Deno, browsers. +**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 it first). Relevant sections: Β§5 *Lifecycle seam* and *Per-runtime gates*, Β§9 ST0, Β§12 items 1/5 (only item 5 is in ST0), Β§13.4 (`charging`), Β§13.5. This plan file lives on its own branch off `main`, so the spec file is only present once PR #53 merges; until then read it from that branch. +**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 gate per package:** 100% line / function / statement, β‰₯ 90% branch (vitest v8). Unreachable code needs an explicit `/* v8 ignore … */` with a one-line justification. -- **Test-first, mutator loop (`docs/implementation-standards.md` Β§2):** for every new/changed method write the failing test, implement, then inject up to 10 different bugs into the new code, confirm the test fails each time, and **roll back by restoring a saved copy (`cp`), never `git checkout`**. Never commit a mutation. -- **Typecheck is separate from tests:** vitest does not typecheck. Run `pnpm --filter @bugsee/ exec tsc --noEmit` before every commit. -- **Gate before pushing:** `pnpm ci:local` (not just `pnpm test`). -- **Shared tiers are runtime-portable:** `@bugsee/capture` and `@bugsee/core` never import `node:*` or DOM types; reach globals only through `globalThis as unknown as {…}` casts. -- **Never fabricate a reading** (CLAUDE.md, ELU/lag precedents): when a source cannot answer, the trace is **omitted**, never zeroed. -- **Bun/Deno honesty:** "the call succeeded" is not evidence. The Bun/Deno default sampler must be a real lifecycle object, never a bare function that a `typeof === 'function'` adapter would silently turn into a no-op `start()` (which would leave `event_loop_lag_*` at the idle floor through a stall). -- **Wire shape:** the `charging` trace becomes `{ state: 1 | 2 | 3 }` (viewer table keys `"0"`–`"3"`: unknown, unplugged, charging, full), `source` **omitted, never invented**. `environment.hardware.charging` stays a boolean β€” do not touch it. -- **Branching:** this work is reviewed as a PR (the design was). Create `feat/st0-lifecycle-sampler` from `main`; one commit per task; open a draft PR after Task 2 and push as you go. Attribution line on every commit/PR: `πŸ€– Generated with [Claude Code](https://claude.com/claude-code)`. +- **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 -The input classes the spec implies but a straight reading of the tasks would not exercise (each is pinned by a named test in the owning task): +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. **`sampler.start()` throws, or the first `sample()` throws, inside `onStart`** β†’ the provider must stop the sampler before rethrowing, or a half-started sampler leaks. (Task 1, test "stops the sampler when the initial emit throws".) -2. **`captureSystemTraces: false`** β†’ no histogram is created and no `getBattery()` call is made, through the *real* launch + provider path, for both the node-default and the Bun/Deno guarded sampler. (Task 4, Task 5.) -3. **Start β†’ stop β†’ start (provider cycled across launches)** β†’ the sampler re-attaches and re-releases; no double-enable, no stale battery. (Tasks 1, 2, 3, 5.) -4. **`getBattery()` resolves *after* `stop()`** β†’ the late fulfilment must not cache the manager or surface battery traces. (Task 5.) -5. **`sample()` called before `start()`** (a stub provider, a future caller) β†’ lag traces omitted, not zeroed; no throw. (Tasks 2, 3.) -6. **Bun/Deno wrap hazard** β†’ the default sampler passed through `@bugsee/{bun,deno}` is an object with real `start`/`stop`, and with traces on it enables the histogram exactly once after provider start. (Task 4.) -7. **`charging` at the edges** β†’ `level === 1` while charging is `full` (3), `0.99` is `charging` (2), not charging is `unplugged` (1), and a viewer-unknown state is never emitted. (Task 6.) +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.) --- @@ -40,1022 +44,1106 @@ The input classes the spec implies but a straight reading of the tasks would not | File | Change | Responsibility | |---|---|---| -| `packages/capture/src/system-traces-provider.ts` | modify | `LifecycleSampler`, `SystemTracesSampler`, `asLifecycleSampler`; provider owns `start()`/`stop()` | -| `packages/capture/src/system-traces-provider.test.ts` | modify | provider lifecycle tests | -| `packages/capture/src/index.ts` | modify | export the new types/helper | -| `packages/node/src/system-metrics.ts` | modify | `EventLoopSource`; node sampler returns `LifecycleSampler`; lazy histogram | -| `packages/node/src/system-metrics.test.ts` | modify | adapt `()` β†’ `.sample()`; lifecycle tests | -| `packages/node/src/guarded-system-metrics.ts` | modify | `guardedEventLoop` returns an `EventLoopSource`; defer `monitor()`+`enable()` to `start()`; guarded factory returns `LifecycleSampler` | -| `packages/node/src/guarded-system-metrics.test.ts` | modify | adapt call sites; lifecycle tests | -| `packages/node/src/launch.ts` | modify | widen `systemMetricsSampler` option type | -| `packages/node/src/launch.test.ts` | modify | end-to-end option-gate tests with the guarded sampler | -| `packages/bun/src/launch.test.ts`, `packages/deno/src/launch.test.ts` | modify | pin that the injected default is a lifecycle object | -| `packages/browser/src/system-metrics.ts` | modify | sampler returns `LifecycleSampler`; `getBattery()` moves to `start()` with an abort token | -| `packages/browser/src/system-metrics.test.ts` | modify | adapt call sites; lifecycle + late-resolve tests | -| `packages/browser/src/system-trace-values.ts` (+ `.test.ts`) | modify | `chargingToWire` | -| `packages/browser/src/launch.ts`, `packages/webview/src/launch.ts` | modify | widen the option type | -| `packages/browser/src/launch.test.ts`, `packages/webview/src/launch.test.ts` | modify | option-gate tests | -| `docs/PROGRESS.md` | modify | one-paragraph record | - -`packages/bun/src/launch.ts` and `packages/deno/src/launch.ts` need **no code change** (they already inject `createGuardedSystemMetricsSampler(...)`; the return type widens underneath them). - -## Cross-task interfaces (defined once, used by every task) +| `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 LifecycleSampler { - /** Attach every standing resource (histogram, observer, listener). Called by the provider's onStart, after the captureSystemTraces gate. Idempotent. */ - start(): void; - /** Release everything start() attached. Idempotent; a no-op before start(). */ - stop(): void; - /** Read current samples synchronously. Must be safe to call before start() (omit what is not yet attached). */ - sample(): readonly TraceSample[]; +// @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 } -export type SystemTracesSampler = (() => readonly TraceSample[]) | LifecycleSampler; -export function asLifecycleSampler(sampler: SystemTracesSampler): LifecycleSampler; - -// @bugsee/node (Task 2) -export interface EventLoopSource { - read: () => { meanMs: number; maxMs: number; p99Ms: number } | undefined; - start(): void; - stop(): void; +// 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 } -// NodeSystemMetricsDeps.eventLoop?: EventLoopReader | EventLoopSource (function form = no lifecycle) -// createNodeSystemMetricsSampler(deps?): LifecycleSampler -// createGuardedSystemMetricsSampler(deps?): LifecycleSampler -// @bugsee/browser (Task 5) -// createBrowserSystemTracesSampler(env?): LifecycleSampler -// @bugsee/browser (Task 6) -// chargingToWire(battery: { charging: boolean; level: number }): { state: 1 | 2 | 3 } +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: Provider owns the sampler lifecycle (`@bugsee/capture`) - -**Files:** -- Modify: `packages/capture/src/system-traces-provider.ts` -- Modify: `packages/capture/src/index.ts` (add exports next to the existing `createSystemTracesProvider` block at line ~64) -- Test: `packages/capture/src/system-traces-provider.test.ts` +### Task 1: Producer contract and base class (`@bugsee/capture`) -**Interfaces:** -- Produces: `LifecycleSampler`, `SystemTracesSampler`, `asLifecycleSampler`, and `SystemTracesProviderOptions.sample: SystemTracesSampler` (was `() => readonly TraceSample[]`). +**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** β€” append to `system-traces-provider.test.ts` (the file already imports `createSystemTracesProvider`, `fakeScheduler`, `buildInit`, `mkStore`, `options`, `drainSystem`; extend the import line to `import { asLifecycleSampler, createSystemTracesProvider, type LifecycleSampler, type TraceSample } from './system-traces-provider';`): +- [ ] **Step 1: Write the failing tests** (`trace-producer.test.ts`): ```ts -// A recording lifecycle sampler: `calls` is the order start/sample/stop were invoked in. -function recordingSampler(samples: TraceSample[] = []): { sampler: LifecycleSampler; calls: string[] } { - const calls: string[] = []; - const sampler: LifecycleSampler = { - start: () => void calls.push('start'), - stop: () => void calls.push('stop'), - sample: () => { - calls.push('sample'); - return samples; - }, - }; - return { sampler, calls }; +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('asLifecycleSampler', () => { - it('wraps a bare function as a sample-only sampler (start/stop are no-ops)', () => { - const fn = (): TraceSample[] => [{ name: 'a', value: 1 }]; - const wrapped = asLifecycleSampler(fn); - expect(() => { - wrapped.start(); - wrapped.stop(); - }).not.toThrow(); - expect(wrapped.sample()).toEqual([{ name: 'a', value: 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('returns a lifecycle object unchanged (same reference)', () => { - const { sampler } = recordingSampler(); - expect(asLifecycleSampler(sampler)).toBe(sampler); + 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); }); -}); -describe('createSystemTracesProvider β€” sampler lifecycle', () => { - it('starts the sampler BEFORE the initial sample, and stops it on provider stop', () => { - const fs = fakeScheduler(); - const { sampler, calls } = recordingSampler(); - const p = createSystemTracesProvider({ sample: sampler, scheduler: fs.scheduler }); - p.init(buildInit(mkStore())); - p.start(options); - expect(calls).toEqual(['start', 'sample']); + 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(); - expect(calls).toEqual(['start', 'sample', 'stop']); + p.emitAll('a', 2); + expect(got).toEqual([{ name: 'a', value: 1 }, { name: 'a', value: 1 }]); }); - it('never starts the sampler when captureSystemTraces is off (the coordinator gate)', () => { - const store = mkStore(); - const fs = fakeScheduler(); - const { sampler, calls } = recordingSampler(); - const coordinator = createCaptureCoordinator(buildInit(store)); - coordinator.addProvider( - createSystemTracesProvider({ sample: sampler, scheduler: fs.scheduler }), - ); - coordinator.start(createOptionsContainer({ [BugseeOption.CaptureSystemTraces]: false })); - expect(calls).toEqual([]); - coordinator.stop(); - expect(calls).toEqual([]); + 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('re-attaches on a second start after a stop (provider cycled across launches)', () => { - const fs = fakeScheduler(); - const { sampler, calls } = recordingSampler(); - const p = createSystemTracesProvider({ sample: sampler, scheduler: fs.scheduler }); - p.init(buildInit(mkStore())); - p.start(options); - p.stop(); - p.start(options); - expect(calls).toEqual(['start', 'sample', 'stop', 'start', 'sample']); + 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('stops the sampler when the initial emit throws, then rethrows', () => { - const fs = fakeScheduler(); - const calls: string[] = []; - const sampler: LifecycleSampler = { - start: () => void calls.push('start'), - stop: () => void calls.push('stop'), - sample: () => { - throw new Error('sample boom'); - }, - }; - const p = createSystemTracesProvider({ sample: sampler, scheduler: fs.scheduler }); - p.init(buildInit(mkStore())); - expect(() => p.start(options)).toThrow('sample boom'); - expect(calls).toEqual(['start', 'stop']); // released, not leaked - expect(fs.cleared()).toBe(0); // the timer was never created + 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('propagates a start() throw without sampling or scheduling', () => { - const fs = fakeScheduler(); - const sampled: string[] = []; - const sampler: LifecycleSampler = { - start: () => { - throw new Error('start boom'); - }, - stop: () => {}, - sample: () => { - sampled.push('sample'); - return []; - }, - }; - const p = createSystemTracesProvider({ sample: sampler, scheduler: fs.scheduler }); - p.init(buildInit(mkStore())); - expect(() => p.start(options)).toThrow('start boom'); - expect(sampled).toEqual([]); - expect(fs.interval()).toBe(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([]); }); }); ``` -(`createOptionsContainer` already accepts an initial map in this test file's sibling tests β€” confirm the exact signature at `packages/core/src/options-container.ts` and mirror how the existing "disabled β†’ no sampling" test builds its disabled container; use that form if it differs.) - -- [ ] **Step 2: Run to verify failure** +- [ ] **Step 2: Run to verify failure** β€” `pnpm --filter @bugsee/capture exec vitest run src/trace-producer.test.ts` β†’ FAIL (module missing). -Run: `pnpm --filter @bugsee/capture exec vitest run src/system-traces-provider.test.ts` -Expected: FAIL β€” `asLifecycleSampler` / `LifecycleSampler` are not exported. - -- [ ] **Step 3: Implement** β€” in `system-traces-provider.ts`: +- [ ] **Step 3: Implement** (`trace-producer.ts`): ```ts -/** One sampled system trace: a named value at the sampling instant. */ -export interface TraceSample { name: string; value: unknown } // (unchanged) - -/** - * A system-traces sampler that owns standing resources (a delay histogram, an observer, a listener). - * The PROVIDER owns the lifecycle: `start()` runs in the provider's `onStart` β€” i.e. only after the - * coordinator's `captureSystemTraces` gate β€” and `stop()` in `onStop`, so nothing attaches when the - * option is off and everything is released on `client.stop()`. - */ -export interface LifecycleSampler { - start(): void; - stop(): void; - sample(): readonly TraceSample[]; +// 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; } -/** A lifecycle sampler, or a bare function (a test stub / sample-only source) that is wrapped as one. */ -export type SystemTracesSampler = (() => readonly TraceSample[]) | LifecycleSampler; - -/** Wrap ONLY a bare function; a lifecycle object passes through by reference. */ -export function asLifecycleSampler(sampler: SystemTracesSampler): LifecycleSampler { - return typeof sampler === 'function' - ? { start: () => {}, stop: () => {}, sample: sampler } - : sampler; +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; } -``` -Change the option and the class: +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)); -```ts -export interface SystemTracesProviderOptions { - /** Reads the current system traces (the runtime-specific seam). A bare function is sample-only; a LifecycleSampler also gets start()/stop() from the provider. Required. */ - sample: SystemTracesSampler; - // intervalMs / scheduler / now unchanged -} +const keyOf = (name: string, displayId: number | undefined): string => + displayId === undefined ? name : `${displayId}:${name}`; -class SystemTracesProvider extends CaptureProviderBase { - // … - readonly #sampler: LifecycleSampler; +export abstract class TraceProducerBase implements TraceProducer { + abstract readonly name: string; + #sink: TraceSink | undefined; + readonly #last = new Map(); - constructor(options: SystemTracesProviderOptions) { - super(); - this.#sampler = asLifecycleSampler(options.sample); - // … + start(sink: TraceSink): void { + if (this.#sink !== undefined) return; + this.#sink = sink; + this.#last.clear(); + this.onStart(); } - protected onStart(): void { - this.#sampler.start(); + stop(): void { + if (this.#sink === undefined) return; try { - this.#emit(); // initial snapshot, so a short-lived session still has current values - } catch (error) { - this.#sampler.stop(); // do not leak a half-started sampler - throw error; + this.onStop(); + } finally { + this.#sink = undefined; // late tracker callbacks after stop() must not write + this.#last.clear(); } - this.#timer = this.#scheduler.setInterval(() => this.#emit(), this.#intervalMs); } - protected override onStop(): void { - this.#scheduler.clearInterval(this.#timer); - this.#timer = null; - this.#sampler.stop(); + /** 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(): void { - const timestamp = this.#now(); - for (const { name, value } of this.#sampler.sample()) { - this.capture('traces.system', timestamp, { timestamp, name, 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); } } ``` -(Remove the now-unused `#sample` field.) In `index.ts` change the export block to: +Export from `index.ts`: `export { type TraceProducer, TraceProducerBase, type TraceSink } from './trace-producer';` -```ts -export { - asLifecycleSampler, - createSystemTracesProvider, - type LifecycleSampler, - type SystemTracesProviderOptions, - type SystemTracesSampler, - type TraceSample, -} from './system-traces-provider'; -``` +- [ ] **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. -- [ ] **Step 4: Run to verify pass** +--- -Run: `pnpm --filter @bugsee/capture exec vitest run src/system-traces-provider.test.ts && pnpm --filter @bugsee/capture exec tsc --noEmit` -Expected: PASS, no type errors. (The existing function-form tests still pass untouched β€” that is the compatibility check.) +### Task 2: Provider becomes a producer container (`@bugsee/capture`) -- [ ] **Step 5: Mutator loop** β€” save `cp system-traces-provider.ts /tmp/stp.bak`; inject, one at a time, and confirm a test fails each time: (1) delete `this.#sampler.start()`; (2) move `start()` after `#emit()`; (3) drop the `stop()` in the catch; (4) drop the `stop()` in `onStop`; (5) wrap `asLifecycleSampler` for objects too (return a copy) β†’ the "same reference" test; (6) change `typeof sampler === 'function'` to `=== 'object'`; (7) call `start()` in the constructor. Restore with `cp /tmp/stp.bak system-traces-provider.ts`. +**Files:** Modify `packages/capture/src/system-traces-provider.ts` and its `.test.ts`; `index.ts` unchanged. -- [ ] **Step 6: Coverage + commit** +**Interfaces:** Consumes `TraceProducer`/`TraceSink` (Task 1). Produces the extended `SystemTracesProviderOptions` and a provider that still accepts the legacy `sample`. -Run: `pnpm --filter @bugsee/capture exec vitest run --coverage` β†’ 100% lines for this file. +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). -```bash -git add packages/capture/src/system-traces-provider.ts packages/capture/src/system-traces-provider.test.ts packages/capture/src/index.ts -git commit -m "feat(capture): traces provider owns the sampler lifecycle (start/stop) +- [ ] **Step 1: Write the failing tests** (append; `fakeScheduler`, `buildInit`, `mkStore`, `options`, `drainSystem` already exist in the file): -πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" -``` +```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 }, + ]); + }); -### Task 2: Node sampler returns a `LifecycleSampler` with a lazy delay histogram + 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'); + }); -**Files:** -- Modify: `packages/node/src/system-metrics.ts` -- Test: `packages/node/src/system-metrics.test.ts` + 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); + }); -**Interfaces:** -- Consumes: `LifecycleSampler`, `TraceSample` from `@bugsee/capture` (Task 1). -- Produces: `EventLoopSource`; `NodeSystemMetricsDeps.eventLoop?: EventLoopReader | EventLoopSource`; `createNodeSystemMetricsSampler(deps): LifecycleSampler`. + 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); + }); -- [ ] **Step 1: Adapt existing call sites** β€” every `createNodeSystemMetricsSampler(deps(...))()` in `system-metrics.test.ts` (11 call sites) becomes `createNodeSystemMetricsSampler(deps(...)).sample()`. Do this mechanically first; the tests fail to typecheck/run until Step 3 β€” that is the expected red. + 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([]); + }); -- [ ] **Step 2: Write the new failing tests** (append): + 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']); + }); -```ts -import type { LifecycleSampler } from '@bugsee/capture'; -import type { EventLoopSource } from './system-metrics'; - -describe('createNodeSystemMetricsSampler β€” lifecycle', () => { - it('returns a lifecycle sampler (start/stop/sample)', () => { - const s: LifecycleSampler = createNodeSystemMetricsSampler(deps()); - expect(typeof s.start).toBe('function'); - expect(typeof s.stop).toBe('function'); - expect(typeof s.sample).toBe('function'); + 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('forwards start/stop to an injected EventLoopSource and reads through it', () => { - const calls: string[] = []; - const source: EventLoopSource = { - read: () => ({ meanMs: 1, maxMs: 2, p99Ms: 3 }), - start: () => void calls.push('start'), - stop: () => void calls.push('stop'), - }; - const s = createNodeSystemMetricsSampler(deps({ eventLoop: source })); - expect(calls).toEqual([]); // nothing attached at construction - s.start(); - s.stop(); - expect(calls).toEqual(['start', 'stop']); - expect(byName([...s.sample()]).get('event_loop_lag_max_ms')).toBe(2); + 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('treats a bare-function eventLoop dep as lifecycle-free (start/stop are no-ops)', () => { - const s = createNodeSystemMetricsSampler(deps({ eventLoop: () => ({ meanMs: 4, maxMs: 5, p99Ms: 6 }) })); - expect(() => { - s.start(); - s.stop(); - }).not.toThrow(); - expect(byName([...s.sample()]).get('event_loop_lag_ms')).toBe(4); + 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('default source: no lag traces before start(), lag traces after start()', () => { - const { eventLoop: _omit, ...rest } = deps(); - const s = createNodeSystemMetricsSampler(rest); // real perf_hooks source - const before = byName([...s.sample()]); - expect(before.has('event_loop_lag_ms')).toBe(false); // omitted, NOT zeroed - s.start(); - try { - const after = byName([...s.sample()]); - for (const k of ['event_loop_lag_ms', 'event_loop_lag_max_ms', 'event_loop_lag_p99_ms']) { - expect(after.has(k)).toBe(true); - } - } finally { - s.stop(); - } + 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('default source: start() twice and stop() before start() are harmless; restart works', () => { - const { eventLoop: _omit, ...rest } = deps(); - const s = createNodeSystemMetricsSampler(rest); - expect(() => s.stop()).not.toThrow(); // before start - s.start(); - s.start(); // idempotent - s.stop(); - s.start(); // restart - expect(byName([...s.sample()]).has('event_loop_lag_ms')).toBe(true); - s.stop(); + 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 3: Run to verify failure** - -Run: `pnpm --filter @bugsee/node exec vitest run src/system-metrics.test.ts` -Expected: FAIL (`.sample` is not a function / `EventLoopSource` not exported). +- [ ] **Step 2: Run to verify failure** β€” `pnpm --filter @bugsee/capture exec vitest run src/system-traces-provider.test.ts` β†’ FAIL. -- [ ] **Step 4: Implement** β€” in `system-metrics.ts`: +- [ ] **Step 3: Implement.** Replace the class body in `system-traces-provider.ts`: ```ts -import type { LifecycleSampler, TraceSample } from '@bugsee/capture'; - -/** - * An event-loop reader that owns a STANDING resource (a perf_hooks delay histogram). The histogram is - * created and enabled in `start()` β€” never at construction β€” so it only exists between the traces - * provider's start and stop (i.e. only when `captureSystemTraces` is on). `read()` answers `undefined` - * (lag traces omitted, never zeroed) until `start()` has attached it. - */ -export interface EventLoopSource { - read: EventLoopReader; - start(): void; - stop(): void; -} -const toEventLoopSource = (e: EventLoopReader | EventLoopSource): EventLoopSource => - typeof e === 'function' ? { read: e, start: () => {}, stop: () => {} } : e; +import { type TraceProducer, type TraceSink } from './trace-producer'; -const createEventLoopSource = (): EventLoopSource => { - let histogram: ReturnType | undefined; - return { - start() { - histogram ??= monitorEventLoopDelay(); - histogram.enable(); - histogram.reset(); // drop anything recorded before a previous stop - }, - stop() { - histogram?.disable(); - }, - read() { - if (histogram === undefined) return undefined; // not started β†’ omit, never zero - const out = { - meanMs: eventLoopLagFromMeanNs(histogram.mean), - maxMs: eventLoopLagFromMeanNs(histogram.max), - p99Ms: eventLoopLagFromMeanNs(histogram.percentile(99)), - }; - histogram.reset(); - return out; - }, - }; -}; -``` +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; +} -Replace `createEventLoopReader` with the above; change the dep type to `eventLoop?: EventLoopReader | EventLoopSource;` and update its doc comment ("a bare function has no lifecycle"). Change the factory: +interface Held { name: string; value: unknown; timestamp: number; displayId: number | undefined } -```ts -export function createNodeSystemMetricsSampler(deps: NodeSystemMetricsDeps = {}): LifecycleSampler { - // …unchanged readers… - const loop: EventLoopSource = toEventLoopSource(deps.eventLoop ?? createEventLoopSource()); - // … - return { - start: () => loop.start(), - stop: () => loop.stop(), - sample: (): TraceSample[] => { - // body of the previous returned arrow, with `const lag = loop.read();` in place of `eventLoop()` +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()); }, }; -} -``` -Keep the CPU baseline (`lastCpu`/`lastWallMs`) and ELU baseline at construction β€” they are values, not standing resources (YAGNI: no re-baseline on restart; the sampler is rebuilt per launch). + 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 ?? (() => {}); + } -- [ ] **Step 5: Run to verify pass** + 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); + } -Run: `pnpm --filter @bugsee/node exec vitest run src/system-metrics.test.ts && pnpm --filter @bugsee/node exec tsc --noEmit` -Expected: PASS (tsc will flag `guarded-system-metrics.ts` and `launch.ts` β€” fixed in Tasks 3–4; run only this file's tests here and defer the package-wide `tsc` to Task 4). + protected override onStop(): void { + this.#scheduler.clearInterval(this.#timer); + this.#timer = null; + this.#stopProducers(); + this.#running = false; + this.#held.clear(); + } -- [ ] **Step 6: Mutator loop** β€” back up `system-metrics.ts`; mutate: (1) create the histogram in the factory body again (construction-time) β†’ "before start() omitted" test; (2) `read()` returns zeros instead of `undefined` when not started; (3) drop `histogram.disable()`; (4) drop `reset()` in `start()`; (5) `toEventLoopSource` returns `e` for functions; (6) `start: () => {}` in the returned sampler (forwarding removed). Confirm each fails; restore from the backup. + #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 + } + } -- [ ] **Step 7: Commit** + #emitLegacy(timestamp: number): void { + if (this.#sample === undefined) return; + for (const { name, value } of this.#sample()) this.#record(name, value, undefined, timestamp); + } -```bash -git add packages/node/src/system-metrics.ts packages/node/src/system-metrics.test.ts -git commit -m "feat(node): node system-metrics sampler is a lifecycle sampler; delay histogram attaches on start + #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); + } -πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" + #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. -### Task 3: Guarded (Bun/Deno) sampler β€” defer `monitor()` + `enable()` to `start()` +- [ ] **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`. -**Files:** -- Modify: `packages/node/src/guarded-system-metrics.ts` (`guardedEventLoop` lines ~52-79; `DelayHistogram` interface; factory lines ~116-128) -- Test: `packages/node/src/guarded-system-metrics.test.ts` +--- + +### Task 3: `ProcessStatsTracker` (node) β€” one timer, shared state -**Interfaces:** -- Consumes: `EventLoopSource` (Task 2), `LifecycleSampler` (Task 1). -- Produces: `createGuardedSystemMetricsSampler(deps): LifecycleSampler`. +**Files:** Create `packages/node/src/trackers/process-stats-tracker.ts` and `.test.ts`. -- [ ] **Step 1: Adapt existing call sites** β€” the 16 uses of `createGuardedSystemMetricsSampler(...)()` become `.sample()`; where an existing test asserts `enable` happened right after construction, change it to call `s.start()` first (those assertions *encode the old bug* β€” update them, do not preserve them). +**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 2: Write the failing tests** (append; reuse the file's existing fake-histogram helper if present, else this local one): +- [ ] **Step 1: Write the failing tests** (`process-stats-tracker.test.ts`): ```ts -function fakePerfHooks(opts: { monitorThrows?: boolean; enableThrows?: boolean } = {}) { - const log = { monitor: 0, enable: 0, disable: 0, reset: 0 }; - const histogram = { - enable: () => { - log.enable += 1; - if (opts.enableThrows) throw new Error('enable boom'); - }, - disable: () => void (log.disable += 1), - reset: () => void (log.reset += 1), - mean: 2e6, - max: 5e6, - percentile: () => 4e6, - }; - const perfHooks = { - monitorEventLoopDelay: () => { - log.monitor += 1; - if (opts.monitorThrows) throw new Error('monitor boom'); - return histogram; - }, - eventLoopUtilization: () => ({ utilization: 0.5, idle: 1, active: 1 }), - }; - return { perfHooks, log }; +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('createGuardedSystemMetricsSampler β€” lifecycle', () => { - it('does NOT construct or enable the histogram at construction or on sample() before start()', () => { - const { perfHooks, log } = fakePerfHooks(); - const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); - expect(log).toMatchObject({ monitor: 0, enable: 0 }); - expect(byName([...s.sample()]).has('event_loop_lag_ms')).toBe(false); // omitted, not zeroed - expect(log).toMatchObject({ monitor: 0, enable: 0 }); +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('start() constructs + enables exactly once; lag traces then appear', () => { - const { perfHooks, log } = fakePerfHooks(); - const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); - s.start(); - s.start(); // idempotent - expect(log.monitor).toBe(1); - expect(log.enable).toBeGreaterThanOrEqual(1); - const t = byName([...s.sample()]); - expect(t.get('event_loop_lag_ms')).toBe(2); - expect(t.get('event_loop_lag_max_ms')).toBe(5); - expect(t.get('event_loop_lag_p99_ms')).toBe(4); + 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('stop() disables the histogram; a restart re-enables the same histogram', () => { - const { perfHooks, log } = fakePerfHooks(); - const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); - s.start(); - s.stop(); - expect(log.disable).toBe(1); - s.start(); - expect(log.monitor).toBe(1); // reused, not rebuilt + 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('stop() before start() is a no-op', () => { - const { perfHooks, log } = fakePerfHooks(); - const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); - expect(() => s.stop()).not.toThrow(); - expect(log.disable).toBe(0); + 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('measuresEventLoopDelay:false never constructs a histogram, even after start()', () => { - const { perfHooks, log } = fakePerfHooks(); - const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks, measuresEventLoopDelay: false }); - s.start(); - s.stop(); - expect(log.monitor).toBe(0); - expect(byName([...s.sample()]).has('event_loop_lag_ms')).toBe(false); + 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('a throwing monitorEventLoopDelay degrades to omitted lag; start() does not throw', () => { - const { perfHooks } = fakePerfHooks({ monitorThrows: true }); - const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); - expect(() => s.start()).not.toThrow(); - expect(byName([...s.sample()]).has('event_loop_lag_ms')).toBe(false); + 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('a throwing enable() degrades to omitted lag; start() does not throw', () => { - const { perfHooks } = fakePerfHooks({ enableThrows: true }); - const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks }); - expect(() => s.start()).not.toThrow(); - expect(byName([...s.sample()]).has('event_loop_lag_ms')).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 runtime histogram with no disable() (Bun/Deno shape) stops cleanly', () => { - const { perfHooks } = fakePerfHooks(); - const bare = { ...perfHooks, monitorEventLoopDelay: () => { - const h = perfHooks.monitorEventLoopDelay(); - const { disable: _d, ...noDisable } = h; - return noDisable; - } }; - const s = createGuardedSystemMetricsSampler({ ...baseDeps(), perfHooks: bare }); - s.start(); - expect(() => s.stop()).not.toThrow(); + 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'); }); }); ``` -`baseDeps()` = the fully-injected `NodeSystemMetricsDeps` minus `eventLoop`/`eventLoopUtilization` (copy the helper at the top of this test file or `system-metrics.test.ts`'s `deps()`). - -- [ ] **Step 3: Run to verify failure** - -Run: `pnpm --filter @bugsee/node exec vitest run src/guarded-system-metrics.test.ts` -Expected: FAIL. - -- [ ] **Step 4: Implement** β€” in `guarded-system-metrics.ts`: +- [ ] **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 type { LifecycleSampler, TraceSample } from '@bugsee/capture'; -import { createNodeSystemMetricsSampler, type EventLoopSource, eluUnavailable, type NodeSystemMetricsDeps } from './system-metrics'; - -interface DelayHistogram { - enable(): void; - /** Absent on some runtimes (Bun/Deno histograms) β€” stop() tolerates that. */ - disable?(): void; - reset(): void; - readonly mean: number; - readonly max: number; - percentile(p: number): number; -} - -const unavailableLoop = (): undefined => undefined; -const unavailableSource: EventLoopSource = { read: unavailableLoop, start: () => {}, stop: () => {} }; +import { cpus, freemem, totalmem } from 'node:os'; +import { performance } from 'node:perf_hooks'; +import { InterceptorBase, type Scheduler } from '@bugsee/core'; -// Builds the lag source. NOTHING is constructed or enabled until start(): a histogram that exists at -// factory time runs with captureSystemTraces:false and survives client.stop(). Answers `undefined` β€” -// never zero β€” when the runtime cannot give a real reading, or before start(). -function guardedEventLoop(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 - histogram = undefined; - } - }, - stop() { - try { - histogram?.disable?.(); - } catch { - // releasing is best-effort - } - }, - read() { - if (histogram === undefined) return undefined; - try { - const out = { - meanMs: lagMs(histogram.mean), - maxMs: lagMs(histogram.max), - p99Ms: lagMs(histogram.percentile(99)), - }; - histogram.reset(); - return out; - } catch { - return undefined; - } - }, - }; +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; } -``` -Factory: `: LifecycleSampler`, and `eventLoop: measuresEventLoopDelay ? guardedEventLoop(perf.monitorEventLoopDelay) : unavailableSource,`. Leave `guardedElu` unchanged (a baseline value, not a standing resource). +const defaultScheduler: Scheduler = { + setInterval: (cb, ms) => { + const h = setInterval(cb, ms); + (h as { unref?: () => void }).unref?.(); + return h; + }, + clearInterval: (h) => clearInterval(h as ReturnType), +}; -- [ ] **Step 5: Run to verify pass** +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, + }; + } -Run: `pnpm --filter @bugsee/node exec vitest run src/guarded-system-metrics.test.ts src/system-metrics.test.ts` -Expected: PASS. + /** Latest stats (read once on activation, then replaced on every poll). Safe for many callers. */ + current(): ProcessStats { + this.#cached ??= this.#read(); + return this.#cached; + } -- [ ] **Step 6: Mutator loop** β€” backup; mutate: (1) call `monitor()` at factory time; (2) swallow nothing in `start()` (remove try/catch) β†’ throw tests; (3) drop `unusable` guard (retry every start); (4) `stop()` without `?.disable?.()`; (5) `read()` returns zero object when `histogram === undefined`; (6) use `unavailableSource` regardless of the flag (inverted ternary); (7) remove `reset()` in `start()`. Confirm each is caught; restore. + 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); + } -- [ ] **Step 7: Commit** + protected override onDeactivate(): void { + this.#d.scheduler.clearInterval(this.#timer); + this.#timer = null; + this.#d.eventLoop?.stop(); + this.#cached = undefined; + } -```bash -git add packages/node/src/guarded-system-metrics.ts packages/node/src/guarded-system-metrics.test.ts -git commit -m "feat(node): guarded sampler defers the delay histogram to start() and releases it on stop() + #poll(): void { + try { + this.#cached = this.#read(); + } catch (error) { + this.#d.onError?.(error); + return; + } + this.emit('stats', this.#cached); + } -πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" + #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: Wire node / Bun / Deno launch and pin the gate end-to-end +### Task 4: Guarded event-loop sources + node producers + factory -**Files:** -- Modify: `packages/node/src/launch.ts:385` (option type), import line near `:8` -- Test: `packages/node/src/launch.test.ts`, `packages/bun/src/launch.test.ts`, `packages/deno/src/launch.test.ts` +**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: `SystemTracesSampler`, `LifecycleSampler` (Task 1); `createGuardedSystemMetricsSampler` (Task 3). +**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.** In `node/src/launch.test.ts` add (reuse `baseOptions`, `launchTracked`, `fakeScheduler`, `memStore`, `fixedClock` already in the file; `perfHooks` fake as in Task 3 β€” import `createGuardedSystemMetricsSampler` from `./guarded-system-metrics`): +- [ ] **Step 1: Write the failing tests.** -```ts -describe('systemMetricsSampler lifecycle through launch()', () => { - it('attaches the delay histogram only after the provider starts, and releases it on client.stop()', async () => { - const { perfHooks, log } = fakePerfHooks(); // the Task-3 helper, copied locally - const client = launchTracked('tok', baseOptions({ - captureStore: memStore(), clock: fixedClock, scheduler: fakeScheduler().scheduler, - systemMetricsSampler: createGuardedSystemMetricsSampler({ perfHooks }), - })); - expect(log.monitor).toBe(1); - expect(log.enable).toBeGreaterThanOrEqual(1); - await client.stop(); - expect(log.disable).toBe(1); - }); +`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.) - it('creates NO histogram when captureSystemTraces is false', async () => { - const { perfHooks, log } = fakePerfHooks(); - const client = launchTracked('tok', baseOptions({ - captureStore: memStore(), clock: fixedClock, scheduler: fakeScheduler().scheduler, - captureSystemTraces: false, - systemMetricsSampler: createGuardedSystemMetricsSampler({ perfHooks }), - })); - expect(log.monitor).toBe(0); - expect(log.enable).toBe(0); - await client.stop(); - expect(log.disable).toBe(0); - }); +`trace-producers.test.ts`: - it('calls start() before the first sample and stop() on client.stop() for a lifecycle sampler', async () => { - const calls: string[] = []; - const client = launchTracked('tok', baseOptions({ - captureStore: memStore(), clock: fixedClock, scheduler: fakeScheduler().scheduler, - systemMetricsSampler: { - start: () => void calls.push('start'), - stop: () => void calls.push('stop'), - sample: () => (calls.push('sample'), []), - }, - })); - expect(calls.slice(0, 2)).toEqual(['start', 'sample']); - await client.stop(); - expect(calls.at(-1)).toBe('stop'); - }); +```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, +}); - it('still accepts a bare-function sampler (test stub)', () => { - const client = launchTracked('tok', baseOptions({ captureStore: memStore(), systemMetricsSampler: () => [] })); - expect(client.isLaunched()).toBe(true); +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); }); + // … }); ``` -In `bun/src/launch.test.ts` and `deno/src/launch.test.ts` (both already `vi.mock` the guarded factory with a pass-through spy) add: +(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 -it('injects a REAL lifecycle sampler, not a bare function a typeof-adapter would turn into a no-op start()', () => { - track(launchCore('tok', base()).client); - const sampler = vi.mocked(createGuardedSystemMetricsSampler).mock.results.at(-1)?.value; - expect(typeof sampler).toBe('object'); - expect(typeof sampler.start).toBe('function'); - expect(typeof sampler.stop).toBe('function'); -}); +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; } + }, + }; +} ``` -- [ ] **Step 2: Run to verify failure** β€” `pnpm --filter @bugsee/node exec vitest run src/launch.test.ts` (type error on the object-valued option) and the bun/deno files. - -- [ ] **Step 3: Implement** β€” `node/src/launch.ts`: `import type { SystemTracesSampler, TraceSample } from '@bugsee/capture';` (drop `TraceSample` from the import if now unused) and +`trace-producers.ts`: five `TraceProducerBase` subclasses, each constructed with the shared tracker: ```ts - /** Reads the current system traces. A LifecycleSampler gets start()/stop() from the traces provider (so nothing attaches when captureSystemTraces is off); a bare function is sample-only (tests). */ - systemMetricsSampler?: SystemTracesSampler; +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)]; +} ``` -Line ~866 (`sample: options.systemMetricsSampler ?? createNodeSystemMetricsSampler(),`) is unchanged. - -- [ ] **Step 4: Run to verify pass + package typecheck** - -Run: `pnpm --filter @bugsee/node exec tsc --noEmit && pnpm --filter @bugsee/node exec vitest run && pnpm --filter @bugsee/bun exec tsc --noEmit && pnpm --filter @bugsee/bun exec vitest run && pnpm --filter @bugsee/deno exec tsc --noEmit && pnpm --filter @bugsee/deno exec vitest run` -Expected: PASS. If `tsc` flags another consumer of the old function return type (e.g. `node/src/index.test.ts`, the electron package), adapt it to `.sample()` and note it in the commit message. - -- [ ] **Step 5: Mutator loop** β€” in `launch.ts`/provider wiring mutate: (1) pass `() => sampler.sample()` instead of the object (loses start) β†’ the guarded "log.monitor toBe(1)" test fails; (2) call `systemMetricsSampler.start()` eagerly at launch β†’ the `captureSystemTraces:false` test fails. Restore. +(`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 6: Commit** +- [ ] **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`. -```bash -git add packages/node/src/launch.ts packages/node/src/launch.test.ts packages/bun/src/launch.test.ts packages/deno/src/launch.test.ts -git commit -m "feat(node,bun,deno): launch passes the lifecycle sampler through; pin the captureSystemTraces gate +--- -πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" -``` +### Task 5: Wire node / Bun / Deno launch -- [ ] **Step 7: Real-runtime check (not in `pnpm test`)** β€” `pnpm test:e2e` (boots the real SDK in node/bun/deno): confirm `event_loop_lag_*` are still present in the uploaded bundle on node and bun and **absent on deno**. This is the regression the whole seam exists to prevent. +**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`): -### Task 5: Browser sampler β€” `getBattery()` moves to `start()`, with an abort token +```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'); +}); -**Files:** -- Modify: `packages/browser/src/system-metrics.ts` (sampler head at lines ~94-105; return) -- Modify: `packages/browser/src/launch.ts:411`, `packages/webview/src/launch.ts:256` (option type) -- Test: `packages/browser/src/system-metrics.test.ts`, `packages/browser/src/launch.test.ts`, `packages/webview/src/launch.test.ts` +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); +}); -**Interfaces:** -- Consumes: `LifecycleSampler` (Task 1). Produces: `createBrowserSystemTracesSampler(env): LifecycleSampler`. +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'); +}); +``` -- [ ] **Step 1: Adapt existing call sites** β€” 8 uses of `createBrowserSystemTracesSampler(...)()` β†’ `.sample()`; tests that relied on the battery being fetched at construction now call `sampler.start()` before awaiting (`await Promise.resolve()` as before). +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: Write the failing tests** (append to `system-metrics.test.ts`): +- [ ] **Step 2: Run to verify failure.** +- [ ] **Step 3: Implement.** `node/src/launch.ts`: ```ts -describe('createBrowserSystemTracesSampler β€” lifecycle', () => { - it('does not call getBattery() at construction; start() calls it once (idempotent)', () => { - let calls = 0; - const s = createBrowserSystemTracesSampler({ - navigator: { getBattery: () => (calls++, Promise.resolve({ level: 1, charging: false })) }, - }); - expect(calls).toBe(0); - s.start(); - s.start(); - expect(calls).toBe(1); - }); - - it('reports battery once the manager resolves after start()', async () => { - const s = createBrowserSystemTracesSampler({ - navigator: { getBattery: () => Promise.resolve({ level: 0.5, charging: false }) }, - }); - s.start(); - await Promise.resolve(); - expect(byName([...s.sample()]).get('battery')).toBe(50); - }); - - it('IGNORES a getBattery() that resolves AFTER stop() (no cache, no battery trace)', async () => { - let resolve!: (m: { level: number; charging: boolean }) => void; - const s = createBrowserSystemTracesSampler({ - navigator: { getBattery: () => new Promise((r) => (resolve = r)) }, - }); - s.start(); - s.stop(); - resolve({ level: 0.9, charging: true }); - await Promise.resolve(); - expect(byName([...s.sample()]).has('battery')).toBe(false); - }); +/** Trace producers (each subscribes to trackers; the provider starts/stops them). Default: the node set. */ +systemTraceProducers?: readonly TraceProducer[]; +``` - it('a stale generation cannot overwrite a newer start (stop β†’ start β†’ old promise resolves late)', async () => { - const resolvers: Array<(m: { level: number; charging: boolean }) => void> = []; - const s = createBrowserSystemTracesSampler({ - navigator: { getBattery: () => new Promise((r) => resolvers.push(r)) }, - }); - s.start(); - s.stop(); - s.start(); - resolvers[0]?.({ level: 0.1, charging: false }); // the OLD fetch - await Promise.resolve(); - expect(byName([...s.sample()]).has('battery')).toBe(false); - resolvers[1]?.({ level: 0.7, charging: false }); // the CURRENT fetch - await Promise.resolve(); - expect(byName([...s.sample()]).get('battery')).toBe(70); - }); +```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 } : {}), + }), +); +``` - it('stop() drops a cached manager; a restart fetches again', async () => { - let calls = 0; - const s = createBrowserSystemTracesSampler({ - navigator: { getBattery: () => (calls++, Promise.resolve({ level: 0.5, charging: false })) }, - }); - s.start(); - await Promise.resolve(); - s.stop(); - expect(byName([...s.sample()]).has('battery')).toBe(false); - s.start(); - expect(calls).toBe(2); - }); +(`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). - it('a synchronously-throwing getBattery() does not break start()', () => { - const s = createBrowserSystemTracesSampler({ - navigator: { getBattery: () => { throw new Error('SecurityError'); } }, - }); - expect(() => s.start()).not.toThrow(); - }); +- [ ] **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`. - it('sample() before start() omits battery and never throws', () => { - const s = createBrowserSystemTracesSampler({ navigator: { getBattery: () => Promise.resolve({ level: 1, charging: true }) } }); - expect(byName([...s.sample()]).has('battery')).toBe(false); - }); -}); -``` +--- -In `browser/src/launch.test.ts` add (reusing its `baseOptions`/`launchTracked`/`vi.stubGlobal` pattern): +### Task 6: Browser β€” `BatteryTracker`, `BatteryProducer`, the `charging` fix -```ts -it('does not fetch the BatteryManager when captureSystemTraces is false, and does when it is true', async () => { - const getBattery = vi.fn(() => Promise.resolve({ level: 1, charging: false })); - vi.stubGlobal('navigator', { userAgent: 'UA', getBattery }); - const off = await launchTracked('tok', baseOptions({ systemMetricsSampler: undefined, captureSystemTraces: false })); - expect(getBattery).not.toHaveBeenCalled(); - await off.stop(); - const on = await launchTracked('tok', baseOptions({ systemMetricsSampler: undefined })); - expect(getBattery).toHaveBeenCalledTimes(1); - await on.stop(); -}); -``` +**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`. -Add the equivalent lifecycle-sampler pass-through test to `webview/src/launch.test.ts` mirroring Task 4's "calls start() before the first sample" test (webview has its own `baseOptions`). +- [ ] **Step 1: Write the failing tests.** -- [ ] **Step 3: Run to verify failure** β€” `pnpm --filter @bugsee/browser exec vitest run src/system-metrics.test.ts`. +`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`. -- [ ] **Step 4: Implement** β€” `system-metrics.ts`: +`battery-tracker.test.ts`: ```ts -import type { LifecycleSampler, TraceSample } from '@bugsee/capture'; - -/** Build a system-traces sampler over the browser context APIs (default the real globals). */ -export function createBrowserSystemTracesSampler( - env: BrowserTracesEnv = realEnv(), -): LifecycleSampler { - // The BatteryManager is fetched in start() β€” NOT at construction β€” so it is never requested when - // captureSystemTraces is off. `generation` makes a fetch that resolves after stop() (or after a - // stopβ†’start cycle) a no-op: a late fulfilment must not cache a manager or surface battery traces. - let battery: BatteryManager | undefined; - let started = false; - let generation = 0; +function mkManager(level = 0.5, charging = false) { + const listeners = new Map void>>(); return { - start() { - if (started) return; - started = true; - const mine = ++generation; - try { - env.navigator?.getBattery?.().then( - (manager) => { - if (started && generation === mine) battery = manager; - }, - () => {}, // unsupported / permission denied β†’ no battery traces - ); - } catch { - // a synchronous throw (e.g. a SecurityError in a sandboxed frame) just means no battery traces - } - }, - stop() { - started = false; - generation += 1; - battery = undefined; - }, - sample: (): TraceSample[] => { - // …the existing returned arrow's body, unchanged… - }, + 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), }; } ``` -Widen `systemMetricsSampler?: SystemTracesSampler;` in `browser/src/launch.ts:411` and `webview/src/launch.ts:256` (`import type { SystemTracesSampler } from '@bugsee/capture'`; leave webview's `:372` drop-list alone). - -- [ ] **Step 5: Run to verify pass + typecheck** +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. -Run: `pnpm --filter @bugsee/browser exec tsc --noEmit && pnpm --filter @bugsee/browser exec vitest run && pnpm --filter @bugsee/webview exec tsc --noEmit && pnpm --filter @bugsee/webview exec vitest run` -Expected: PASS. +`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. -- [ ] **Step 6: Mutator loop** β€” backup; mutate: (1) call `getBattery` at construction again; (2) drop `generation === mine`; (3) drop `started &&`; (4) drop `battery = undefined` in `stop()`; (5) drop the `try/catch`; (6) remove the `if (started) return` guard; (7) `stop()` without `generation += 1`. Confirm each fails; restore. +`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 7: Commit** +- [ ] **Step 2: Run to verify failure** (`pnpm --filter @bugsee/browser exec vitest run`). +- [ ] **Step 3: Implement.** -```bash -git add packages/browser/src packages/webview/src -git commit -m "feat(browser,webview): browser sampler is a lifecycle sampler; getBattery() moves to start() with an abort token +```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 } -πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" -``` +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; } -### Task 6: `charging` trace β†’ `{ state }` (the viewer-renderable shape) + current(): BatteryState | undefined { return this.#state; } -**Files:** -- Modify: `packages/browser/src/system-trace-values.ts` (add `chargingToWire` after `orientationToWire`), `packages/browser/src/system-metrics.ts` (the `charging` sample) -- Test: `packages/browser/src/system-trace-values.test.ts`, `packages/browser/src/system-metrics.test.ts` (lines ~114-180 assert the boolean) + 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 */ } + } -**Interfaces:** Produces `chargingToWire(battery: { charging: boolean; level: number }): { state: 1 | 2 | 3 }`. + 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; + } -Why: the viewer's `charging` table is keyed `"0"`–`"3"` and `processCharging` takes an int or `{state, …}`. A JS boolean never matches (`false` is dropped by a falsy guard, `true` looks up `states["true"]`), so the shipped trace renders nothing (spec Β§13.4). `source` has no Battery-API equivalent and is omitted. + #publish(): void { + if (this.#manager === undefined) return; + this.#state = { level: this.#manager.level, charging: this.#manager.charging }; + this.emit('state', this.#state); + } +} +``` -- [ ] **Step 1: Write the failing tests.** `system-trace-values.test.ts`: +`chargingToWire` in `system-trace-values.ts`: ```ts -describe('chargingToWire', () => { - it('not charging β†’ unplugged (1)', () => { - expect(chargingToWire({ charging: false, level: 0.4 })).toEqual({ state: 1 }); - }); - it('charging below full β†’ charging (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 level 1 β†’ full (3)', () => { - expect(chargingToWire({ charging: true, level: 1 })).toEqual({ state: 3 }); - }); - it('never invents a source, and never emits unknown (0)', () => { - for (const b of [{ charging: false, level: 0 }, { charging: true, level: 0 }, { charging: true, level: 1 }]) { - const v = chargingToWire(b); - expect(Object.keys(v)).toEqual(['state']); - expect([1, 2, 3]).toContain(v.state); - } - }); -}); +/** 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 }; +} ``` -Update `system-metrics.test.ts` so every `{ name: 'charging', value: true|false }` expectation becomes `{ name: 'charging', value: { state: … } }` (`charging: true, level: 0.426` β†’ `{ state: 2 }`; `charging: false` β†’ `{ state: 1 }`; the file's `level: 1, charging: false` case β†’ `{ state: 1 }`; add one `level: 1, charging: true` case β†’ `{ state: 3 }`). - -- [ ] **Step 2: Run to verify failure** β€” `pnpm --filter @bugsee/browser exec vitest run src/system-trace-values.test.ts src/system-metrics.test.ts`. - -- [ ] **Step 3: Implement.** +`trace-producers.ts`: ```ts -/** - * The `charging` trace in the vocabulary the viewer's table is keyed by (Android/iOS state ints: - * 0 unknown, 1 unplugged, 2 charging, 3 full) β€” an `{ state }` object, `source` omitted because the - * Battery API cannot say AC vs USB vs wireless and an invented one would be a lie. Unknown (0) is never - * emitted: with no BatteryManager the trace is omitted entirely. - */ -export function chargingToWire(battery: { charging: boolean; level: number }): { state: 1 | 2 | 3 } { - if (!battery.charging) return { state: 1 }; - return { state: battery.level >= 1 ? 3 : 2 }; +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))]; } ``` -In `system-metrics.ts`: `{ name: 'charging', value: chargingToWire(battery) }` (import it next to `connectionTypeToWire`). Update the header comment's `battery (0-100) + charging` line. Do **not** touch `environment.hardware.charging`. - -- [ ] **Step 4: Run to verify pass** β€” same command, plus `pnpm --filter @bugsee/browser exec tsc --noEmit`. Also `grep -rn "charging" packages/*/src e2e` and fix any other assertion on the boolean (only `system-metrics.test.ts` was found at planning time). - -- [ ] **Step 5: Mutator loop** β€” mutate: (1) `>= 1` β†’ `> 1`; (2) `>= 1` β†’ `>= 0.99`; (3) swap 1/2; (4) return `{ state: 2, source: 'ac' }`; (5) invert `!battery.charging`; (6) always `{state: 2}`. Each must fail; restore. - -- [ ] **Step 6: Commit** +(`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`. -```bash -git add packages/browser/src/system-trace-values.ts packages/browser/src/system-trace-values.test.ts packages/browser/src/system-metrics.ts packages/browser/src/system-metrics.test.ts -git commit -m "fix(browser): charging trace is { state } (viewer table keys 1/2/3), not a boolean the viewer cannot render - -πŸ€– Generated with [Claude Code](https://claude.com/claude-code)" -``` +- [ ] **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: Full local gate** β€” `pnpm ci:local` (lint β†’ typecheck β†’ cycles β†’ per-package coverage β†’ e2e). Expected: green. Fix any coverage gap by adding a test, never by lowering a threshold. -- [ ] **Step 2: Docs** β€” add a short paragraph to `docs/PROGRESS.md` under the traces section: "ST0 done: traces provider owns `LifecycleSampler.start()/stop()`; node/guarded/browser samplers are lifecycle objects; delay histogram and `getBattery()` attach on start; `charging` is `{state}`." Commit: `docs: record ST0 (sampler lifecycle seam)`. -- [ ] **Step 3: Convergent review (`docs/implementation-standards.md` Β§7)** β€” run a multi-agent review in parallel over the branch diff: one agent per concern β€” (a) provider/sampler lifecycle correctness, (b) Bun/Deno honesty + gate wiring, (c) browser late-resolve/abort and `charging`, (d) tests (do they assert outcomes, do mutations die). Agents are read-only and must cite `file:line`. Triage, fix real findings test-first, re-run a *fresh* review until a round yields zero new real findings. -- [ ] **Step 4: Open the PR** β€” `gh pr create --draft --base main --title "feat(capture,node,browser): traces sampler lifecycle seam + charging fix (ST0)"`; body: summary of Tasks 1–6, link to `docs/design/system-traces-expansion.md` (PR #53), the test evidence (`pnpm ci:local` output, e2e on node/bun/deno), and the Review Focus list with the test that pins each item. +- [ ] **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 (design doc Β§9 ST0, Β§5, Β§12 item 5, Β§13.4):** lifecycle seam + provider owns start/stop β†’ Task 1; existing factory-time work moved (`monitorEventLoopDelay().enable()` in both the node site and the Bun/Deno `guardedEventLoop` site, `getBattery()`) β†’ Tasks 2, 3, 5; bare-function wrap only for `typeof === 'function'`, production factories return objects β†’ Tasks 1–3; three composition roots (node/browser/webview) pass the object through, Bun/Deno inject the guarded sampler β†’ Tasks 4, 5; late-resolve abort token β†’ Task 5; `charging` decision β†’ Task 6. Out of ST0 by design: browser `window`/`document` env threading and rAF (ST4), the events source and `matchMedia` (ST4b), Electron wrap (ST6), `measures*` flags beyond the existing `measuresEventLoopDelay` (ST1/ST2). **WebView:** covered for the option type and the lifecycle pass-through test; its default sampler is the same browser factory, so Task 5's behaviour tests cover it. +**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:** none; every code step carries its code. Two steps say "confirm the exact signature/helper name in the file and mirror it" (`createOptionsContainer` in Task 1, the fake-histogram helper in Task 3) β€” both name the exact file to read. +**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:** `LifecycleSampler{start,stop,sample}`, `SystemTracesSampler`, `asLifecycleSampler`, `EventLoopSource{read,start,stop}`, `chargingToWire` are used with the same names/signatures in every task; the provider option stays named `sample` (accepting the union) to keep the diff small. +**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. -**Known risks to watch during execution:** (1) `tsc` may surface other consumers of the old function return type (the electron package, `node/src/index.test.ts`) β€” adapt them to `.sample()`; (2) ELU/CPU baselines stay at construction (deliberate, YAGNI); (3) `histogram.reset()` on a Deno/Bun histogram is assumed present as today (`guardedEventLoop` already called it); (4) all runtime facts are macOS-measured β€” the e2e step (Task 4 Step 7) is the real-runtime confirmation. +**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. From f7fb6cf2230b5116607ec70ef8d0246bd758e289 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:12:43 +0500 Subject: [PATCH 03/20] feat(capture): TraceProducer contract and base (baseline-friendly emit, de-dup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/capture/src/index.ts | 1 + packages/capture/src/trace-producer.test.ts | 136 ++++++++++++++++++++ packages/capture/src/trace-producer.ts | 72 +++++++++++ 3 files changed, 209 insertions(+) create mode 100644 packages/capture/src/trace-producer.test.ts create mode 100644 packages/capture/src/trace-producer.ts diff --git a/packages/capture/src/index.ts b/packages/capture/src/index.ts index b1f3e8fb..c7444407 100644 --- a/packages/capture/src/index.ts +++ b/packages/capture/src/index.ts @@ -65,6 +65,7 @@ export { type SystemTracesProviderOptions, type TraceSample, } from './system-traces-provider'; +export { type TraceProducer, TraceProducerBase, type TraceSink } from './trace-producer'; export { createTraceparentDecorator, type ParsedTraceparent, diff --git a/packages/capture/src/trace-producer.test.ts b/packages/capture/src/trace-producer.test.ts new file mode 100644 index 00000000..f8d4c627 --- /dev/null +++ b/packages/capture/src/trace-producer.test.ts @@ -0,0 +1,136 @@ +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[] } => { + 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([]); + }); + + 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..c05e35ea --- /dev/null +++ b/packages/capture/src/trace-producer.ts @@ -0,0 +1,72 @@ +// 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); + } +} From bca9d2e86b00276507719456cf6322d7d0d82b06 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:15:15 +0500 Subject: [PATCH 04/20] feat(capture): traces provider is a producer container (isolation, last-value refresh), legacy sample kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- .../src/system-traces-provider.test.ts | 367 ++++++++++++++++++ .../capture/src/system-traces-provider.ts | 112 +++++- 2 files changed, 468 insertions(+), 11 deletions(-) diff --git a/packages/capture/src/system-traces-provider.test.ts b/packages/capture/src/system-traces-provider.test.ts index 36a77f76..d730aa41 100644 --- a/packages/capture/src/system-traces-provider.test.ts +++ b/packages/capture/src/system-traces-provider.test.ts @@ -13,6 +13,7 @@ import { import { BugseeOption } from '@bugsee/protocol'; import { describe, expect, it, vi } from 'vitest'; import { createSystemTracesProvider, 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 +151,369 @@ 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 } = {}, + ) { + super(); + this.name = name; + } + protected onStart(): void { + this.startedCount += 1; + for (const [n, v, d] of this.seed) 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(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]]); + 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], + ]); + 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('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(); + 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]]); + 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]]); + 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]]); + 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/); + }); +}); diff --git a/packages/capture/src/system-traces-provider.ts b/packages/capture/src/system-traces-provider.ts index 5d9b6cd5..a4543587 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,136 @@ 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 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 #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) => { + 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.#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 + } 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); } - #emit(): void { - const timestamp = this.#now(); - for (const { name, value } of this.#sample()) { - this.capture('traces.system', timestamp, { timestamp, name, value }); + #safeStop(producer: TraceProducer): void { + try { + producer.stop(); + } catch (error) { + this.#onError(error); } } } From f2d6db6f601a1aaaa532e624fd267c600beecaee Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:18:00 +0500 Subject: [PATCH 05/20] =?UTF-8?q?feat(node):=20ProcessStatsTracker=20?= =?UTF-8?q?=E2=80=94=20one=20timer,=20shared=20state,=20lazy=20event-loop?= =?UTF-8?q?=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- .../trackers/process-stats-tracker.test.ts | 239 ++++++++++++++++++ .../src/trackers/process-stats-tracker.ts | 152 +++++++++++ 2 files changed, 391 insertions(+) create mode 100644 packages/node/src/trackers/process-stats-tracker.test.ts create mode 100644 packages/node/src/trackers/process-stats-tracker.ts 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..a23b6a8f --- /dev/null +++ b/packages/node/src/trackers/process-stats-tracker.test.ts @@ -0,0 +1,239 @@ +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'); + }); + + 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', async () => { + const tracker = new ProcessStatsTracker({ intervalMs: 5 }); + const got: ProcessStats[] = []; + const off = tracker.on('stats', (s) => got.push(s)); + await new Promise((r) => setTimeout(r, 40)); + off(); + expect(got.length).toBeGreaterThan(0); + }); + + 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); + }); +}); 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..45b3a0fd --- /dev/null +++ b/packages/node/src/trackers/process-stats-tracker.ts @@ -0,0 +1,152 @@ +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; + #lastCpu = { user: 0, system: 0 }; + #lastWallMs = 0; + #cached: ProcessStats | undefined; + + 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. */ + 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.#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.#eventLoop?.stop(); + this.#cached = undefined; + } + + #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 } : {}), + }; + } +} From ae7ab555fea8220e2ec69c9b4549de61ab1a5279 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:21:18 +0500 Subject: [PATCH 06/20] feat(node): node trace producers over ProcessStatsTracker; guarded lazy event-loop source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/node/src/index.test.ts | 2 + packages/node/src/index.ts | 6 + packages/node/src/trace-producers.test.ts | 242 ++++++++++++++++++ packages/node/src/trace-producers.ts | 111 ++++++++ .../src/trackers/guarded-event-loop.test.ts | 228 +++++++++++++++++ .../node/src/trackers/guarded-event-loop.ts | 99 +++++++ 6 files changed, 688 insertions(+) create mode 100644 packages/node/src/trace-producers.test.ts create mode 100644 packages/node/src/trace-producers.ts create mode 100644 packages/node/src/trackers/guarded-event-loop.test.ts create mode 100644 packages/node/src/trackers/guarded-event-loop.ts 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/trace-producers.test.ts b/packages/node/src/trace-producers.test.ts new file mode 100644 index 00000000..80bf4ff7 --- /dev/null +++ b/packages/node/src/trace-producers.test.ts @@ -0,0 +1,242 @@ +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 = { + enable() { + state.enabled = true; + }, + disable() { + state.enabled = false; + state.disables += 1; + }, + reset() {}, + mean: 2e6, + max: 9e6, + percentile: () => 7e6, + }; + return { + state, + perfHooks: { + monitorEventLoopDelay: () => { + state.monitor += 1; + return histogram; + }, + eventLoopUtilization: elu as never, + }, + }; +} + +const eventLoop = { + read: () => ({ meanMs: 1, maxMs: 2, p99Ms: 3 }), + start: () => {}, + stop: () => {}, +}; + +describe('createNodeSystemTraceProducers', () => { + it('emits the SAME trace names and values as the legacy sampler (parity)', () => { + const deps = constDeps({ eventLoop, eventLoopUtilization: () => 0.5 }); + const h = harness(deps); + h.startAll(); + h.tick(); + const legacy = createNodeSystemMetricsSampler({ ...deps, eventLoop: eventLoop.read })(); + 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('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('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('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..bd13fa62 --- /dev/null +++ b/packages/node/src/trackers/guarded-event-loop.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from 'vitest'; +import { + createGuardedElu, + createGuardedEventLoopSource, + eluUnavailable, + realPerfHooks, +} from './guarded-event-loop'; + +// A controllable event-loop delay histogram (readings in ns). +function fakePerfHooks( + readings = { mean: 2e6, max: 9e6, p99: 7e6 }, + 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; + }, + }; + 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 }); + 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 as zero lag', () => { + const { monitor } = fakePerfHooks({ mean: Number.NaN, max: 5e6, p99: 5e6 }); + const src = createGuardedEventLoopSource(monitor); + src.start(); + expect(src.read()).toEqual({ meanMs: 0, maxMs: 5, p99Ms: 5 }); + }); + + 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..aa98650c --- /dev/null +++ b/packages/node/src/trackers/guarded-event-loop.ts @@ -0,0 +1,99 @@ +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; + 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 non-finite (e.g. NaN before the first tick). */ +const lagMs = (ns: number): number => (Number.isFinite(ns) ? ns / 1e6 : 0); + +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 + histogram = undefined; + } + }, + 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; + } + }, + }; +} + +// 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; + } + }; +} From 402761e6eafe9d80c441dd46c45a77f9d4b900e1 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:30:46 +0500 Subject: [PATCH 07/20] feat(node,bun,deno): launch wires trace producers; Deno opts out of event-loop lag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/bun/src/launch.test.ts | 37 ++++++++- packages/bun/src/launch.ts | 6 +- packages/deno/src/launch.test.ts | 31 +++++-- packages/deno/src/launch.ts | 6 +- packages/node/src/launch.test.ts | 137 +++++++++++++++++++++++++++++++ packages/node/src/launch.ts | 11 ++- 6 files changed, 212 insertions(+), 16 deletions(-) diff --git a/packages/bun/src/launch.test.ts b/packages/bun/src/launch.test.ts index 66125528..0f133b56 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 { 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 { @@ -87,6 +98,30 @@ 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)', () => { + vi.mocked(createNodeSystemTraceProducers).mockClear(); + track(launchCore('tok', base()).client); + const arg = vi.mocked(createNodeSystemTraceProducers).mock.calls[0]?.[0]; + expect(arg?.measuresEventLoopDelay).not.toBe(false); + }); it('reports the Bun RUNTIME identity in the environment envelope', () => { const { client, internals } = launchCore('tok', base()); track(client); diff --git a/packages/bun/src/launch.ts b/packages/bun/src/launch.ts index 934f8362..a1d47bc6 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,13 @@ 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(), ...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/deno/src/launch.test.ts b/packages/deno/src/launch.test.ts index a6a4e2fb..d771bc6f 100644 --- a/packages/deno/src/launch.test.ts +++ b/packages/deno/src/launch.test.ts @@ -1,17 +1,14 @@ 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 { denoSystemProbe } from './environment'; @@ -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), }; }); @@ -275,8 +272,28 @@ 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).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); + }); }); diff --git a/packages/deno/src/launch.ts b/packages/deno/src/launch.ts index 7709a159..c884411d 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,7 @@ 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 }), ...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/launch.test.ts b/packages/node/src/launch.test.ts index e260094c..876278d6 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,141 @@ 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('the default producers attach nothing when captureSystemTraces is false, and release everything on stop()', async () => { + const fs = fakeScheduler(); + const { calls, deps } = producerDeps(fs); + const off = launchTracked( + 'tok', + baseOptions({ + captureStore: memStore(), + clock: fixedClock, + scheduler: fs.scheduler, + captureSystemTraces: false, + systemMetricsSampler: undefined, + systemTraceProducers: createNodeSystemTraceProducers(deps), + }), + ); + expect(calls).toEqual([]); + await off.stop(); + expect(calls).toEqual([]); + const on = launchTracked( + 'tok', + baseOptions({ + captureStore: memStore(), + clock: fixedClock, + scheduler: fs.scheduler, + systemMetricsSampler: undefined, + systemTraceProducers: createNodeSystemTraceProducers(deps), + }), + ); + expect(calls).toEqual(['monitor', 'enable']); + await on.stop(); + expect(calls.at(-1)).toBe('disable'); + }); + + it.each([ + { maxRecordingTime: 8, refreshMs: 2000 }, // 8s/4 = 2s + { maxRecordingTime: 2, refreshMs: 1000 }, // 0.5s clamped up to 1s + { 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), + 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('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..9a36c702 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -8,6 +8,7 @@ import { createSystemTracesProvider, installNetworkCapture, type NetworkCapture, + type TraceProducer, type TraceSample, } from '@bugsee/capture'; import { @@ -133,7 +134,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 +384,8 @@ 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. A supplied `systemMetricsSampler` wins over this. */ + 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 +866,11 @@ 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() }), + refreshMs: Math.min(10_000, Math.max(1_000, (maxRecordingTime * 1000) / 4)), + ...(options.onError !== undefined ? { onError: options.onError } : {}), ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), }), ); From e53286e49b0ddaedc2cc97824aab8243dc278170 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:37:14 +0500 Subject: [PATCH 08/20] feat(browser,webview): BatteryTracker + BatteryProducer (event-driven, abortable); charging is { state } MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/browser/src/index.ts | 2 + packages/browser/src/launch.test.ts | 34 ++++ packages/browser/src/launch.ts | 7 + packages/browser/src/system-metrics.test.ts | 48 +----- packages/browser/src/system-metrics.ts | 28 +--- .../browser/src/system-trace-values.test.ts | 24 +++ packages/browser/src/system-trace-values.ts | 8 + packages/browser/src/trace-producers.test.ts | 132 +++++++++++++++ packages/browser/src/trace-producers.ts | 53 ++++++ .../src/trackers/battery-tracker.test.ts | 158 ++++++++++++++++++ .../browser/src/trackers/battery-tracker.ts | 75 +++++++++ packages/webview/src/launch.test.ts | 23 +++ packages/webview/src/launch.ts | 7 + 13 files changed, 531 insertions(+), 68 deletions(-) create mode 100644 packages/browser/src/trace-producers.test.ts create mode 100644 packages/browser/src/trace-producers.ts create mode 100644 packages/browser/src/trackers/battery-tracker.test.ts create mode 100644 packages/browser/src/trackers/battery-tracker.ts 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..8d13a736 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -1014,6 +1014,40 @@ 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('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..3c284e74 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -92,6 +92,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 +797,12 @@ async function buildLaunch( client.addCaptureProvider( createSystemTracesProvider({ sample: options.systemMetricsSampler ?? createBrowserSystemTracesSampler(), + producers: createBrowserSystemTraceProducers(), + refreshMs: Math.min( + 10_000, + Math.max(1_000, (resolved.options.get(BugseeOption.Duration, 60) * 1000) / 4), + ), + ...(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..8b6022c1 --- /dev/null +++ b/packages/browser/src/trace-producers.test.ts @@ -0,0 +1,132 @@ +import { createSystemTracesProvider, 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('publishes the tracker current() immediately when already resolved (second producer start)', async () => { + const m = mkManager(0.5, false); + const producers = createBrowserSystemTraceProducers({ getBattery: () => Promise.resolve(m) }); + const first = run(producers); + await flush(); + first.p.stop(); // tracker deactivates -> current() undefined + const again = run(producers); // restart: re-fetches, emits again after resolve + await flush(); + expect(again.out.map((o) => o.name)).toEqual(['battery', 'charging']); + }); + + it('constructs nothing at call time (no getBattery call)', () => { + const getBattery = vi.fn(); + createBrowserSystemTraceProducers({ getBattery }); + expect(getBattery).not.toHaveBeenCalled(); + }); + + it('with captureSystemTraces:false (provider never started) getBattery is never called', () => { + const getBattery = vi.fn(() => Promise.resolve(mkManager(1, false))); + const provider = createSystemTracesProvider({ + producers: createBrowserSystemTraceProducers({ getBattery }), + }); + expect(provider).toBeDefined(); + 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..96b632f5 --- /dev/null +++ b/packages/browser/src/trace-producers.ts @@ -0,0 +1,53 @@ +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) }; +} + +export function createBrowserSystemTraceProducers(env?: BatteryEnv): TraceProducer[] { + return [new BatteryProducer(new BatteryTracker(env ?? realBatteryEnv()))]; +} 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..f015b65c --- /dev/null +++ b/packages/browser/src/trackers/battery-tracker.test.ts @@ -0,0 +1,158 @@ +import { 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(); + }); +}); diff --git a/packages/browser/src/trackers/battery-tracker.ts b/packages/browser/src/trackers/battery-tracker.ts new file mode 100644 index 00000000..48f5831d --- /dev/null +++ b/packages/browser/src/trackers/battery-tracker.ts @@ -0,0 +1,75 @@ +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; + + constructor(env: BatteryEnv, onError?: (error: unknown) => void) { + super(onError); + this.#env = env; + } + + /** 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 + const onChange = (): void => this.#publish(manager); + this.#manager = manager; + this.#onChange = onChange; + manager.addEventListener?.('levelchange', onChange); + manager.addEventListener?.('chargingchange', onChange); + this.#publish(manager); + }, + () => {}, // 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 + const onChange = this.#onChange; + if (onChange !== undefined) { + this.#manager?.removeEventListener?.('levelchange', onChange); + this.#manager?.removeEventListener?.('chargingchange', onChange); + } + this.#onChange = undefined; + this.#manager = undefined; + this.#state = undefined; + } + + #publish(manager: BatteryManagerLike): void { + this.#state = { level: manager.level, charging: manager.charging }; + this.emit('state', this.#state); + } +} diff --git a/packages/webview/src/launch.test.ts b/packages/webview/src/launch.test.ts index 75810f7c..de954bf4 100644 --- a/packages/webview/src/launch.test.ts +++ b/packages/webview/src/launch.test.ts @@ -911,6 +911,29 @@ 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) => JSON.stringify(e.p)); + expect(traces.some((t) => t.includes('"battery"') && t.includes('100'))).toBe(true); + expect(traces.some((t) => t.includes('"charging"') && t.includes('"state":3'))).toBe(true); + } 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..24eac188 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, @@ -671,6 +672,12 @@ export function launchWithSeams( client.addCaptureProvider( createSystemTracesProvider({ sample: options.systemMetricsSampler ?? createBrowserSystemTracesSampler(), + producers: createBrowserSystemTraceProducers(), + refreshMs: Math.min( + 10_000, + Math.max(1_000, (resolved.options.get(BugseeOption.Duration, 60) * 1000) / 4), + ), + ...(options.onError !== undefined ? { onError: options.onError } : {}), ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), }), ); From e744f7067f71a999cf48ec0d22b683f50c8e6b8c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:38:38 +0500 Subject: [PATCH 09/20] =?UTF-8?q?docs:=20record=20ST0=E2=80=B2=20(trace=20?= =?UTF-8?q?producers=20over=20shared=20trackers)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5d3ab72e..d2e5c5df 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -391,6 +391,14 @@ solid), in slices: - **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. +- **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. 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 From 80a1fcc02a777b466d1f328649a06d445568fe66 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:44:39 +0500 Subject: [PATCH 10/20] test(bugsee): classify systemTraceProducers as a reset relaunch option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/bugsee/src/relaunch-options.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bugsee/src/relaunch-options.test.ts b/packages/bugsee/src/relaunch-options.test.ts index bb1bcbe2..58df89d8 100644 --- a/packages/bugsee/src/relaunch-options.test.ts +++ b/packages/bugsee/src/relaunch-options.test.ts @@ -196,6 +196,7 @@ const RESET_OPTIONS: readonly string[] = [ 'reportTrigger', 'shutdownTimeoutMs', 'startInBlackout', + 'systemTraceProducers', 'traceInteractions', 'traceNavigations', 'tracePropagationOrigin', From a7fe9b1433bad2ed489b7ef5abad75ab0dc183fa Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:59:08 +0500 Subject: [PATCH 11/20] fix(capture): refresh only HELD trace values; a failed poll no longer freezes at its last value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TraceSink.emit gains an optional 4th argument { hold?: boolean }. TraceProducerBase.emit sends hold: false (polled cpu/memory/lag/ELU), emitIfChanged sends hold: true (battery/charging). The provider keeps a value for the refreshMs re-record ONLY when hold === true; legacy sample() rows are never held. Before, a tracker whose poll threw stopped emitting and the provider re-recorded the last good cpu/memory value every refreshMs β€” a flat, fabricated line. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/capture/src/index.ts | 7 +- .../src/system-traces-provider.test.ts | 110 +++++++++++++++--- .../capture/src/system-traces-provider.ts | 25 +++- packages/capture/src/trace-producer.test.ts | 29 ++++- packages/capture/src/trace-producer.ts | 41 +++++-- packages/node/src/launch.test.ts | 2 +- 6 files changed, 183 insertions(+), 31 deletions(-) diff --git a/packages/capture/src/index.ts b/packages/capture/src/index.ts index c7444407..4c4cb0b7 100644 --- a/packages/capture/src/index.ts +++ b/packages/capture/src/index.ts @@ -65,7 +65,12 @@ export { type SystemTracesProviderOptions, type TraceSample, } from './system-traces-provider'; -export { type TraceProducer, TraceProducerBase, type TraceSink } from './trace-producer'; +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 d730aa41..8d454f46 100644 --- a/packages/capture/src/system-traces-provider.test.ts +++ b/packages/capture/src/system-traces-provider.test.ts @@ -159,14 +159,18 @@ class Fixed extends TraceProducerBase { constructor( name: string, readonly seed: Array<[string, unknown, number?]>, - private readonly opts: { throwStart?: boolean; throwStop?: boolean } = {}, + 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.emit(n, v, d); + 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 { @@ -174,7 +178,7 @@ class Fixed extends TraceProducerBase { if (this.opts.throwStop) throw new Error(`${this.name} stop boom`); } push(n: string, v: unknown): void { - this.emit(n, v); + this.#out(n, v); } } const values = async (store: CaptureStore) => @@ -278,7 +282,7 @@ describe('createSystemTracesProvider β€” producers', () => { const store = mkStore(); const fs = fakeScheduler(); let t = 0; - const a = new Fixed('a', [['battery', 50]]); + const a = new Fixed('a', [['battery', 50]], { held: true }); const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, @@ -303,10 +307,14 @@ describe('createSystemTracesProvider β€” producers', () => { const store = mkStore(); const fs = fakeScheduler(); let t = 0; - const a = new Fixed('a', [ - ['o', 1, 0], - ['o', 2, 1], - ]); + const a = new Fixed( + 'a', + [ + ['o', 1, 0], + ['o', 2, 1], + ], + { held: true }, + ); const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, @@ -325,11 +333,87 @@ describe('createSystemTracesProvider β€” producers', () => { ]); }); + 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]]); + const a = new Fixed('a', [['x', 1]], { held: true }); const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, @@ -347,7 +431,7 @@ describe('createSystemTracesProvider β€” producers', () => { const store = mkStore(); const fs = fakeScheduler(); let t = 0; - const a = new Fixed('a', [['x', 0]]); + const a = new Fixed('a', [['x', 0]], { held: true }); const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, @@ -367,7 +451,7 @@ describe('createSystemTracesProvider β€” producers', () => { const store = mkStore(); const fs = fakeScheduler(); let t = 0; - const a = new Fixed('a', [['x', 1]]); + const a = new Fixed('a', [['x', 1]], { held: true }); const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, @@ -389,7 +473,7 @@ describe('createSystemTracesProvider β€” producers', () => { const store = mkStore(); const fs = fakeScheduler(); let t = 0; - const a = new Fixed('a', [['x', 1]]); + const a = new Fixed('a', [['x', 1]], { held: true }); const p = createSystemTracesProvider({ producers: [a], scheduler: fs.scheduler, @@ -409,7 +493,7 @@ describe('createSystemTracesProvider β€” producers', () => { const fs = fakeScheduler(); let t = 0; let fail = true; - const a = new Fixed('a', [['x', 1]]); + const a = new Fixed('a', [['x', 1]], { held: true }); const p = createSystemTracesProvider({ producers: [a], sample: () => { diff --git a/packages/capture/src/system-traces-provider.ts b/packages/capture/src/system-traces-provider.ts index a4543587..0c1cf2be 100644 --- a/packages/capture/src/system-traces-provider.ts +++ b/packages/capture/src/system-traces-provider.ts @@ -40,7 +40,11 @@ export interface SystemTracesProviderOptions { scheduler?: Scheduler; /** Wall-clock source for the entry timestamp; injectable for tests. Default Date.now. */ 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. */ + /** + * 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; @@ -68,8 +72,8 @@ class SystemTracesProvider extends CaptureProviderBase { #running = false; #timer: unknown = null; readonly #sink: TraceSink = { - emit: (name, value, displayId) => { - if (this.#running) this.#record(name, value, displayId, this.#now()); + emit: (name, value, displayId, options) => { + if (this.#running) this.#record(name, value, displayId, this.#now(), options?.hold === true); }, }; @@ -123,7 +127,7 @@ class SystemTracesProvider extends CaptureProviderBase { 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); + this.#record(held.name, held.value, held.displayId, now, true); } } } catch (error) { @@ -133,16 +137,25 @@ class SystemTracesProvider extends CaptureProviderBase { #emitLegacy(timestamp: number): void { if (this.#sample === undefined) return; - for (const { name, value } of this.#sample()) this.#record(name, value, undefined, timestamp); + for (const { name, value } of this.#sample()) { + this.#record(name, value, undefined, timestamp, false); // re-read every tick: never held + } } - #record(name: string, value: unknown, displayId: number | undefined, timestamp: number): void { + #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, diff --git a/packages/capture/src/trace-producer.test.ts b/packages/capture/src/trace-producer.test.ts index f8d4c627..452cd860 100644 --- a/packages/capture/src/trace-producer.test.ts +++ b/packages/capture/src/trace-producer.test.ts @@ -2,13 +2,17 @@ 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[] } => { +const mkSink = (): { sink: TraceSink; got: Emitted[]; holds: unknown[] } => { const got: Emitted[] = []; + const holds: unknown[] = []; return { got, + holds, sink: { - emit: (name, value, displayId) => - void got.push({ name, value, ...(displayId !== undefined ? { displayId } : {}) }), + emit: (name, value, displayId, options) => { + got.push({ name, value, ...(displayId !== undefined ? { displayId } : {}) }); + holds.push(options?.hold); + }, }, }; }; @@ -109,6 +113,25 @@ describe('TraceProducerBase', () => { 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 { diff --git a/packages/capture/src/trace-producer.ts b/packages/capture/src/trace-producer.ts index c05e35ea..61f59447 100644 --- a/packages/capture/src/trace-producer.ts +++ b/packages/capture/src/trace-producer.ts @@ -4,10 +4,25 @@ // 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. */ - emit(name: string, value: unknown, displayId?: number): void; + /** + * 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 { @@ -57,16 +72,28 @@ export abstract class TraceProducerBase implements TraceProducer { /** Unsubscribe here. */ protected onStop(): void {} - /** Always emit (polled families: cpu / memory emit every tick, like Android). */ + /** + * 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.#sink?.emit(name, value, displayId); - if (this.#sink !== undefined) this.#last.set(keyOf(name, displayId), value); + this.#send(name, value, displayId, false); } - /** Emit only when the value differs from the last emitted for this name+display (iOS emitIfChangedName). */ + /** + * 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.emit(name, value, displayId); + 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/node/src/launch.test.ts b/packages/node/src/launch.test.ts index 876278d6..7ae968c0 100644 --- a/packages/node/src/launch.test.ts +++ b/packages/node/src/launch.test.ts @@ -1436,7 +1436,7 @@ describe('launch β€” system trace producers', () => { const store = memStore(); const once: TraceProducer = { name: 'once', - start: (sink) => sink.emit('held_trace', 1), + start: (sink) => sink.emit('held_trace', 1, undefined, { hold: true }), stop: () => {}, }; launchTracked( From e39ea62e0e172dfde45da01d5e520d56b8f3f57c Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 17:00:35 +0500 Subject: [PATCH 12/20] fix(node): no fabricated event-loop lag baseline; disable a histogram whose reset() failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right after enable()+reset() the delay histogram has no samples (mean NaN, max 0, p99 ~0), and the producer baseline read it as a 0 ms 'healthy loop' on every start/restart. read() now returns undefined until the histogram has sampled (count === 0 when exposed, else a non-finite mean), so the lag traces are omitted at the baseline. A start whose enable() succeeded but reset() threw now disables the histogram before dropping it. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/node/src/trace-producers.test.ts | 30 ++++++ .../src/trackers/guarded-event-loop.test.ts | 95 ++++++++++++++++++- .../node/src/trackers/guarded-event-loop.ts | 18 +++- 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/packages/node/src/trace-producers.test.ts b/packages/node/src/trace-producers.test.ts index 80bf4ff7..2c266af2 100644 --- a/packages/node/src/trace-producers.test.ts +++ b/packages/node/src/trace-producers.test.ts @@ -48,6 +48,7 @@ function fakePerfHooks( ) { const state = { monitor: 0, enabled: false, disables: 0 }; const histogram = { + count: 3, // samples taken; 0 = nothing measured yet enable() { state.enabled = true; }, @@ -62,6 +63,7 @@ function fakePerfHooks( }; return { state, + histogram, perfHooks: { monitorEventLoopDelay: () => { state.monitor += 1; @@ -144,6 +146,34 @@ describe('createNodeSystemTraceProducers', () => { 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 }); diff --git a/packages/node/src/trackers/guarded-event-loop.test.ts b/packages/node/src/trackers/guarded-event-loop.test.ts index bd13fa62..5ef30b61 100644 --- a/packages/node/src/trackers/guarded-event-loop.test.ts +++ b/packages/node/src/trackers/guarded-event-loop.test.ts @@ -7,8 +7,14 @@ import { } 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: 2e6, max: 9e6, p99: 7e6 }, + 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 }; @@ -38,6 +44,10 @@ function fakePerfHooks( 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; @@ -94,7 +104,7 @@ describe('createGuardedEventLoopSource', () => { }); it('read() is undefined before start(), then ns to ms, and resets the histogram', () => { - const { state, monitor } = fakePerfHooks({ mean: 2e6, max: 9e6, p99: 7e6 }); + const { state, monitor } = fakePerfHooks({ mean: 2e6, max: 9e6, p99: 7e6, count: 1 }); const src = createGuardedEventLoopSource(monitor); expect(src.read()).toBeUndefined(); src.start(); @@ -103,11 +113,86 @@ describe('createGuardedEventLoopSource', () => { expect(state.resets).toBe(before + 1); }); - it('treats a non-finite reading as zero lag', () => { - const { monitor } = fakePerfHooks({ mean: Number.NaN, max: 5e6, p99: 5e6 }); + 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: 0, maxMs: 5, p99Ms: 5 }); + 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', () => { diff --git a/packages/node/src/trackers/guarded-event-loop.ts b/packages/node/src/trackers/guarded-event-loop.ts index aa98650c..97efd0bc 100644 --- a/packages/node/src/trackers/guarded-event-loop.ts +++ b/packages/node/src/trackers/guarded-event-loop.ts @@ -11,6 +11,8 @@ 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; @@ -35,9 +37,17 @@ export const realPerfHooks: PerfHooks = { export { eluUnavailable }; -/** ns β†’ ms; 0 when non-finite (e.g. NaN before the first tick). */ +/** 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; @@ -50,6 +60,11 @@ export function createGuardedEventLoopSource(monitor: () => DelayHistogram): Eve 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; } }, @@ -63,6 +78,7 @@ export function createGuardedEventLoopSource(monitor: () => DelayHistogram): Eve 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), From 37b094e3d3fff523410c08454f83e47c8e3c9d3a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 17:02:03 +0500 Subject: [PATCH 13/20] fix(node): guard ProcessStatsTracker activation; unwind and retry on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InterceptorBase marks the tracker active (with the subscriber registered) before onActivate runs, so a throwing cpuUsage()/monotonicNowMs()/eventLoop.start()/scheduler.setInterval left an 'active' tracker with no timer and threw out of on(), leaking the producer's listener. onActivate now reports the failure via onError, clears the timer and stops the event-loop source only if it started; the next activation retries. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- .../trackers/process-stats-tracker.test.ts | 122 ++++++++++++++++++ .../src/trackers/process-stats-tracker.ts | 37 +++++- 2 files changed, 153 insertions(+), 6 deletions(-) diff --git a/packages/node/src/trackers/process-stats-tracker.test.ts b/packages/node/src/trackers/process-stats-tracker.test.ts index a23b6a8f..2cd0f414 100644 --- a/packages/node/src/trackers/process-stats-tracker.test.ts +++ b/packages/node/src/trackers/process-stats-tracker.test.ts @@ -236,4 +236,126 @@ describe('ProcessStatsTracker', () => { 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('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(1); + }); + + 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 index 45b3a0fd..14b83aed 100644 --- a/packages/node/src/trackers/process-stats-tracker.ts +++ b/packages/node/src/trackers/process-stats-tracker.ts @@ -70,6 +70,7 @@ export class ProcessStatsTracker extends InterceptorBase<{ stats: ProcessStats } 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; @@ -97,18 +98,42 @@ export class ProcessStatsTracker extends InterceptorBase<{ stats: ProcessStats } 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 and everything started is unwound; the + // tracker then re-tries on its next activation (after the current subscribers leave). protected onActivate(): void { - this.#lastCpu = this.#d.cpuUsage(); - this.#lastWallMs = this.#d.monotonicNowMs(); - this.#cached = undefined; - this.#eventLoop?.start(); - this.#timer = this.#d.scheduler.setInterval(() => this.#poll(), this.#d.intervalMs); + 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) { + this.#onError?.(error); + this.#timer = null; + try { + this.#stopEventLoop(); + } catch { + /* best-effort unwind; the original failure is the one reported */ + } + } + } + + /** 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 { this.#d.scheduler.clearInterval(this.#timer); this.#timer = null; - this.#eventLoop?.stop(); + this.#stopEventLoop(); this.#cached = undefined; } From 72eacb04719d182f600d0cd26aa49e552e8f4a17 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 17:03:52 +0500 Subject: [PATCH 14/20] fix(node,bun,deno): the launch scheduler and onError reach the system-trace tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createNodeSystemTraceProducers() was called with no scheduler/onError, so the shared tracker's sampling timer ignored an injected options.scheduler (always a real timer) and its poll errors were swallowed. Node, Bun and Deno launches now forward options.scheduler / options.onError into the factory when defined (Bun/Deno keep the factory call before ...options; Deno keeps measuresEventLoopDelay: false). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/bun/src/launch.test.ts | 55 ++++++++++++++++++++++++++++++++ packages/bun/src/launch.ts | 6 +++- packages/deno/src/launch.test.ts | 55 ++++++++++++++++++++++++++++++++ packages/deno/src/launch.ts | 7 +++- packages/node/src/launch.test.ts | 45 ++++++++++++++++++++++++++ packages/node/src/launch.ts | 10 +++++- 6 files changed, 175 insertions(+), 3 deletions(-) diff --git a/packages/bun/src/launch.test.ts b/packages/bun/src/launch.test.ts index 0f133b56..29a577b2 100644 --- a/packages/bun/src/launch.test.ts +++ b/packages/bun/src/launch.test.ts @@ -296,3 +296,58 @@ 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'); + }); +}); diff --git a/packages/bun/src/launch.ts b/packages/bun/src/launch.ts index a1d47bc6..fb2f127f 100644 --- a/packages/bun/src/launch.ts +++ b/packages/bun/src/launch.ts @@ -19,7 +19,11 @@ import { bunSystemProbe } from './environment'; export function launchCore(appToken: string, options: BugseeLaunchOptions = {}): LaunchResult { return nodeLaunchCore(appToken, { systemProbe: bunSystemProbe, - systemTraceProducers: createNodeSystemTraceProducers(), + 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/deno/src/launch.test.ts b/packages/deno/src/launch.test.ts index d771bc6f..c5b101c2 100644 --- a/packages/deno/src/launch.test.ts +++ b/packages/deno/src/launch.test.ts @@ -297,3 +297,58 @@ describe('@bugsee/deno β€” the event-loop metrics Deno cannot measure', () => { 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'); + }); +}); diff --git a/packages/deno/src/launch.ts b/packages/deno/src/launch.ts index c884411d..ff6cb95d 100644 --- a/packages/deno/src/launch.ts +++ b/packages/deno/src/launch.ts @@ -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. - systemTraceProducers: createNodeSystemTraceProducers({ 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/launch.test.ts b/packages/node/src/launch.test.ts index 7ae968c0..0e272688 100644 --- a/packages/node/src/launch.test.ts +++ b/packages/node/src/launch.test.ts @@ -1466,6 +1466,51 @@ describe('launch β€” system trace producers', () => { } }); + 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: () => {} }; diff --git a/packages/node/src/launch.ts b/packages/node/src/launch.ts index 9a36c702..61c7c579 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -868,7 +868,15 @@ function buildLaunch(appToken: string, options: BugseeLaunchOptions = {}): Launc createSystemTracesProvider({ ...(options.systemMetricsSampler !== undefined ? { sample: options.systemMetricsSampler } // legacy injector wins (tests) - : { producers: options.systemTraceProducers ?? createNodeSystemTraceProducers() }), + : { + 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: Math.min(10_000, Math.max(1_000, (maxRecordingTime * 1000) / 4)), ...(options.onError !== undefined ? { onError: options.onError } : {}), ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), From 1144f946fbb27173d839516c711961f22f99b40a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 17:07:07 +0500 Subject: [PATCH 15/20] fix(browser,webview): no unhandled rejection after getBattery() resolves; report via onError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The try/catch covered only the synchronous getBattery() call, so a throwing addEventListener or a throwing level/charging getter inside the fulfilment rejected a promise nobody handled. The whole fulfilment is now guarded: on failure the listeners are detached, no baseline is kept, onError is called and the next activation retries. A later change event whose getter throws is reported rather than thrown into the BatteryManager dispatch, and the deactivation removal is best-effort. createBrowserSystemTraceProducers(env?, onError?) forwards onError to the tracker; browser and webview launches pass options.onError. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/browser/src/launch.test.ts | 16 ++ packages/browser/src/launch.ts | 2 +- packages/browser/src/trace-producers.test.ts | 14 ++ packages/browser/src/trace-producers.ts | 11 +- .../src/trackers/battery-tracker.test.ts | 143 +++++++++++++++++- .../browser/src/trackers/battery-tracker.ts | 60 ++++++-- packages/webview/src/launch.test.ts | 25 +++ packages/webview/src/launch.ts | 2 +- 8 files changed, 255 insertions(+), 18 deletions(-) diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index 8d13a736..4b026117 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -1037,6 +1037,22 @@ describe('launch', () => { ); }); + 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('never calls getBattery when captureSystemTraces is false', async () => { const getBattery = vi.fn(() => Promise.resolve(batteryManager())); vi.stubGlobal('navigator', { getBattery }); diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 3c284e74..38e57676 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -797,7 +797,7 @@ async function buildLaunch( client.addCaptureProvider( createSystemTracesProvider({ sample: options.systemMetricsSampler ?? createBrowserSystemTracesSampler(), - producers: createBrowserSystemTraceProducers(), + producers: createBrowserSystemTraceProducers(undefined, options.onError), refreshMs: Math.min( 10_000, Math.max(1_000, (resolved.options.get(BugseeOption.Duration, 60) * 1000) / 4), diff --git a/packages/browser/src/trace-producers.test.ts b/packages/browser/src/trace-producers.test.ts index 8b6022c1..4cebb69d 100644 --- a/packages/browser/src/trace-producers.test.ts +++ b/packages/browser/src/trace-producers.test.ts @@ -89,6 +89,20 @@ describe('BatteryProducer', () => { expect(again.out.map((o) => o.name)).toEqual(['battery', 'charging']); }); + 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 }); diff --git a/packages/browser/src/trace-producers.ts b/packages/browser/src/trace-producers.ts index 96b632f5..04b4dc4f 100644 --- a/packages/browser/src/trace-producers.ts +++ b/packages/browser/src/trace-producers.ts @@ -48,6 +48,13 @@ function realBatteryEnv(): BatteryEnv { return { getBattery: () => getBattery.call(nav) }; } -export function createBrowserSystemTraceProducers(env?: BatteryEnv): TraceProducer[] { - return [new BatteryProducer(new BatteryTracker(env ?? realBatteryEnv()))]; +/** + * 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 index f015b65c..1383d88c 100644 --- a/packages/browser/src/trackers/battery-tracker.test.ts +++ b/packages/browser/src/trackers/battery-tracker.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +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) { @@ -155,4 +155,145 @@ describe('BatteryTracker', () => { 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 index 48f5831d..eb3c909d 100644 --- a/packages/browser/src/trackers/battery-tracker.ts +++ b/packages/browser/src/trackers/battery-tracker.ts @@ -26,9 +26,12 @@ export class BatteryTracker extends InterceptorBase<{ state: BatteryState }> { #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. */ @@ -42,12 +45,7 @@ export class BatteryTracker extends InterceptorBase<{ state: BatteryState }> { this.#env.getBattery?.().then( (manager) => { if (this.#generation !== mine) return; // stopped (or restarted) while in flight - const onChange = (): void => this.#publish(manager); - this.#manager = manager; - this.#onChange = onChange; - manager.addEventListener?.('levelchange', onChange); - manager.addEventListener?.('chargingchange', onChange); - this.#publish(manager); + this.#attach(manager); }, () => {}, // unsupported / denied -> no battery traces ); @@ -56,20 +54,56 @@ export class BatteryTracker extends InterceptorBase<{ state: BatteryState }> { } } + // 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; - if (onChange !== undefined) { - this.#manager?.removeEventListener?.('levelchange', onChange); - this.#manager?.removeEventListener?.('chargingchange', onChange); - } - this.#onChange = undefined; 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 { - this.#state = { level: manager.level, charging: manager.charging }; - this.emit('state', this.#state); + const state = { level: manager.level, charging: manager.charging }; // getters may throw + this.#state = state; + this.emit('state', state); } } diff --git a/packages/webview/src/launch.test.ts b/packages/webview/src/launch.test.ts index de954bf4..f1fd065d 100644 --- a/packages/webview/src/launch.test.ts +++ b/packages/webview/src/launch.test.ts @@ -934,6 +934,31 @@ describe('launch (webview)', () => { } }); + 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 24eac188..e4fdde4b 100644 --- a/packages/webview/src/launch.ts +++ b/packages/webview/src/launch.ts @@ -672,7 +672,7 @@ export function launchWithSeams( client.addCaptureProvider( createSystemTracesProvider({ sample: options.systemMetricsSampler ?? createBrowserSystemTracesSampler(), - producers: createBrowserSystemTraceProducers(), + producers: createBrowserSystemTraceProducers(undefined, options.onError), refreshMs: Math.min( 10_000, Math.max(1_000, (resolved.options.get(BugseeOption.Duration, 60) * 1000) / 4), From 26a1fe1ca11e6ffdd6ecc9688fe29e75896ba50a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 17:10:18 +0500 Subject: [PATCH 16/20] fix(capture,node,browser,webview): harden the system-traces provider; one shared deriveRefreshMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - a throwing legacy sample() inside a tick is isolated and reported, so the held-value refresh on the same tick still runs (browser/webview pass both sample and producers) - a throwing scheduler.setInterval on start unwinds exactly like a failed initial sample (producers stopped, not running, rethrown) - a tick that slips past stop() returns immediately - refreshMs is derived by ONE helper, deriveRefreshMs(recordingWindowMs) in @bugsee/capture: a quarter of the window clamped to [2 x the 1 s provider interval, 10 s], and a non-finite / non-positive window falls back to 10 s. Node, browser and webview launches use it (the floor was 1 s, which re-recorded a held value on every other tick). Tests: deriveRefreshMs table, provider hardening, the refresh clamp it.each now also on browser (battery producer) and webview launches, and one timer / one clearInterval per start/stop. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/browser/src/launch.test.ts | 49 ++++++ packages/browser/src/launch.ts | 6 +- packages/capture/src/index.ts | 1 + .../src/system-traces-provider.test.ts | 140 +++++++++++++++++- .../capture/src/system-traces-provider.ts | 30 +++- packages/node/src/launch.test.ts | 5 +- packages/node/src/launch.ts | 3 +- packages/webview/src/launch.test.ts | 56 +++++++ packages/webview/src/launch.ts | 6 +- 9 files changed, 280 insertions(+), 16 deletions(-) diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index 4b026117..aa99a61a 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -1053,6 +1053,55 @@ describe('launch', () => { 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 }); diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 38e57676..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, @@ -798,10 +799,7 @@ async function buildLaunch( createSystemTracesProvider({ sample: options.systemMetricsSampler ?? createBrowserSystemTracesSampler(), producers: createBrowserSystemTraceProducers(undefined, options.onError), - refreshMs: Math.min( - 10_000, - Math.max(1_000, (resolved.options.get(BugseeOption.Duration, 60) * 1000) / 4), - ), + 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/capture/src/index.ts b/packages/capture/src/index.ts index 4c4cb0b7..e57971af 100644 --- a/packages/capture/src/index.ts +++ b/packages/capture/src/index.ts @@ -62,6 +62,7 @@ export { } from './system-events-provider'; export { createSystemTracesProvider, + deriveRefreshMs, type SystemTracesProviderOptions, type TraceSample, } from './system-traces-provider'; diff --git a/packages/capture/src/system-traces-provider.test.ts b/packages/capture/src/system-traces-provider.test.ts index 8d454f46..2bcd5026 100644 --- a/packages/capture/src/system-traces-provider.test.ts +++ b/packages/capture/src/system-traces-provider.test.ts @@ -12,7 +12,11 @@ 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 => @@ -601,3 +605,137 @@ describe('createSystemTracesProvider β€” producers', () => { 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('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 0c1cf2be..f57f4ce1 100644 --- a/packages/capture/src/system-traces-provider.ts +++ b/packages/capture/src/system-traces-provider.ts @@ -50,6 +50,21 @@ export interface SystemTracesProviderOptions { 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; @@ -84,8 +99,8 @@ class SystemTracesProvider extends CaptureProviderBase { } this.#producers = options.producers ?? []; this.#sample = options.sample; - this.#intervalMs = options.intervalMs ?? 1000; - this.#refreshMs = options.refreshMs ?? 10_000; + 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 ?? (() => {}); @@ -105,12 +120,14 @@ class SystemTracesProvider extends CaptureProviderBase { } 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; } - this.#timer = this.#scheduler.setInterval(() => this.#tick(), this.#intervalMs); } protected override onStop(): void { @@ -122,9 +139,14 @@ class SystemTracesProvider extends CaptureProviderBase { } #tick(): void { + if (!this.#running) return; // a tick that slipped past stop() records nothing try { const now = this.#now(); - this.#emitLegacy(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); diff --git a/packages/node/src/launch.test.ts b/packages/node/src/launch.test.ts index 0e272688..449eb475 100644 --- a/packages/node/src/launch.test.ts +++ b/packages/node/src/launch.test.ts @@ -1422,8 +1422,9 @@ describe('launch β€” system trace producers', () => { }); it.each([ - { maxRecordingTime: 8, refreshMs: 2000 }, // 8s/4 = 2s - { maxRecordingTime: 2, refreshMs: 1000 }, // 0.5s clamped up to 1s + { 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, diff --git a/packages/node/src/launch.ts b/packages/node/src/launch.ts index 61c7c579..a3937628 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -6,6 +6,7 @@ import { createLogCaptureProvider, createSystemEventsProvider, createSystemTracesProvider, + deriveRefreshMs, installNetworkCapture, type NetworkCapture, type TraceProducer, @@ -877,7 +878,7 @@ function buildLaunch(appToken: string, options: BugseeLaunchOptions = {}): Launc ...(options.onError !== undefined ? { onError: options.onError } : {}), }), }), - refreshMs: Math.min(10_000, Math.max(1_000, (maxRecordingTime * 1000) / 4)), + refreshMs: deriveRefreshMs(maxRecordingTime * 1000), ...(options.onError !== undefined ? { onError: options.onError } : {}), ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), }), diff --git a/packages/webview/src/launch.test.ts b/packages/webview/src/launch.test.ts index f1fd065d..7633ee2c 100644 --- a/packages/webview/src/launch.test.ts +++ b/packages/webview/src/launch.test.ts @@ -934,6 +934,62 @@ describe('launch (webview)', () => { } }); + 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(() => diff --git a/packages/webview/src/launch.ts b/packages/webview/src/launch.ts index e4fdde4b..25642ba8 100644 --- a/packages/webview/src/launch.ts +++ b/packages/webview/src/launch.ts @@ -18,6 +18,7 @@ import { createLogCaptureProvider, createSystemEventsProvider, createSystemTracesProvider, + deriveRefreshMs, installNetworkCapture, type TraceSample, } from '@bugsee/capture'; @@ -673,10 +674,7 @@ export function launchWithSeams( createSystemTracesProvider({ sample: options.systemMetricsSampler ?? createBrowserSystemTracesSampler(), producers: createBrowserSystemTraceProducers(undefined, options.onError), - refreshMs: Math.min( - 10_000, - Math.max(1_000, (resolved.options.get(BugseeOption.Duration, 60) * 1000) / 4), - ), + refreshMs: deriveRefreshMs(resolved.options.get(BugseeOption.Duration, 60) * 1000), ...(options.onError !== undefined ? { onError: options.onError } : {}), ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), }), From 9aeefa8460d8b89fcc6973a742a9129611bd941e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 17:13:26 +0500 Subject: [PATCH 17/20] test(node,bun,deno,browser,webview): close the test gaps that let real regressions through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - node: a CPU producer test with distinct non-zero user/system/wall steps pins each name to its value and the %; the legacy-parity test now uses per-call constant increments (a real, non-zero window on both sides) instead of a constant reader whose deltas were all zero - ProcessStatsTracker: the fake scheduler hands out unique handles and clearInterval must receive a live one (a dropped / nulled handle fails); the real-timer 40 ms wait is replaced by fake timers with an after-unsubscribe silence check. Deactivation no longer clears a timer a failed activation never registered. - webview: battery/charging asserted on parsed entries (toContainEqual), not substrings - bun/deno: the factory spy is cleared before every test; the bun opt-out test asserts one call and its argument; a captureSystemTraces:false gate test for both (no sampling timer, no entries) - node: the gate test runs for both lag-measured and lag-opted-out producers and asserts the tracker registers no timer while disabled and exactly one when enabled - browser: the vacuous never-started provider test is removed; the restart test is retitled and asserts the re-fetch πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- packages/browser/src/trace-producers.test.ts | 28 +++++------ packages/bun/src/launch.test.ts | 38 +++++++++++++-- packages/deno/src/launch.test.ts | 33 ++++++++++++- packages/node/src/launch.test.ts | 22 ++++++--- packages/node/src/trace-producers.test.ts | 48 +++++++++++++++++-- .../trackers/process-stats-tracker.test.ts | 39 ++++++++++----- .../src/trackers/process-stats-tracker.ts | 2 +- packages/webview/src/launch.test.ts | 10 ++-- 8 files changed, 176 insertions(+), 44 deletions(-) diff --git a/packages/browser/src/trace-producers.test.ts b/packages/browser/src/trace-producers.test.ts index 4cebb69d..0a154c5f 100644 --- a/packages/browser/src/trace-producers.test.ts +++ b/packages/browser/src/trace-producers.test.ts @@ -1,4 +1,4 @@ -import { createSystemTracesProvider, type TraceProducer } from '@bugsee/capture'; +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'; @@ -78,15 +78,22 @@ describe('BatteryProducer', () => { expect(out).toHaveLength(5); }); - it('publishes the tracker current() immediately when already resolved (second producer start)', async () => { + it('a restart (stop, then start) re-fetches getBattery and re-emits the baseline', async () => { const m = mkManager(0.5, false); - const producers = createBrowserSystemTraceProducers({ getBattery: () => Promise.resolve(m) }); + const getBattery = vi.fn(() => Promise.resolve(m)); + const producers = createBrowserSystemTraceProducers({ getBattery }); const first = run(producers); await flush(); - first.p.stop(); // tracker deactivates -> current() undefined - const again = run(producers); // restart: re-fetches, emits again after resolve + 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(again.out.map((o) => o.name)).toEqual(['battery', 'charging']); + 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 () => { @@ -109,15 +116,6 @@ describe('BatteryProducer', () => { expect(getBattery).not.toHaveBeenCalled(); }); - it('with captureSystemTraces:false (provider never started) getBattery is never called', () => { - const getBattery = vi.fn(() => Promise.resolve(mkManager(1, false))); - const provider = createSystemTracesProvider({ - producers: createBrowserSystemTraceProducers({ getBattery }), - }); - expect(provider).toBeDefined(); - expect(getBattery).not.toHaveBeenCalled(); - }); - it('default env reads navigator lazily and tolerates its absence', async () => { vi.stubGlobal('navigator', undefined); try { diff --git a/packages/bun/src/launch.test.ts b/packages/bun/src/launch.test.ts index 29a577b2..bbc9fbf2 100644 --- a/packages/bun/src/launch.test.ts +++ b/packages/bun/src/launch.test.ts @@ -10,7 +10,7 @@ import { } from '@bugsee/core'; 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'; @@ -87,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())); @@ -117,10 +123,12 @@ describe('@bugsee/bun launch', () => { }); it('does not opt out of event-loop delay (Bun measures it)', () => { - vi.mocked(createNodeSystemTraceProducers).mockClear(); track(launchCore('tok', base()).client); + expect(createNodeSystemTraceProducers).toHaveBeenCalledTimes(1); const arg = vi.mocked(createNodeSystemTraceProducers).mock.calls[0]?.[0]; - expect(arg?.measuresEventLoopDelay).not.toBe(false); + 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()); @@ -351,3 +359,27 @@ describe('@bugsee/bun β€” the launch scheduler / onError reach the trace tracker 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/deno/src/launch.test.ts b/packages/deno/src/launch.test.ts index c5b101c2..347c5fef 100644 --- a/packages/deno/src/launch.test.ts +++ b/packages/deno/src/launch.test.ts @@ -10,7 +10,7 @@ import { } from '@bugsee/core'; 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'; @@ -89,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())); @@ -272,6 +278,7 @@ 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(createNodeSystemTraceProducers).toHaveBeenCalledTimes(1); expect(createNodeSystemTraceProducers).toHaveBeenCalledWith( expect.objectContaining({ measuresEventLoopDelay: false }), ); @@ -352,3 +359,27 @@ describe('@bugsee/deno β€” the launch scheduler / onError reach the trace tracke 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/node/src/launch.test.ts b/packages/node/src/launch.test.ts index 449eb475..bf0d4491 100644 --- a/packages/node/src/launch.test.ts +++ b/packages/node/src/launch.test.ts @@ -1389,9 +1389,16 @@ describe('launch β€” system trace producers', () => { return { calls, deps: { perfHooks, scheduler: fs.scheduler } }; }; - it('the default producers attach nothing when captureSystemTraces is false, and release everything on stop()', async () => { + 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 { calls, deps } = producerDeps(fs); + 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({ @@ -1400,12 +1407,14 @@ describe('launch β€” system trace producers', () => { scheduler: fs.scheduler, captureSystemTraces: false, systemMetricsSampler: undefined, - systemTraceProducers: createNodeSystemTraceProducers(deps), + 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({ @@ -1413,12 +1422,13 @@ describe('launch β€” system trace producers', () => { clock: fixedClock, scheduler: fs.scheduler, systemMetricsSampler: undefined, - systemTraceProducers: createNodeSystemTraceProducers(deps), + systemTraceProducers: createNodeSystemTraceProducers({ ...deps, ...over }), }), ); - expect(calls).toEqual(['monitor', 'enable']); + 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('disable'); + expect(calls.at(-1)).toBe(lagMeasured ? 'disable' : undefined); }); it.each([ diff --git a/packages/node/src/trace-producers.test.ts b/packages/node/src/trace-producers.test.ts index 2c266af2..72187a67 100644 --- a/packages/node/src/trace-producers.test.ts +++ b/packages/node/src/trace-producers.test.ts @@ -81,14 +81,32 @@ const eventLoop = { }; describe('createNodeSystemTraceProducers', () => { - it('emits the SAME trace names and values as the legacy sampler (parity)', () => { - const deps = constDeps({ eventLoop, eventLoopUtilization: () => 0.5 }); - const h = harness(deps); + // 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 legacy = createNodeSystemMetricsSampler({ ...deps, eventLoop: eventLoop.read })(); + 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); @@ -96,6 +114,28 @@ describe('createNodeSystemTraceProducers', () => { 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(); diff --git a/packages/node/src/trackers/process-stats-tracker.test.ts b/packages/node/src/trackers/process-stats-tracker.test.ts index 2cd0f414..1062c395 100644 --- a/packages/node/src/trackers/process-stats-tracker.test.ts +++ b/packages/node/src/trackers/process-stats-tracker.test.ts @@ -1,22 +1,29 @@ import type { Scheduler } from '@bugsee/core'; -import { describe, expect, it } from 'vitest'; +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; - return 'h'; + const handle = { id: sets }; + live.add(handle); + return handle; }, - clearInterval: () => { + clearInterval: (h) => { + expect(live.has(h), `clearInterval(${JSON.stringify(h)}) is not a live handle`).toBe(true); + live.delete(h); cleared += 1; }, }; @@ -173,13 +180,23 @@ describe('ProcessStatsTracker', () => { off(); // clears the real interval }); - it('the default scheduler delivers ticks', async () => { - const tracker = new ProcessStatsTracker({ intervalMs: 5 }); - const got: ProcessStats[] = []; - const off = tracker.on('stats', (s) => got.push(s)); - await new Promise((r) => setTimeout(r, 40)); - off(); - expect(got.length).toBeGreaterThan(0); + 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', () => { @@ -301,7 +318,7 @@ describe('ProcessStatsTracker', () => { tracker.on('stats', () => {}); expect(sets).toBe(1); expect(calls).toEqual(['start', 'stop', 'start']); // the unwound source is not stopped twice - expect(cleared).toBe(1); + 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', () => { diff --git a/packages/node/src/trackers/process-stats-tracker.ts b/packages/node/src/trackers/process-stats-tracker.ts index 14b83aed..6ddeabbc 100644 --- a/packages/node/src/trackers/process-stats-tracker.ts +++ b/packages/node/src/trackers/process-stats-tracker.ts @@ -131,7 +131,7 @@ export class ProcessStatsTracker extends InterceptorBase<{ stats: ProcessStats } } protected override onDeactivate(): void { - this.#d.scheduler.clearInterval(this.#timer); + if (this.#timer !== null) this.#d.scheduler.clearInterval(this.#timer); // none after a failed activation this.#timer = null; this.#stopEventLoop(); this.#cached = undefined; diff --git a/packages/webview/src/launch.test.ts b/packages/webview/src/launch.test.ts index 7633ee2c..7f8aaa19 100644 --- a/packages/webview/src/launch.test.ts +++ b/packages/webview/src/launch.test.ts @@ -926,9 +926,13 @@ describe('launch (webview)', () => { 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) => JSON.stringify(e.p)); - expect(traces.some((t) => t.includes('"battery"') && t.includes('100'))).toBe(true); - expect(traces.some((t) => t.includes('"charging"') && t.includes('"state":3'))).toBe(true); + 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(); } From 201e5fa392070a0e68e43cbf1f0af07b43b12aaf Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 17:14:24 +0500 Subject: [PATCH 18/20] =?UTF-8?q?docs:=20PROGRESS/ST0=E2=80=B2,=20bun/deno?= =?UTF-8?q?=20barrels,=20systemTraceProducers=20JSDoc,=20relaunch=20RESET?= =?UTF-8?q?=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PROGRESS.md: the browser sampler no longer emits battery/charging (BatteryTracker does); the node and table lines describe the producers as the default and the legacy samplers as overrides; the guarded sampler is no longer what Bun/Deno launch with; the ST0β€² paragraph adds the Bun/Deno wiring, the webview adoption, the systemTraceProducers option (RESET on relaunch), held-only refresh with deriveRefreshMs, and the no-fabricated-lag-baseline rule - @bugsee/bun and @bugsee/deno barrels: they default the trace producers, not a system-metrics sampler - systemTraceProducers JSDoc: per-launch only (stateful producers), and on Bun/Deno a caller-supplied set replaces the runtime gates (e.g. Deno's lag opt-out) - relaunch-options.test.ts: why systemTraceProducers is RESET πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- docs/PROGRESS.md | 28 +++++++++++++------- packages/bugsee/src/relaunch-options.test.ts | 6 ++++- packages/bun/src/index.ts | 9 ++++--- packages/deno/src/index.ts | 5 ++-- packages/node/src/launch.ts | 12 ++++++++- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index d2e5c5df..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,16 +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. Legacy samplers remain until a cleanup - slice; remaining browser traces migrate in follow-on slices. + 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 @@ -684,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/packages/bugsee/src/relaunch-options.test.ts b/packages/bugsee/src/relaunch-options.test.ts index 58df89d8..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', 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/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/node/src/launch.ts b/packages/node/src/launch.ts index a3937628..f05323f4 100644 --- a/packages/node/src/launch.ts +++ b/packages/node/src/launch.ts @@ -385,7 +385,17 @@ 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. A supplied `systemMetricsSampler` wins over this. */ + /** + * 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; From 95b2c23d27577a6e4ad44b4985ff1d0907176790 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 17:15:09 +0500 Subject: [PATCH 19/20] test(capture): cover the tick's outer catch (a throwing clock) now that the legacy sample is isolated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- .../src/system-traces-provider.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/capture/src/system-traces-provider.test.ts b/packages/capture/src/system-traces-provider.test.ts index 2bcd5026..f821f8b4 100644 --- a/packages/capture/src/system-traces-provider.test.ts +++ b/packages/capture/src/system-traces-provider.test.ts @@ -671,6 +671,26 @@ describe('createSystemTracesProvider β€” hardening', () => { 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(); From d0c23c845ff8bb2e1b91115158ebce90b10aa148 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 14:40:27 +0500 Subject: [PATCH 20/20] fix(node): ProcessStatsTracker.current() retries a failed activation (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A one-time cpuUsage()/monotonicNowMs()/setInterval failure at activation was reported and unwound, but the producer stayed subscribed to a tracker with no timer, so the traces went silent until every subscriber left. The failure is now remembered; the next current() (the producer's baseline read) retries activation and propagates a second failure, which the provider isolates and unwinds. Deactivation clears the pending failure. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --- .../trackers/process-stats-tracker.test.ts | 68 +++++++++++++++++++ .../src/trackers/process-stats-tracker.ts | 32 +++++++-- 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/packages/node/src/trackers/process-stats-tracker.test.ts b/packages/node/src/trackers/process-stats-tracker.test.ts index 1062c395..8e488b60 100644 --- a/packages/node/src/trackers/process-stats-tracker.test.ts +++ b/packages/node/src/trackers/process-stats-tracker.test.ts @@ -288,6 +288,74 @@ describe('ProcessStatsTracker', () => { 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[] = []; diff --git a/packages/node/src/trackers/process-stats-tracker.ts b/packages/node/src/trackers/process-stats-tracker.ts index 6ddeabbc..62a5212b 100644 --- a/packages/node/src/trackers/process-stats-tracker.ts +++ b/packages/node/src/trackers/process-stats-tracker.ts @@ -74,6 +74,7 @@ export class ProcessStatsTracker extends InterceptorBase<{ stats: ProcessStats } #lastCpu = { user: 0, system: 0 }; #lastWallMs = 0; #cached: ProcessStats | undefined; + #activationFailed = false; constructor(deps: ProcessStatsDeps = {}) { super(deps.onError); @@ -92,17 +93,36 @@ export class ProcessStatsTracker extends InterceptorBase<{ stats: ProcessStats } this.#onError = deps.onError; } - /** Latest stats (read once on activation, then replaced on every poll). Safe for many callers. */ + /** + * 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 and everything started is unwound; the - // tracker then re-tries on its next activation (after the current subscribers leave). + // 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(); @@ -113,13 +133,12 @@ export class ProcessStatsTracker extends InterceptorBase<{ stats: ProcessStats } } this.#timer = this.#d.scheduler.setInterval(() => this.#poll(), this.#d.intervalMs); } catch (error) { - this.#onError?.(error); - this.#timer = null; try { this.#stopEventLoop(); } catch { - /* best-effort unwind; the original failure is the one reported */ + /* best-effort unwind; the original failure is the one thrown */ } + throw error; } } @@ -135,6 +154,7 @@ export class ProcessStatsTracker extends InterceptorBase<{ stats: ProcessStats } this.#timer = null; this.#stopEventLoop(); this.#cached = undefined; + this.#activationFailed = false; } #poll(): void {