From eca66c282be54f80426db46749292e4d11c2ef7f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 10:32:39 +0500 Subject: [PATCH 01/14] =?UTF-8?q?docs(design):=20system=20traces=20expansi?= =?UTF-8?q?on=20=E2=80=94=20competitor=20gaps=20+=20mobile-parity=20port?= =?UTF-8?q?=20plan?= 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-expansion.md | 192 +++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/design/system-traces-expansion.md diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md new file mode 100644 index 00000000..ecfa91d8 --- /dev/null +++ b/docs/design/system-traces-expansion.md @@ -0,0 +1,192 @@ +# System traces expansion β€” competitor gap + mobile-parity port plan β€” DESIGNED (not built) + +**Status:** DESIGNED 2026-10-14. Nothing here is implemented. Built from five read-only research passes +(competitor web, competitor server/cloud/edge, competitor Electron/desktop, Bugsee Android SDK, Bugsee iOS +SDK). Every runtime-support claim marked **UNMEASURED** must be probed on the real runtime before the slice +that depends on it starts (see Β§7) β€” on Bun/Deno "the call succeeded" is not evidence the thing happened +(CLAUDE.md, the ELU / `monitorEventLoopDelay` / local-variables precedents). + +## 1. Goal and non-goals + +**Goal.** Close the highest-value gaps in the `traces.system` / `events.system` catalog, in this order of +authority: (1) Android-canonical wire vocabulary (parity target), (2) what several competitors collect that we +do not, (3) what is cheaply and truthfully obtainable from each JS runtime. + +**Non-goals.** +- Anything a runtime cannot answer truthfully. We omit rather than synthesize (precedent: ELU is omitted on + Bun/Deno; `event_loop_lag_*` is omitted on Deno). A derived value must be NAMED as derived. +- Privacy-sensitive device identifiers (wifi SSID/BSSID/MAC/IP, carrier, accessibility/screen-reader state). +- `*.user` streams. SDK code never writes there (CLAUDE.md, product-owner rule). Everything here is + `traces.system` / `events.system`. +- Web vitals / long tasks if `@bugsee/performance` already owns them β€” see Q1. + +## 2. Current catalog (as built) + +| Tier | Traces | Events | +|---|---|---| +| Browser (`browser/src/system-metrics.ts`, `system-events.ts`) | `ram_js_heap_{total,free,used}` (Chromium `performance.memory`), `ram_system_advertised`, `connection`, `orientation`, `battery`, `charging` | `process_started`, `process_exiting`, `process_foreground`, `process_background`, `online`, `offline`, `orientation_changed` | +| Node/Bun/Deno (`node/src/system-metrics.ts`, `system-events.ts`) | `process_memory_{rss,heap_total,heap_used,external,array_buffers}`, `ram_system_{total,free}`, `cpu_usage_{user,system,process}`, `event_loop_lag_{ms,max_ms,p99_ms}`, `event_loop_utilization` | `process_started`, `process_exiting`, `process_before_exit`, `process_warning`, `process_signal` | +| Electron | composes the two above; **no Electron-specific telemetry** (only `crashReporter` + `render-process-gone` for incident synthesis) | β€” | + +Sampler: one provider, snapshot at start then every 1000 ms (`capture/src/system-traces-provider.ts`). No +event-driven emission today. Registered only by node/browser/webview launches (bun/deno reuse node's). + +## 3. Findings that drive the design + +**Competitors.** Server-side the common set (RSS/heap/CPU/system RAM/event-loop delay+ELU) we already match. +The most-shared gaps: **GC stats** (Datadog, OTel, Splunk, New Relic, AppDynamics), **V8 heap statistics** +(Datadog, AppSignal), **per-space heap** (Datadog, OTel), **active handles/requests** (Elastic, OTel), +**Lambda context** (Sentry, Datadog). Browser-side: **long tasks / LoAF**, **web vitals**, **navigation/resource +timing**, **FPS** (Highlight, OpenReplay), **page visibility**, **viewport**. Electron: Sentry collects +per-process memory (`app.getAppMetrics`), GPU, screen, `child-process-gone`, `powerMonitor`/window/`app` +breadcrumbs. **No competitor reads the Battery API**; no vendor Node SDK implements cgroup limits (OTel +specifies them). + +**Mobile.** Android has ~28 trace producers + 7 event producers; iOS 24 + ~8. Portable and truthful subset is +small (Β§4). Many Android/iOS traces have **no JS API** and are explicitly not ported. + +**Wire-shape clashes (must resolve before reuse).** +1. `charging`: Android `{state, source}` map; iOS 4-state int; JS boolean. +2. `cpu`: iOS = percent summed over threads (can exceed 100); JS `cpu_usage_process` = per-core-normalized. +3. `connection` (JS) vs `network` (iOS) vs Android `connection` β€” JS already follows Android's vocabulary + (`web-tier-wire-alignment`); keep. +4. `orientation` ints 3/4 mean opposite things on iOS vs Android β€” JS follows Android; keep. +5. Unavailable service: Android emits `"$$unavailable"`; JS omits. See Q3. + +## 4. Proposed additions (prioritised) + +Naming rule: use the Android wire name when the quantity is the same thing; otherwise a JS-specific name in +the existing `process_*` / `event_loop_*` style. Units: bytes unless Android says otherwise. + +### P1 β€” Node/Bun/Deno runtime depth (`@bugsee/node`) +| Trace | Source | Notes | +|---|---|---| +| `gc_pause_ms` (sum/max per interval), `gc_count` (optionally by type tag) | `PerformanceObserver({entryTypes:['gc']})` | Broadest competitor-backed gap. **UNMEASURED on Bun/Deno** β€” gate per runtime like `measuresEventLoopDelay`. | +| `v8_heap_size_limit`, `v8_heap_available`, `v8_heap_malloced`, `v8_heap_peak_malloced` | `v8.getHeapStatistics()` | Cheap, sync. Bun support **UNMEASURED**. | +| `ram_js_heap_{total,free,used}` on Node | `v8.getHeapStatistics()` (`heap_size_limit` = total, as Android's `ram_jvm_heap` contract: total = max, free = total βˆ’ used) | Gives the viewer the same grouping browser already gets. Decision Q2. | +| `ram_system_available` (cgroup-aware) | `process.availableMemory()` | Sentry uses it. Fallback = omit, not `os.freemem()`. | +| `disk_mem_free`, `disk_mem_total` | `fs.statfs` (Node β‰₯18.15) | Android/iOS MB integer, change-deduped (β‰₯1 MB). Opt-in-able like iOS `BugseeOptionCaptureDiskSpace`. **UNMEASURED on Bun/Deno.** | +| `handles_active`, `requests_active` | `process.getActiveResourcesInfo()` | Elastic/OTel. Low priority; API exists Node β‰₯17.3. | +| `process_uptime_s` | `process.uptime()` | Trivial; Sentry/Datadog/OTel. | + +### P2 β€” Browser parity (`@bugsee/browser`) +| Trace / event | Source | Notes | +|---|---|---| +| `fps` | `requestAnimationFrame` counter over the 1 s window | Android `fps`; iOS `fps`; Highlight/OpenReplay. **Gate on visibility; never keep a permanent rAF loop in a hidden tab** (battery + keeps page awake). | +| `app_state` (step trace) | `document.visibilityState` | Android+iOS have it; JS has only the events today. Reuse the existing visibility listener. | +| `displays` | `screen.width/height`, `devicePixelRatio`; Electron `screen.getAllDisplays()` | Static snapshot + `resize`/`display-metrics-changed`. | +| `thermal_state` | Compute Pressure `PressureObserver` | Chromium-only, experimental, permissions-policy gated β†’ **behind feature detection, off by default**. | +| `DarkModeChanged` event | `matchMedia('(prefers-color-scheme: dark)')` change | Android name; trivial. | +| `freeze` / `resume` events | Page Lifecycle | Complements `process_background`. | +| Event-driven `battery`/`charging`/`connection` | `levelchange`, `chargingchange`, `connection` `change` | Replaces 1 s polling for these; OpenReplay precedent. Keep a 1 s fallback only where events are absent. | +| `hardware_concurrency` (context, not trace) | `navigator.hardwareConcurrency` | Sentry; static β†’ belongs in environment, not a trace. | +| Main-thread responsiveness | `setTimeout`/`MessageChannel` round-trip delta | Browser analogue of node `event_loop_lag_*` / iOS `queue.main`. Name `main_thread_lag_ms`. | + +### P3 β€” Electron main-process telemetry (`@bugsee/electron`) +Gated into the main-process sampler; `electron` is already an injected arg (no package dependency). +| Item | Source | +|---|---| +| Per-process CPU+memory (`app_metrics`: type, pid, cpu %, workingSet) | `app.getAppMetrics()` | +| `child_process_gone` event (type, reason, exitCode) | `app.on('child-process-gone')` β€” beyond the existing `render-process-gone` incident path | +| `power_suspend/resume`, `power_lock/unlock`, `power_ac/battery` events | `powerMonitor` | +| `displays` (above) incl. add/remove/metrics-change events | `screen` | +| GPU info (context) | `app.getGPUInfo('basic')` β€” wrap in try/catch (throws on Linux without acceleration, per Sentry) | +| Window focus/blur/show/hide/unresponsive events | `BrowserWindow` | + +### P4 β€” Serverless / edge context +| Item | Source | +|---|---| +| Lambda: remaining time, memory limit, request id, cold-start flag, timeout-warning event | Lambda `context` | Sentry/Datadog. Context fields, not polled traces. | +| Cloud provider/region from env | `process.env` (`VERCEL`, `AWS_REGION`, `FLY_REGION`, …) | Check whether the environment builder already does this (Q4). | +| Cloudflare/Vercel CPU/memory | **Platform-side only** β€” SDK cannot read them; do not fake. | + +### Explicitly NOT ported +`wifi`, `cellular`, `bluetooth`, `nfc`, `screen_brightness`, `game_mode`, `satellite`, `device_idle`, +`low_power_*`, `thermal_headroom`, iOS `accessibility`/`fold_state`/`hinge_angle`, `ram_low_memory` / +`MemoryPressure` / `TrimMemory` (no JS signal; a heap-ratio threshold would be an invention β€” if wanted later it +must be named derived, e.g. `heap_pressure`), `UserDidTakeScreenshot`, `ClipboardChanged` content, input-device +events. Reasons: no API, privacy, or Bugsee-internal state. + +## 5. Architecture constraints + +- **Thin kernel, no core piercing.** All additions are sampler entries in the platform packages' existing + `system-metrics.ts` callbacks (or new interceptors feeding the system-events provider). Nothing in + `@bugsee/core`. +- **Runtime-portable.** Shared tiers reach globals only via `globalThis as unknown as {…}` casts. `v8`/`fs`/ + `perf_hooks` live in `@bugsee/node` behind the existing guarded-metrics seam (`guarded-system-metrics.ts`). +- **Per-runtime gates, not passive tells.** Anything UNMEASURED on Bun/Deno ships with a runtime flag (like + `measuresEventLoopDelay`) defaulting to OFF there until measured; `@bugsee/{bun,deno}` flip it only with a + recorded measurement in this doc. +- **Cadence.** Keep the single 1 s sampler for cheap sync reads. Slow/expensive sources (`fs.statfs`, + `getAppMetrics`, `getHeapSpaceStatistics`) sample every N ticks (Android precedent: process memory every + 5th tick, disk every 5 s). Event-driven sources emit on change with dedupe (iOS `emitIfChangedName`). +- **Cost.** GC observer and rAF counter run only while a subscriber is present (subscriber-presence drives + activation, per the pub/sub architecture). Budget: the sampler tick must stay well under the existing + cost envelope; measure, don't assume. +- **Protocol.** Trace names are free-form in `traces.system`, but the viewer renders from fixed lookup tables + (`web-tier-wire-alignment`): new names need a viewer-side title/units entry or they render raw. See Q5. + +## 6. Test and review plan (binding standards) + +Per slice: test-first; mutator loop per new entity (≀10 iterations, roll back by `cp`/restore β€” never +`git checkout`); integration test at each package boundary; 100% line / β‰₯90% branch per package; real-runtime +cases added to `@bugsee/instrumentation-tests` (node/bun/deno e2e) and a real-browser e2e for browser traces +(`real-browser-and-otel-e2e` precedent). Each runtime gate gets a test proving the trace is OMITTED where +the probe says the runtime is blind, and PRESENT where it is sound. Convergent multi-agent review per slice +(implementation-standards Β§7). Run `pnpm ci:local`, not just `pnpm test`. + +## 7. Prerequisite probes (do before building P1/P2 slices) + +Measure, on real runtimes, with the SDK's own call path (not a toy script): +1. `PerformanceObserver('gc')` β€” fires? entries have `detail.kind`/`duration`? on node 18/22, bun 1.4, deno 2.9. +2. `v8.getHeapStatistics()` and `getHeapSpaceStatistics()` β€” present and non-constant on bun and deno? +3. `fs.statfs` β€” present and correct on bun and deno? +4. `process.availableMemory()` / `getActiveResourcesInfo()` β€” presence per runtime and min Node version. +5. Browser: rAF-counter cost and accuracy in background tabs; Compute Pressure availability; `battery` + events on Chromium. +6. Sampler tick cost with P1 enabled (Β΅s per tick) vs the current baseline. +Record each result in Β§8 with runtime version and date, as `docs/PROGRESS.md` does for the existing gates. + +## 8. Measurement log + +*(empty β€” fill as probes run)* + +## 9. Slices + +| Slice | Content | Depends on | +|---|---|---| +| ST0 | Wire-shape decisions Q2/Q3/Q5 + `charging` shape fix | β€” | +| ST1 | Node: `v8_heap_*`, `ram_js_heap_*` aliases, `process_uptime_s`, `ram_system_available` | probes 2, 4 | +| ST2 | Node: GC observer (`gc_*`) with per-runtime gate | probe 1 | +| ST3 | Node: `disk_mem_*` (+ option) | probe 3 | +| ST4 | Browser: `app_state`, `fps`, `displays`, `main_thread_lag_ms`, dark-mode/freeze/resume events | probe 5 | +| ST5 | Browser: event-driven battery/connection | probe 5 | +| ST6 | Electron: `app_metrics`, `child_process_gone`, power/screen/window events, GPU context | β€” | +| ST7 | Serverless context: Lambda context fields, cloud-region audit | Q4 | +| ST8 | Optional: Compute Pressure `thermal_state` (flagged off) | probe 5 | + +## 10. Open questions (need a product/owner decision) + +- **Q1.** Do long tasks / LoAF / web vitals / navigation+resource timing belong in `@bugsee/performance` + (already owns web-vitals) rather than `traces.system`? Recommendation: yes β€” keep them out of this doc's + scope and confirm `@bugsee/performance` covers long tasks/LoAF; check for overlap before any build. +- **Q2.** Should Node emit `ram_js_heap_*` *in addition to* `process_memory_heap_*`? Recommendation: yes + (viewer grouping parity with browser/Android); accept the duplicate values. +- **Q3.** Omit vs `"$$unavailable"` for unsupported services. Recommendation: keep omitting (consistent with + the ELU/Deno precedent) unless the viewer needs the sentinel β€” verify viewer handling first. +- **Q4.** Does the env builder already detect cloud provider/region? (Not checked.) +- **Q5.** Which new trace names does the viewer already render? (Viewer tables not inspected.) Any name the + viewer lacks needs a viewer PR (precedent: worker PR 46 / viewer PR 14). +- **Q6.** `disk_mem_*` privacy/opt-in: mirror iOS's option-gated capture? Recommendation: opt-in. + +## 11. Evidence base and its limits + +- Competitor browser: source read from shallow clones (Sentry, Datadog, New Relic, Highlight, OpenReplay, + Embrace, Faro, PostHog, Bugsnag, Rollbar). **LogRocket, FullStory, Raygun, Dynatrace: docs only** β€” blanks + there are absence of evidence. +- Competitor server: Sentry/Datadog/New Relic/Elastic/AppSignal/OTel from docs + source. Datadog Lambda + CPU//tmp/fd collection mechanism, Sentry `processSession`, Dynatrace metric keys: **unverified**. +- Electron: Sentry from source; Bugsnag from docs only; Datadog has no verified main-process Electron SDK. +- Mobile: Android/iOS read from source (no code run). iOS spec `report-bundle-structure/platforms/ios.md:192` + says its producer inventory is "still to be audited", so the iOS source is the only authority. +- Not researched: Tauri/NW.js; Raygun/Rollbar/LogRocket Electron. From 0c75d6d06f4d6d2fa82612387a214719e6ad1ce0 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 10:33:55 +0500 Subject: [PATCH 02/14] docs(design): record runtime probe results for system traces expansion 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-expansion.md | 45 ++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index ecfa91d8..b6439925 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -61,12 +61,12 @@ the existing `process_*` / `event_loop_*` style. Units: bytes unless Android say ### P1 β€” Node/Bun/Deno runtime depth (`@bugsee/node`) | Trace | Source | Notes | |---|---|---| -| `gc_pause_ms` (sum/max per interval), `gc_count` (optionally by type tag) | `PerformanceObserver({entryTypes:['gc']})` | Broadest competitor-backed gap. **UNMEASURED on Bun/Deno** β€” gate per runtime like `measuresEventLoopDelay`. | -| `v8_heap_size_limit`, `v8_heap_available`, `v8_heap_malloced`, `v8_heap_peak_malloced` | `v8.getHeapStatistics()` | Cheap, sync. Bun support **UNMEASURED**. | +| `gc_pause_ms` (sum/max per interval), `gc_count` (optionally by type tag) | `PerformanceObserver({entryTypes:['gc']})` | Broadest competitor-backed gap. **MEASURED: node only β€” blind on Bun and Deno (Β§8). Gate OFF there.** | +| `v8_heap_size_limit`, `v8_heap_available`, `v8_heap_malloced`, `v8_heap_peak_malloced` | `v8.getHeapStatistics()` | Cheap on Node/Deno (Β΅s). **MEASURED: do not emit on Bun** β€” JSC-derived, limit not a ceiling, ~1 ms/call (Β§8). | | `ram_js_heap_{total,free,used}` on Node | `v8.getHeapStatistics()` (`heap_size_limit` = total, as Android's `ram_jvm_heap` contract: total = max, free = total βˆ’ used) | Gives the viewer the same grouping browser already gets. Decision Q2. | -| `ram_system_available` (cgroup-aware) | `process.availableMemory()` | Sentry uses it. Fallback = omit, not `os.freemem()`. | -| `disk_mem_free`, `disk_mem_total` | `fs.statfs` (Node β‰₯18.15) | Android/iOS MB integer, change-deduped (β‰₯1 MB). Opt-in-able like iOS `BugseeOptionCaptureDiskSpace`. **UNMEASURED on Bun/Deno.** | -| `handles_active`, `requests_active` | `process.getActiveResourcesInfo()` | Elastic/OTel. Low priority; API exists Node β‰₯17.3. | +| `ram_system_available` | `process.availableMemory()` | Sentry uses it. **MEASURED: not comparable across runtimes (24 GB vs 0.9 GB same host) and absent on Node 18 β€” Node β‰₯22 only, if at all (Β§8).** | +| `disk_mem_free`, `disk_mem_total` | `fs.statfs` (Node β‰₯18.15) | Android/iOS MB integer, change-deduped (β‰₯1 MB). Opt-in-able like iOS `BugseeOptionCaptureDiskSpace`. **MEASURED sound on node/bun/deno (Β§8).** | +| `handles_active`, `requests_active` | `process.getActiveResourcesInfo()` | Elastic/OTel. Low priority; **MEASURED: always 0 on Bun/Deno β†’ Node only.** | | `process_uptime_s` | `process.uptime()` | Trivial; Sentry/Datadog/OTel. | ### P2 β€” Browser parity (`@bugsee/browser`) @@ -149,7 +149,40 @@ Record each result in Β§8 with runtime version and date, as `docs/PROGRESS.md` d ## 8. Measurement log -*(empty β€” fill as probes run)* +Measured 2026-10-14 on macOS (arm64) with standalone probe scripts (NOT yet through the SDK's own call path β€” +that confirmation is still owed per slice). Runtimes: node 18.20.6 / 22.13.1 / 22.20.0 / 24.15.0, bun 1.4.3, +deno 2.8.3. Method: allocate+retain ~100 MB of arrays, churn garbage, read `v8.getHeapStatistics()` and a +`PerformanceObserver({entryTypes:['gc']})`. + +| Probe | node (18/22/24) | bun 1.4.3 | deno 2.8.3 | +|---|---|---|---| +| `PerformanceObserver('gc')` entries after heavy churn + forced GC | **works** (28–234 entries; `detail.kind` + `duration` present) | **BLIND β€” 0 entries** | **BLIND β€” 0 entries** (also with `--v8-flags=--expose-gc`) | +| `v8.getHeapStatistics()` tracks a +100 MB retained alloc | yes (Ξ”99.8 MB, = `memoryUsage().heapUsed`) | yes but **JSC-derived**: Ξ”62 MB for the same alloc; `malloced_memory == used == total` (aliases); **`heap_size_limit` is not a ceiling** β€” it moved 55.9 MB β†’ 1.07 GB with heap growth | yes (Ξ”99.9 MB, real V8) | +| `v8.getHeapSpaceStatistics()` | real spaces | answers, but only `old_space` is non-zero (a JSC mapping, not V8 spaces) | real spaces | +| `getHeapStatistics()` cost | 0.3 Β΅s/call | **0.2–1.6 ms/call** (`bun:jsc.heapStats()` 0.47 ms) β€” ~1000Γ— node | 1.3 Β΅s/call | +| `fs.statfs` / `statfsSync` | present (node 18.15+ ok); ~0.8 Β΅s sync | present, correct (same blocks as node); 0.5 Β΅s | present, correct; 2.1 Β΅s | +| `process.availableMemory()` | absent on node 18; present 22+ | present | present β€” but value differs wildly (24 GB vs node's 0.9 GB on the same host): **semantics not comparable across runtimes** | +| `process.constrainedMemory()` | 0 (no cgroup limit) | 68.7 GB (looks like a cap, not a container limit) | 0 | +| `process.getActiveResourcesInfo()` | present (18+) | present, returns 0 entries | present, 0 entries | +| `process.resourceUsage()` | present | present | **absent** | + +**Consequences (applied to Β§4/Β§9):** +1. **GC observer is node-only.** Gate it OFF on Bun and Deno (`measuresGc: false`), same mechanism as + `measuresEventLoopDelay`; omit rather than report a quiet GC. (Its absence is itself a lie-by-omission only + if we labelled it β€” we don't.) +2. **`ram_js_heap_*` / `v8_heap_*` must NOT be emitted on Bun.** Its `heap_size_limit` is not a ceiling, so + `free = limit βˆ’ used` is meaningless, and the cost is ~1000Γ— node. Node and Deno only (real V8). + Bun keeps `process_memory_heap_*` (which reads the same JSC data but is already shipped and honest about + being "heap used/total"). +3. **`ram_system_available` (`availableMemory`) is not portable** β€” drop from ST1 for Bun/Deno; Node β‰₯22 only, + and name/define it as "Node's availableMemory", not Android's `ram_system_free`. +4. `fs.statfs` is sound on all three β†’ ST3 unblocked (still re-confirm through the SDK path). +5. `handles_active`/`requests_active` is near-useless on Bun/Deno (always 0) β†’ Node only, low priority. +6. Per-space heap is meaningless on Bun β†’ Node/Deno only if built at all. + +**Still unmeasured:** Deno <2.8 (declared floor 2.0), Bun <1.4, Node 20; browser probes (rAF in hidden tabs, +Compute Pressure, battery events); sampler tick cost with P1 enabled through the SDK; Linux/Windows +(all numbers above are macOS). ## 9. Slices From b2029823bfa3cabbffbb0c711d81f73e5f57bcd2 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 11:04:41 +0500 Subject: [PATCH 03/14] docs(design): address review findings on system traces expansion 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-expansion.md | 55 +++++++++++++++++--------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index b6439925..d439fd81 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -55,17 +55,20 @@ small (Β§4). Many Android/iOS traces have **no JS API** and are explicitly not p ## 4. Proposed additions (prioritised) -Naming rule: use the Android wire name when the quantity is the same thing; otherwise a JS-specific name in -the existing `process_*` / `event_loop_*` style. Units: bytes unless Android says otherwise. +Naming rule: use the Android wire name ONLY when the quantity is the same thing; otherwise a JS-specific name in +the existing `process_*` / `event_loop_*` style. Units: bytes unless the reused Android name specifies otherwise +(`disk_mem_*` is MB). Values must be the Android/iOS wire shape or the viewer cannot render them (the +`orientation`/`connection` precedent). ### P1 β€” Node/Bun/Deno runtime depth (`@bugsee/node`) | Trace | Source | Notes | |---|---|---| | `gc_pause_ms` (sum/max per interval), `gc_count` (optionally by type tag) | `PerformanceObserver({entryTypes:['gc']})` | Broadest competitor-backed gap. **MEASURED: node only β€” blind on Bun and Deno (Β§8). Gate OFF there.** | | `v8_heap_size_limit`, `v8_heap_available`, `v8_heap_malloced`, `v8_heap_peak_malloced` | `v8.getHeapStatistics()` | Cheap on Node/Deno (Β΅s). **MEASURED: do not emit on Bun** β€” JSC-derived, limit not a ceiling, ~1 ms/call (Β§8). | -| `ram_js_heap_{total,free,used}` on Node | `v8.getHeapStatistics()` (`heap_size_limit` = total, as Android's `ram_jvm_heap` contract: total = max, free = total βˆ’ used) | Gives the viewer the same grouping browser already gets. Decision Q2. | +| `ram_js_heap_{total,free,used}` on Node/Deno | `v8.getHeapStatistics()`: `total` = `heap_size_limit` (the CEILING, Android `ram_jvm_heap` contract), `used` = `used_heap_size`, `free` = limit βˆ’ used | **Not an alias of `process_memory_heap_*`**: `process_memory_heap_total` is `heapTotal` (committed heap, not the ceiling). Only `used` is the same number in both families; `total` and `free` differ. Keep `process_memory_heap_*` as committed. Same name = same quantity as browser/Android. Decision Q2. | +| `process_memory_limit` (cgroup cap) | `process.constrainedMemory()` (Node β‰₯18.15; `SYNTHESIS.md` T1 calls it the real OOM predictor) | Emit ONLY when the value is `> 0` AND `< 2^63` AND `≀ os.totalmem()`; omit for `0` (macOS/unconstrained) and the 2^64 Linux sentinel β€” never gate on `!== 0` alone. **Needs a Linux probe (macOS-only so far).** Deno/Bun semantics unmeasured. | | `ram_system_available` | `process.availableMemory()` | Sentry uses it. **MEASURED: not comparable across runtimes (24 GB vs 0.9 GB same host) and absent on Node 18 β€” Node β‰₯22 only, if at all (Β§8).** | -| `disk_mem_free`, `disk_mem_total` | `fs.statfs` (Node β‰₯18.15) | Android/iOS MB integer, change-deduped (β‰₯1 MB). Opt-in-able like iOS `BugseeOptionCaptureDiskSpace`. **MEASURED sound on node/bun/deno (Β§8).** | +| `disk_mem_free`, `disk_mem_total` | `fs.statfs` (Node β‰₯18.15) | **Unit: integer MB** (the Android/iOS wire; a deliberate exception to the bytes rule above, converted in the sampler from `bsize Γ— bavail` / `bsize Γ— blocks`), change-deduped (β‰₯1 MB). **Path: the filesystem `dataDir` lives on** (`effectiveDataDir`), not `/` or cwd β€” it is that volume's space, not mobile "device storage", and differs from `/` on overlay/container roots. Opt-in like iOS `BugseeOptionCaptureDiskSpace`. Β§8 confirmed `statfs` is present and returns sane block counts on node/bun/deno; it did NOT check wire units or path choice. | | `handles_active`, `requests_active` | `process.getActiveResourcesInfo()` | Elastic/OTel. Low priority; **MEASURED: always 0 on Bun/Deno β†’ Node only.** | | `process_uptime_s` | `process.uptime()` | Trivial; Sentry/Datadog/OTel. | @@ -73,12 +76,13 @@ the existing `process_*` / `event_loop_*` style. Units: bytes unless Android say | Trace / event | Source | Notes | |---|---|---| | `fps` | `requestAnimationFrame` counter over the 1 s window | Android `fps`; iOS `fps`; Highlight/OpenReplay. **Gate on visibility; never keep a permanent rAF loop in a hidden tab** (battery + keeps page awake). | -| `app_state` (step trace) | `document.visibilityState` | Android+iOS have it; JS has only the events today. Reuse the existing visibility listener. | +| `app_state` (step trace) | `document.visibilityState` mapped to the Android vocabulary (`foreground`/`background`, per Android `TraceAppState`) | NEVER put raw `visible`/`hidden`/`prerender` on `traces.system`. Verify the exact Android value set and the viewer table (Q5) first; `process_foreground`/`process_background` events already exist, so this trace is optional until the viewer needs the step curve. | | `displays` | `screen.width/height`, `devicePixelRatio`; Electron `screen.getAllDisplays()` | Static snapshot + `resize`/`display-metrics-changed`. | -| `thermal_state` | Compute Pressure `PressureObserver` | Chromium-only, experimental, permissions-policy gated β†’ **behind feature detection, off by default**. | -| `DarkModeChanged` event | `matchMedia('(prefers-color-scheme: dark)')` change | Android name; trivial. | +| `cpu_pressure` (**not** `thermal_state`) | Compute Pressure `PressureObserver.observe('cpu')` | Chromium-only, experimental, permissions-policy gated. It is CPU CONTENTION (incl. other tabs) with states `nominal/fair/serious/critical`, not Android's 0–6 temperature/throttling enum β€” so it gets a JS-specific name and the API's own state strings. Map to `thermal_state` only if `PressureObserver.knownSources` includes `thermals` AND the value is converted to Android's int enum. Feature-detect the SOURCE, not just the constructor. Off by default. | +| `dark_mode_changed` event | `matchMedia('(prefers-color-scheme: dark)')` change | Existing JS `events.system` names are snake_case; use `dark_mode_changed` unless Q5 shows the viewer keys Android's `DarkModeChanged`. | | `freeze` / `resume` events | Page Lifecycle | Complements `process_background`. | -| Event-driven `battery`/`charging`/`connection` | `levelchange`, `chargingchange`, `connection` `change` | Replaces 1 s polling for these; OpenReplay precedent. Keep a 1 s fallback only where events are absent. | +| Event-driven `battery`/`charging` | `levelchange`, `chargingchange` | May replace 1 s polling for these two. | +| `connection` β€” **keep polling (or add events) but never drop the `onLine`-first mapper** | `window` `online`/`offline` **and** `connection.change`, mapped through `system-trace-values.ts` (`navigator.onLine` outranks `connection.type`) | `connection.change` does NOT fire on `offline` (a Chromium NetworkInformation can still say `wifi` while offline), so replacing the 1 s read with `change` alone leaves the trace stuck on `wifi` after the network drops while `events.system` `offline` fires β€” the streams disagree. Do not remove polling until the online/offline + change pair is specified and tested. | | `hardware_concurrency` (context, not trace) | `navigator.hardwareConcurrency` | Sentry; static β†’ belongs in environment, not a trace. | | Main-thread responsiveness | `setTimeout`/`MessageChannel` round-trip delta | Browser analogue of node `event_loop_lag_*` / iOS `queue.main`. Name `main_thread_lag_ms`. | @@ -102,7 +106,7 @@ Gated into the main-process sampler; `electron` is already an injected arg (no p ### Explicitly NOT ported `wifi`, `cellular`, `bluetooth`, `nfc`, `screen_brightness`, `game_mode`, `satellite`, `device_idle`, -`low_power_*`, `thermal_headroom`, iOS `accessibility`/`fold_state`/`hinge_angle`, `ram_low_memory` / +`low_power_*`, `thermal_state`/`thermal_headroom` (browser Compute Pressure is CPU, not thermal β€” see `cpu_pressure`), iOS `accessibility`/`fold_state`/`hinge_angle`, `ram_low_memory` / `MemoryPressure` / `TrimMemory` (no JS signal; a heap-ratio threshold would be an invention β€” if wanted later it must be named derived, e.g. `heap_pressure`), `UserDidTakeScreenshot`, `ClipboardChanged` content, input-device events. Reasons: no API, privacy, or Bugsee-internal state. @@ -120,8 +124,16 @@ events. Reasons: no API, privacy, or Bugsee-internal state. - **Cadence.** Keep the single 1 s sampler for cheap sync reads. Slow/expensive sources (`fs.statfs`, `getAppMetrics`, `getHeapSpaceStatistics`) sample every N ticks (Android precedent: process memory every 5th tick, disk every 5 s). Event-driven sources emit on change with dedupe (iOS `emitIfChangedName`). -- **Cost.** GC observer and rAF counter run only while a subscriber is present (subscriber-presence drives - activation, per the pub/sub architecture). Budget: the sampler tick must stay well under the existing +- **Lifecycle seam (REQUIRED, ST0).** Today the platform sampler is a bare `sample: () => TraceSample[]` + (`system-traces-provider.ts:33`) built unconditionally at launch (`browser/src/launch.ts:798`, + `node/src/launch.ts:866`), and the provider is not tied to an interceptor, so interceptor subscriber-presence + does NOT apply to it; `stop()` only clears the 1 s timer. Standing sources (GC `PerformanceObserver`, rAF + counter, `PressureObserver`, battery/connection/`matchMedia` listeners) therefore MUST NOT be installed in the + sampler factory β€” they would run with `captureSystemTraces:false` and survive `client.stop()`. Extend the + sampler contract to `{ start(), stop(), sample() }` (or a dedicated provider/interceptor): attach in + `start`, tear down in `stop`; the gate is `captureSystemTraces` + provider start/stop. Each standing source + needs a test proving it is absent when the option is off and released on stop. +- **Cost.** GC observer and rAF counter run only between provider `start`/`stop` (see Lifecycle seam). Budget: the sampler tick must stay well under the existing cost envelope; measure, don't assume. - **Protocol.** Trace names are free-form in `traces.system`, but the viewer renders from fixed lookup tables (`web-tier-wire-alignment`): new names need a viewer-side title/units entry or they render raw. See Q5. @@ -159,10 +171,10 @@ deno 2.8.3. Method: allocate+retain ~100 MB of arrays, churn garbage, read `v8.g | `PerformanceObserver('gc')` entries after heavy churn + forced GC | **works** (28–234 entries; `detail.kind` + `duration` present) | **BLIND β€” 0 entries** | **BLIND β€” 0 entries** (also with `--v8-flags=--expose-gc`) | | `v8.getHeapStatistics()` tracks a +100 MB retained alloc | yes (Ξ”99.8 MB, = `memoryUsage().heapUsed`) | yes but **JSC-derived**: Ξ”62 MB for the same alloc; `malloced_memory == used == total` (aliases); **`heap_size_limit` is not a ceiling** β€” it moved 55.9 MB β†’ 1.07 GB with heap growth | yes (Ξ”99.9 MB, real V8) | | `v8.getHeapSpaceStatistics()` | real spaces | answers, but only `old_space` is non-zero (a JSC mapping, not V8 spaces) | real spaces | -| `getHeapStatistics()` cost | 0.3 Β΅s/call | **0.2–1.6 ms/call** (`bun:jsc.heapStats()` 0.47 ms) β€” ~1000Γ— node | 1.3 Β΅s/call | +| `getHeapStatistics()` cost | 0.3 Β΅s/call | **0.2–1.6 ms/call at a small heap β€” NOT a bound.** `docs/research/capture-apis/SYNTHESIS.md` (Β§528–539, 699) measured it **heap-linear on Bun: 394 Β΅s at 1–5 MB β†’ 16.9 ms at 20–52 MB β†’ 69 ms at 127–199 MB** (~7% sustained stall at 1 Hz) because JSC walks the heap to fake V8 stats. Cite 69 ms as the bound. (`bun:jsc.heapStats()` 0.47 ms here, one heap size.) | 1.3 Β΅s/call | | `fs.statfs` / `statfsSync` | present (node 18.15+ ok); ~0.8 Β΅s sync | present, correct (same blocks as node); 0.5 Β΅s | present, correct; 2.1 Β΅s | | `process.availableMemory()` | absent on node 18; present 22+ | present | present β€” but value differs wildly (24 GB vs node's 0.9 GB on the same host): **semantics not comparable across runtimes** | -| `process.constrainedMemory()` | 0 (no cgroup limit) | 68.7 GB (looks like a cap, not a container limit) | 0 | +| `process.constrainedMemory()` | 0 on **macOS**; on Linux Node 22.14 (review host) it returned `18446744073709552000` (β‰ˆ2^64, the "memory.max = max" sentinel), NOT 0 | 68.7 GB on macOS (not a container limit) | 0 on macOS | | `process.getActiveResourcesInfo()` | present (18+) | present, returns 0 entries | present, 0 entries | | `process.resourceUsage()` | present | present | **absent** | @@ -180,6 +192,9 @@ deno 2.8.3. Method: allocate+retain ~100 MB of arrays, churn garbage, read `v8.g 5. `handles_active`/`requests_active` is near-useless on Bun/Deno (always 0) β†’ Node only, low priority. 6. Per-space heap is meaningless on Bun β†’ Node/Deno only if built at all. +**Corrections after review (PR #53):** `constrainedMemory` unconstrained sentinel differs by OS (Linux 2^64 vs +macOS 0); the Bun heap-stats cost is heap-linear (SYNTHESIS Β§699), not 1.6 ms. + **Still unmeasured:** Deno <2.8 (declared floor 2.0), Bun <1.4, Node 20; browser probes (rAF in hidden tabs, Compute Pressure, battery events); sampler tick cost with P1 enabled through the SDK; Linux/Windows (all numbers above are macOS). @@ -188,23 +203,25 @@ Compute Pressure, battery events); sampler tick cost with P1 enabled through the | Slice | Content | Depends on | |---|---|---| -| ST0 | Wire-shape decisions Q2/Q3/Q5 + `charging` shape fix | β€” | -| ST1 | Node: `v8_heap_*`, `ram_js_heap_*` aliases, `process_uptime_s`, `ram_system_available` | probes 2, 4 | +| ST0 | Wire-shape decisions Q2/Q3/Q5 + `charging` shape fix + **sampler `{start,stop,sample}` lifecycle seam** | β€” | +| ST1 | Node/Deno: `v8_heap_*`, ceiling-based `ram_js_heap_*`, `process_memory_limit` (Linux probe first), `process_uptime_s`, optional `ram_system_available` (Node β‰₯22) | probes 2, 4 + Linux | | ST2 | Node: GC observer (`gc_*`) with per-runtime gate | probe 1 | | ST3 | Node: `disk_mem_*` (+ option) | probe 3 | | ST4 | Browser: `app_state`, `fps`, `displays`, `main_thread_lag_ms`, dark-mode/freeze/resume events | probe 5 | -| ST5 | Browser: event-driven battery/connection | probe 5 | +| ST5 | Browser: event-driven battery/charging; `connection` only with the online/offline + change pair and the `onLine`-first mapper kept | probe 5 | | ST6 | Electron: `app_metrics`, `child_process_gone`, power/screen/window events, GPU context | β€” | | ST7 | Serverless context: Lambda context fields, cloud-region audit | Q4 | -| ST8 | Optional: Compute Pressure `thermal_state` (flagged off) | probe 5 | +| ST8 | Optional: Compute Pressure `cpu_pressure` (flagged off) | probe 5 | ## 10. Open questions (need a product/owner decision) - **Q1.** Do long tasks / LoAF / web vitals / navigation+resource timing belong in `@bugsee/performance` (already owns web-vitals) rather than `traces.system`? Recommendation: yes β€” keep them out of this doc's scope and confirm `@bugsee/performance` covers long tasks/LoAF; check for overlap before any build. -- **Q2.** Should Node emit `ram_js_heap_*` *in addition to* `process_memory_heap_*`? Recommendation: yes - (viewer grouping parity with browser/Android); accept the duplicate values. +- **Q2.** Should Node/Deno emit `ram_js_heap_*` *in addition to* `process_memory_heap_*`? Recommendation: yes, + as a DIFFERENT quantity (ceiling-based total/free, per Β§4 P1), not a duplicate; only `used` repeats. Subsumes + `SYNTHESIS.md` T1 (`heap_size_limit` / `constrainedMemory`), which already proposed this β€” this doc must + reconcile with it, not restate it. - **Q3.** Omit vs `"$$unavailable"` for unsupported services. Recommendation: keep omitting (consistent with the ELU/Deno precedent) unless the viewer needs the sentinel β€” verify viewer handling first. - **Q4.** Does the env builder already detect cloud provider/region? (Not checked.) From 74554474b67644470edd79e5b67f910fc3b8d2bc Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 11:25:14 +0500 Subject: [PATCH 04/14] docs(design): address second review round on system traces expansion 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-expansion.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index d439fd81..751b7d07 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -46,7 +46,7 @@ specifies them). small (Β§4). Many Android/iOS traces have **no JS API** and are explicitly not ported. **Wire-shape clashes (must resolve before reuse).** -1. `charging`: Android `{state, source}` map; iOS 4-state int; JS boolean. +1. `charging`: Android `{state, source}` map; iOS 4-state int; JS boolean (shipped in 0.1.0-beta.3). **Decision needed before any change (ST0):** the Battery API has no `source`, and changing a shipped wire shape is breaking. Options: (a) keep the boolean under its current name and stop calling it a bug; (b) emit Android `{state}` with `source` OMITTED (never invented) and `state` mapped via a table written here (`charging`/`full`/`unplugged`, verified against Android `TracePowerStatus` and the viewer); (c) keep the boolean and accept the viewer gap. **Recommendation: (a)/(c) β€” do not touch the shipped boolean until Q5 shows what the viewer does with it.** 2. `cpu`: iOS = percent summed over threads (can exceed 100); JS `cpu_usage_process` = per-core-normalized. 3. `connection` (JS) vs `network` (iOS) vs Android `connection` β€” JS already follows Android's vocabulary (`web-tier-wire-alignment`); keep. @@ -77,14 +77,14 @@ the existing `process_*` / `event_loop_*` style. Units: bytes unless the reused |---|---|---| | `fps` | `requestAnimationFrame` counter over the 1 s window | Android `fps`; iOS `fps`; Highlight/OpenReplay. **Gate on visibility; never keep a permanent rAF loop in a hidden tab** (battery + keeps page awake). | | `app_state` (step trace) | `document.visibilityState` mapped to the Android vocabulary (`foreground`/`background`, per Android `TraceAppState`) | NEVER put raw `visible`/`hidden`/`prerender` on `traces.system`. Verify the exact Android value set and the viewer table (Q5) first; `process_foreground`/`process_background` events already exist, so this trace is optional until the viewer needs the step curve. | -| `displays` | `screen.width/height`, `devicePixelRatio`; Electron `screen.getAllDisplays()` | Static snapshot + `resize`/`display-metrics-changed`. | +| `screen_size` (JS-specific; **not** `displays`) | `screen.width/height`, `devicePixelRatio` on `resize` | Android `displays` is a display LIST (id, density, flags, bounds), not two CSS-pixel numbers, so the browser `Screen` must not reuse the name. Static values already ship as `hardware.screen_width/screen_height/pixel_ratio` (`browser/src/environment.ts:116-118`); add this only if resize-over-time is wanted, with an explicit value shape `{width, height, dpr}` and Q5 viewer confirmation. Otherwise stay environment-only. | | `cpu_pressure` (**not** `thermal_state`) | Compute Pressure `PressureObserver.observe('cpu')` | Chromium-only, experimental, permissions-policy gated. It is CPU CONTENTION (incl. other tabs) with states `nominal/fair/serious/critical`, not Android's 0–6 temperature/throttling enum β€” so it gets a JS-specific name and the API's own state strings. Map to `thermal_state` only if `PressureObserver.knownSources` includes `thermals` AND the value is converted to Android's int enum. Feature-detect the SOURCE, not just the constructor. Off by default. | | `dark_mode_changed` event | `matchMedia('(prefers-color-scheme: dark)')` change | Existing JS `events.system` names are snake_case; use `dark_mode_changed` unless Q5 shows the viewer keys Android's `DarkModeChanged`. | | `freeze` / `resume` events | Page Lifecycle | Complements `process_background`. | | Event-driven `battery`/`charging` | `levelchange`, `chargingchange` | May replace 1 s polling for these two. | | `connection` β€” **keep polling (or add events) but never drop the `onLine`-first mapper** | `window` `online`/`offline` **and** `connection.change`, mapped through `system-trace-values.ts` (`navigator.onLine` outranks `connection.type`) | `connection.change` does NOT fire on `offline` (a Chromium NetworkInformation can still say `wifi` while offline), so replacing the 1 s read with `change` alone leaves the trace stuck on `wifi` after the network drops while `events.system` `offline` fires β€” the streams disagree. Do not remove polling until the online/offline + change pair is specified and tested. | | `hardware_concurrency` (context, not trace) | `navigator.hardwareConcurrency` | Sentry; static β†’ belongs in environment, not a trace. | -| Main-thread responsiveness | `setTimeout`/`MessageChannel` round-trip delta | Browser analogue of node `event_loop_lag_*` / iOS `queue.main`. Name `main_thread_lag_ms`. | +| Main-thread responsiveness | `MessageChannel` / `scheduler.yield` round-trip delta (not `setTimeout(0)`, 4 ms clamp) | Name `main_thread_lag_ms`. **Measure only while `document.visibilityState === 'visible'`; omit (never zero) when hidden** β€” background tabs clamp/throttle timers and `MessageChannel` to ~1 s and then ~1 min, so a hidden-tab round-trip measures throttling and would paint a hang that did not happen (the inverse of the Deno `event_loop_lag` lie). Not comparable to iOS `queue.main`, which has no such artifact. Probe 5 must include hidden-tab lag vs a known sync block before ST4. | ### P3 β€” Electron main-process telemetry (`@bugsee/electron`) Gated into the main-process sampler; `electron` is already an injected arg (no package dependency). @@ -93,7 +93,7 @@ Gated into the main-process sampler; `electron` is already an injected arg (no p | Per-process CPU+memory (`app_metrics`: type, pid, cpu %, workingSet) | `app.getAppMetrics()` | | `child_process_gone` event (type, reason, exitCode) | `app.on('child-process-gone')` β€” beyond the existing `render-process-gone` incident path | | `power_suspend/resume`, `power_lock/unlock`, `power_ac/battery` events | `powerMonitor` | -| `displays` (above) incl. add/remove/metrics-change events | `screen` | +| `displays` as an Android-shaped display LIST (id, size, scale, state), + add/remove/metrics-change events | `screen.getAllDisplays()` β€” use the name `displays` ONLY once that list shape is specified/confirmed against the viewer (Q5); else a JS-specific name | | GPU info (context) | `app.getGPUInfo('basic')` β€” wrap in try/catch (throws on Linux without acceleration, per Sentry) | | Window focus/blur/show/hide/unresponsive events | `BrowserWindow` | @@ -118,6 +118,16 @@ events. Reasons: no API, privacy, or Bugsee-internal state. `@bugsee/core`. - **Runtime-portable.** Shared tiers reach globals only via `globalThis as unknown as {…}` casts. `v8`/`fs`/ `perf_hooks` live in `@bugsee/node` behind the existing guarded-metrics seam (`guarded-system-metrics.ts`). +- **Named per-runtime flags, never `typeof fn === 'function'` or `value !== 0`.** ST1 lives in + `createNodeSystemMetricsSampler`, which `@bugsee/bun`/`@bugsee/deno` reuse wholesale via + `createGuardedSystemMetricsSampler`, so any metric added there ships on all three runtimes unless flagged. + Add `measuresGc`, `measuresV8Heap`, `measuresAvailableMemory`, `measuresConstrainedMemory` (default ON for + Node). `@bugsee/bun`: all four `false`. `@bugsee/deno`: `measuresGc:false`, `measuresAvailableMemory:false` + (incomparable), `measuresConstrainedMemory:false` until a Linux probe, `measuresV8Heap:true` (measured real). + Put the bun/deno `false`s AFTER `...options` β€” where `captureLocalVariables: false` already lives + (`bun/src/launch.ts:32`, `deno/src/launch.ts:41`) β€” so a caller cannot switch the lie back on. Bun's + `constrainedMemory` of 68.7 GB passes any numeric sanity filter, which is why the sentinel check alone is not a gate. + Tests must run the sampler under a Bun/Deno-shaped fake and assert these traces are ABSENT. - **Per-runtime gates, not passive tells.** Anything UNMEASURED on Bun/Deno ships with a runtime flag (like `measuresEventLoopDelay`) defaulting to OFF there until measured; `@bugsee/{bun,deno}` flip it only with a recorded measurement in this doc. @@ -154,7 +164,7 @@ Measure, on real runtimes, with the SDK's own call path (not a toy script): 2. `v8.getHeapStatistics()` and `getHeapSpaceStatistics()` β€” present and non-constant on bun and deno? 3. `fs.statfs` β€” present and correct on bun and deno? 4. `process.availableMemory()` / `getActiveResourcesInfo()` β€” presence per runtime and min Node version. -5. Browser: rAF-counter cost and accuracy in background tabs; Compute Pressure availability; `battery` +5. Browser: rAF-counter cost and accuracy in background tabs; `main_thread_lag_ms` in a hidden tab vs a known sync block; Compute Pressure availability; `battery` events on Chromium. 6. Sampler tick cost with P1 enabled (Β΅s per tick) vs the current baseline. Record each result in Β§8 with runtime version and date, as `docs/PROGRESS.md` does for the existing gates. @@ -203,11 +213,11 @@ Compute Pressure, battery events); sampler tick cost with P1 enabled through the | Slice | Content | Depends on | |---|---|---| -| ST0 | Wire-shape decisions Q2/Q3/Q5 + `charging` shape fix + **sampler `{start,stop,sample}` lifecycle seam** | β€” | +| ST0 | Wire-shape decisions Q2/Q3/Q5 + `charging` decision (Β§3.1; default = leave as is) + **sampler `{start,stop,sample}` lifecycle seam** | β€” | | ST1 | Node/Deno: `v8_heap_*`, ceiling-based `ram_js_heap_*`, `process_memory_limit` (Linux probe first), `process_uptime_s`, optional `ram_system_available` (Node β‰₯22) | probes 2, 4 + Linux | | ST2 | Node: GC observer (`gc_*`) with per-runtime gate | probe 1 | | ST3 | Node: `disk_mem_*` (+ option) | probe 3 | -| ST4 | Browser: `app_state`, `fps`, `displays`, `main_thread_lag_ms`, dark-mode/freeze/resume events | probe 5 | +| ST4 | Browser: `app_state`, `fps`, `screen_size` (optional), `main_thread_lag_ms`, dark-mode/freeze/resume events | probe 5 | | ST5 | Browser: event-driven battery/charging; `connection` only with the online/offline + change pair and the `onLine`-first mapper kept | probe 5 | | ST6 | Electron: `app_metrics`, `child_process_gone`, power/screen/window events, GPU context | β€” | | ST7 | Serverless context: Lambda context fields, cloud-region audit | Q4 | From 71a4025814c2f14381f77dd5513833613a687d85 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 11:33:41 +0500 Subject: [PATCH 05/14] =?UTF-8?q?docs(design):=20third=20review=20round=20?= =?UTF-8?q?=E2=80=94=20gates=20on=20the=20guarded=20sampler,=20ST0=20scope?= =?UTF-8?q?,=20disk=20units?= 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-expansion.md | 53 +++++++++++++++++--------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index 751b7d07..c4e491a7 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -68,8 +68,8 @@ the existing `process_*` / `event_loop_*` style. Units: bytes unless the reused | `ram_js_heap_{total,free,used}` on Node/Deno | `v8.getHeapStatistics()`: `total` = `heap_size_limit` (the CEILING, Android `ram_jvm_heap` contract), `used` = `used_heap_size`, `free` = limit βˆ’ used | **Not an alias of `process_memory_heap_*`**: `process_memory_heap_total` is `heapTotal` (committed heap, not the ceiling). Only `used` is the same number in both families; `total` and `free` differ. Keep `process_memory_heap_*` as committed. Same name = same quantity as browser/Android. Decision Q2. | | `process_memory_limit` (cgroup cap) | `process.constrainedMemory()` (Node β‰₯18.15; `SYNTHESIS.md` T1 calls it the real OOM predictor) | Emit ONLY when the value is `> 0` AND `< 2^63` AND `≀ os.totalmem()`; omit for `0` (macOS/unconstrained) and the 2^64 Linux sentinel β€” never gate on `!== 0` alone. **Needs a Linux probe (macOS-only so far).** Deno/Bun semantics unmeasured. | | `ram_system_available` | `process.availableMemory()` | Sentry uses it. **MEASURED: not comparable across runtimes (24 GB vs 0.9 GB same host) and absent on Node 18 β€” Node β‰₯22 only, if at all (Β§8).** | -| `disk_mem_free`, `disk_mem_total` | `fs.statfs` (Node β‰₯18.15) | **Unit: integer MB** (the Android/iOS wire; a deliberate exception to the bytes rule above, converted in the sampler from `bsize Γ— bavail` / `bsize Γ— blocks`), change-deduped (β‰₯1 MB). **Path: the filesystem `dataDir` lives on** (`effectiveDataDir`), not `/` or cwd β€” it is that volume's space, not mobile "device storage", and differs from `/` on overlay/container roots. Opt-in like iOS `BugseeOptionCaptureDiskSpace`. Β§8 confirmed `statfs` is present and returns sane block counts on node/bun/deno; it did NOT check wire units or path choice. | -| `handles_active`, `requests_active` | `process.getActiveResourcesInfo()` | Elastic/OTel. Low priority; **MEASURED: always 0 on Bun/Deno β†’ Node only.** | +| `disk_mem_free`, `disk_mem_total` | `fs.statfs` (Node β‰₯18.15) | **Unit: integer MB via the existing `bytesToMegabytes` (`@bugsee/protocol` `units.ts:14-18`: `Math.floor(bytes/1024/1024)`, the Android `(int)` cast β€” binary, NOT `/1e6`; no second definition of "MB"), a deliberate exception to the bytes rule above, from `bsize Γ— bavail` / `bsize Γ— blocks`, change-deduped (β‰₯1 MB). **Path: the filesystem `dataDir` lives on** (`effectiveDataDir`), not `/` or cwd β€” it is that volume's space, not mobile "device storage", and differs from `/` on overlay/container roots. **Omit both traces when `effectiveDataDir` is undefined** (in-memory launch, or disk setup degraded β€” `node/src/launch.ts:580-609` clears it); never fall back to cwd or `/`. The sampler is built today with no `dataDir` dep (`launch.ts:866`), so ST3 must thread it in. Opt-in like iOS `BugseeOptionCaptureDiskSpace`. Β§8 confirmed `statfs` is present and returns sane block counts on node/bun/deno; it did NOT check wire units or path choice. | +| `handles_active`, `requests_active` | `process.getActiveResourcesInfo()` | Elastic/OTel. Low priority; **MEASURED: always 0 on Bun/Deno β†’ Node only, enforced by `measuresActiveResources` (Β§5); omit, never emit 0.** | | `process_uptime_s` | `process.uptime()` | Trivial; Sentry/Datadog/OTel. | ### P2 β€” Browser parity (`@bugsee/browser`) @@ -84,7 +84,7 @@ the existing `process_*` / `event_loop_*` style. Units: bytes unless the reused | Event-driven `battery`/`charging` | `levelchange`, `chargingchange` | May replace 1 s polling for these two. | | `connection` β€” **keep polling (or add events) but never drop the `onLine`-first mapper** | `window` `online`/`offline` **and** `connection.change`, mapped through `system-trace-values.ts` (`navigator.onLine` outranks `connection.type`) | `connection.change` does NOT fire on `offline` (a Chromium NetworkInformation can still say `wifi` while offline), so replacing the 1 s read with `change` alone leaves the trace stuck on `wifi` after the network drops while `events.system` `offline` fires β€” the streams disagree. Do not remove polling until the online/offline + change pair is specified and tested. | | `hardware_concurrency` (context, not trace) | `navigator.hardwareConcurrency` | Sentry; static β†’ belongs in environment, not a trace. | -| Main-thread responsiveness | `MessageChannel` / `scheduler.yield` round-trip delta (not `setTimeout(0)`, 4 ms clamp) | Name `main_thread_lag_ms`. **Measure only while `document.visibilityState === 'visible'`; omit (never zero) when hidden** β€” background tabs clamp/throttle timers and `MessageChannel` to ~1 s and then ~1 min, so a hidden-tab round-trip measures throttling and would paint a hang that did not happen (the inverse of the Deno `event_loop_lag` lie). Not comparable to iOS `queue.main`, which has no such artifact. Probe 5 must include hidden-tab lag vs a known sync block before ST4. | +| Main-thread responsiveness | `MessageChannel` / `scheduler.yield` round-trip delta (not `setTimeout(0)`, 4 ms clamp) | Name `main_thread_lag_ms`. **Measure only while `document.visibilityState === 'visible'`; omit (never zero) when hidden** β€” background tabs clamp/throttle timers and `MessageChannel` to ~1 s and then ~1 min, so a hidden-tab round-trip measures throttling and would paint a hang that did not happen (the inverse of the Deno `event_loop_lag` lie). Not comparable to iOS `queue.main`, which has no such artifact. Probe 5 must include hidden-tab lag vs a known sync block before ST4. **Mechanics:** the provider calls `sample()` synchronously (`system-traces-provider.ts:69-73`), so a round-trip cannot complete inside it. The ping is a STANDING source: armed in `start()` (post a message, record the delta on receipt), disconnected in `stop()`; `sample()` only reads the LAST COMPLETED delta and omits until one exists (and while hidden). No busy-wait, no fire-and-forget returning 0. | ### P3 β€” Electron main-process telemetry (`@bugsee/electron`) Gated into the main-process sampler; `electron` is already an injected arg (no package dependency). @@ -118,16 +118,24 @@ events. Reasons: no API, privacy, or Bugsee-internal state. `@bugsee/core`. - **Runtime-portable.** Shared tiers reach globals only via `globalThis as unknown as {…}` casts. `v8`/`fs`/ `perf_hooks` live in `@bugsee/node` behind the existing guarded-metrics seam (`guarded-system-metrics.ts`). -- **Named per-runtime flags, never `typeof fn === 'function'` or `value !== 0`.** ST1 lives in - `createNodeSystemMetricsSampler`, which `@bugsee/bun`/`@bugsee/deno` reuse wholesale via - `createGuardedSystemMetricsSampler`, so any metric added there ships on all three runtimes unless flagged. - Add `measuresGc`, `measuresV8Heap`, `measuresAvailableMemory`, `measuresConstrainedMemory` (default ON for - Node). `@bugsee/bun`: all four `false`. `@bugsee/deno`: `measuresGc:false`, `measuresAvailableMemory:false` - (incomparable), `measuresConstrainedMemory:false` until a Linux probe, `measuresV8Heap:true` (measured real). - Put the bun/deno `false`s AFTER `...options` β€” where `captureLocalVariables: false` already lives - (`bun/src/launch.ts:32`, `deno/src/launch.ts:41`) β€” so a caller cannot switch the lie back on. Bun's - `constrainedMemory` of 68.7 GB passes any numeric sanity filter, which is why the sentinel check alone is not a gate. - Tests must run the sampler under a Bun/Deno-shaped fake and assert these traces are ABSENT. +- **Named per-runtime flags on the GUARDED SAMPLER, never `typeof fn === 'function'` or `value !== 0`.** ST1 + lives in `createNodeSystemMetricsSampler`, which `@bugsee/bun`/`@bugsee/deno` reuse via + `createGuardedSystemMetricsSampler(deps)` (`node/src/guarded-system-metrics.ts:113-127`). The flags are + `GuardedSystemMetricsDeps` fields β€” NOT launch options β€” passed into the factory call BEFORE `...options`, + exactly as `deno/src/launch.ts:29` does for `measuresEventLoopDelay: false` (keeping that position lets tests + still inject `systemMetricsSampler`). `captureLocalVariables: false` after `...options` is a launch option that + `nodeLaunchCore` reads; flags set there never reach the sampler and are silently ignored. Bun today calls + `createGuardedSystemMetricsSampler()` with NO args (`bun/src/launch.ts:22`). + New deps (default ON for Node): `measuresGc`, `measuresV8Heap`, `measuresAvailableMemory`, + `measuresConstrainedMemory`, `measuresActiveResources`. Each `false` must INJECT AN OMITTED READER (the way + `measuresEventLoopDelay: false` injects `eventLoop: unavailableLoop`) or a `NodeSystemMetricsDeps` field the node + sampler branches on β€” a boolean that is merely destructured does nothing. Values: `@bugsee/bun` all five + `false`; `@bugsee/deno` `measuresGc`/`measuresAvailableMemory`/`measuresConstrainedMemory`/ + `measuresActiveResources` `false` (blind, incomparable, unprobed on Linux, always 0), `measuresV8Heap: true` + (measured real). Bun's `constrainedMemory` of 68.7 GB passes any numeric sanity filter, so the sentinel check + is not a gate. Tests: pin the factory call per runtime + (`toHaveBeenCalledWith(expect.objectContaining({ measuresV8Heap: false, … }))`, as lag is pinned in + `deno/src/launch.test.ts`), AND run the sampler under a Bun/Deno-shaped fake asserting the traces are ABSENT. - **Per-runtime gates, not passive tells.** Anything UNMEASURED on Bun/Deno ships with a runtime flag (like `measuresEventLoopDelay`) defaulting to OFF there until measured; `@bugsee/{bun,deno}` flip it only with a recorded measurement in this doc. @@ -138,11 +146,20 @@ events. Reasons: no API, privacy, or Bugsee-internal state. (`system-traces-provider.ts:33`) built unconditionally at launch (`browser/src/launch.ts:798`, `node/src/launch.ts:866`), and the provider is not tied to an interceptor, so interceptor subscriber-presence does NOT apply to it; `stop()` only clears the 1 s timer. Standing sources (GC `PerformanceObserver`, rAF - counter, `PressureObserver`, battery/connection/`matchMedia` listeners) therefore MUST NOT be installed in the + counter, the `MessageChannel` ping, `PressureObserver`, battery/connection/`matchMedia` listeners) therefore MUST NOT be installed in the sampler factory β€” they would run with `captureSystemTraces:false` and survive `client.stop()`. Extend the sampler contract to `{ start(), stop(), sample() }` (or a dedicated provider/interceptor): attach in - `start`, tear down in `stop`; the gate is `captureSystemTraces` + provider start/stop. Each standing source - needs a test proving it is absent when the option is off and released on stop. + `start`, tear down in `stop`; **ST0 also migrates the EXISTING factory-time work**: `monitorEventLoopDelay().enable()` + at sampler construction (`node/src/system-metrics.ts:62-63`) and `getBattery()` in the browser factory + (`browser/src/system-metrics.ts:96-101`) move into `start()` (disable/release in `stop()`) β€” today + `captureSystemTraces:false` / `client.stop()` still starts the histogram and fetches BatteryManager. There are + THREE composition roots, not two: node (`launch.ts:866`), browser (`launch.ts:798`) and **webview** + (`webview/src/launch.ts:671-675`, same `createSystemTracesProvider({ sample: … ?? createBrowserSystemTracesSampler() })`); + all three must call `start()`/`stop()` or webview fps stays 0 / its factory leaks. The public option + `systemMetricsSampler?: () => readonly TraceSample[]` (`browser/src/launch.ts:411`) must keep working: a bare + function is wrapped as sample-only (start/stop no-ops) so every `() => []` test injector survives; the gate is `captureSystemTraces` + provider start/stop. Each standing source + needs a test proving it is absent when the option is off and released on stop β€” INCLUDING the two pre-existing + ones above. - **Cost.** GC observer and rAF counter run only between provider `start`/`stop` (see Lifecycle seam). Budget: the sampler tick must stay well under the existing cost envelope; measure, don't assume. - **Protocol.** Trace names are free-form in `traces.system`, but the viewer renders from fixed lookup tables @@ -226,7 +243,7 @@ Compute Pressure, battery events); sampler tick cost with P1 enabled through the ## 10. Open questions (need a product/owner decision) - **Q1.** Do long tasks / LoAF / web vitals / navigation+resource timing belong in `@bugsee/performance` - (already owns web-vitals) rather than `traces.system`? Recommendation: yes β€” keep them out of this doc's + (already owns `longtask` + web-vitals; it does NOT own `long-animation-frame`, which is research-only in `docs/research/capture-apis/browser.md` β€” LoAF is the open item) rather than `traces.system`? Recommendation: yes β€” keep them out of this doc's scope and confirm `@bugsee/performance` covers long tasks/LoAF; check for overlap before any build. - **Q2.** Should Node/Deno emit `ram_js_heap_*` *in addition to* `process_memory_heap_*`? Recommendation: yes, as a DIFFERENT quantity (ceiling-based total/free, per Β§4 P1), not a duplicate; only `used` repeats. Subsumes @@ -234,7 +251,7 @@ Compute Pressure, battery events); sampler tick cost with P1 enabled through the reconcile with it, not restate it. - **Q3.** Omit vs `"$$unavailable"` for unsupported services. Recommendation: keep omitting (consistent with the ELU/Deno precedent) unless the viewer needs the sentinel β€” verify viewer handling first. -- **Q4.** Does the env builder already detect cloud provider/region? (Not checked.) +- **Q4.** Does the env builder already detect cloud provider/region? **Checked in review: NO** β€” node/browser env builders do not read `AWS_REGION`/`VERCEL`/`FLY_REGION`; `hardware.cpu_count` and `hardware.screen_*`/`pixel_ratio` already exist, so do not re-emit those as traces. - **Q5.** Which new trace names does the viewer already render? (Viewer tables not inspected.) Any name the viewer lacks needs a viewer PR (precedent: worker PR 46 / viewer PR 14). - **Q6.** `disk_mem_*` privacy/opt-in: mirror iOS's option-gated capture? Recommendation: opt-in. From 17323f5af9c5cb72117ef12bfcbdf9f3dba43545 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 11:44:30 +0500 Subject: [PATCH 06/14] =?UTF-8?q?docs(design):=20fourth=20review=20round?= =?UTF-8?q?=20=E2=80=94=20lifecycle=20samplers=20for=20bun/deno,=20presenc?= =?UTF-8?q?e=20vs=20honesty=20gates?= 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-expansion.md | 38 ++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index c4e491a7..e4560058 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -66,7 +66,7 @@ the existing `process_*` / `event_loop_*` style. Units: bytes unless the reused | `gc_pause_ms` (sum/max per interval), `gc_count` (optionally by type tag) | `PerformanceObserver({entryTypes:['gc']})` | Broadest competitor-backed gap. **MEASURED: node only β€” blind on Bun and Deno (Β§8). Gate OFF there.** | | `v8_heap_size_limit`, `v8_heap_available`, `v8_heap_malloced`, `v8_heap_peak_malloced` | `v8.getHeapStatistics()` | Cheap on Node/Deno (Β΅s). **MEASURED: do not emit on Bun** β€” JSC-derived, limit not a ceiling, ~1 ms/call (Β§8). | | `ram_js_heap_{total,free,used}` on Node/Deno | `v8.getHeapStatistics()`: `total` = `heap_size_limit` (the CEILING, Android `ram_jvm_heap` contract), `used` = `used_heap_size`, `free` = limit βˆ’ used | **Not an alias of `process_memory_heap_*`**: `process_memory_heap_total` is `heapTotal` (committed heap, not the ceiling). Only `used` is the same number in both families; `total` and `free` differ. Keep `process_memory_heap_*` as committed. Same name = same quantity as browser/Android. Decision Q2. | -| `process_memory_limit` (cgroup cap) | `process.constrainedMemory()` (Node β‰₯18.15; `SYNTHESIS.md` T1 calls it the real OOM predictor) | Emit ONLY when the value is `> 0` AND `< 2^63` AND `≀ os.totalmem()`; omit for `0` (macOS/unconstrained) and the 2^64 Linux sentinel β€” never gate on `!== 0` alone. **Needs a Linux probe (macOS-only so far).** Deno/Bun semantics unmeasured. | +| `process_memory_limit` (cgroup cap) | `process.constrainedMemory()` (Node β‰₯18.15 β€” absent on 18.0–18.14, feature-detect; `SYNTHESIS.md` T1 calls it the real OOM predictor) | Emit ONLY when the value is `> 0` AND `< 2^63` AND `≀ os.totalmem()`; omit for `0` (macOS/unconstrained) and the 2^64 Linux sentinel β€” never gate on `!== 0` alone. **Needs a Linux probe (macOS-only so far).** Deno/Bun semantics unmeasured. | | `ram_system_available` | `process.availableMemory()` | Sentry uses it. **MEASURED: not comparable across runtimes (24 GB vs 0.9 GB same host) and absent on Node 18 β€” Node β‰₯22 only, if at all (Β§8).** | | `disk_mem_free`, `disk_mem_total` | `fs.statfs` (Node β‰₯18.15) | **Unit: integer MB via the existing `bytesToMegabytes` (`@bugsee/protocol` `units.ts:14-18`: `Math.floor(bytes/1024/1024)`, the Android `(int)` cast β€” binary, NOT `/1e6`; no second definition of "MB"), a deliberate exception to the bytes rule above, from `bsize Γ— bavail` / `bsize Γ— blocks`, change-deduped (β‰₯1 MB). **Path: the filesystem `dataDir` lives on** (`effectiveDataDir`), not `/` or cwd β€” it is that volume's space, not mobile "device storage", and differs from `/` on overlay/container roots. **Omit both traces when `effectiveDataDir` is undefined** (in-memory launch, or disk setup degraded β€” `node/src/launch.ts:580-609` clears it); never fall back to cwd or `/`. The sampler is built today with no `dataDir` dep (`launch.ts:866`), so ST3 must thread it in. Opt-in like iOS `BugseeOptionCaptureDiskSpace`. Β§8 confirmed `statfs` is present and returns sane block counts on node/bun/deno; it did NOT check wire units or path choice. | | `handles_active`, `requests_active` | `process.getActiveResourcesInfo()` | Elastic/OTel. Low priority; **MEASURED: always 0 on Bun/Deno β†’ Node only, enforced by `measuresActiveResources` (Β§5); omit, never emit 0.** | @@ -136,6 +136,21 @@ events. Reasons: no API, privacy, or Bugsee-internal state. is not a gate. Tests: pin the factory call per runtime (`toHaveBeenCalledWith(expect.objectContaining({ measuresV8Heap: false, … }))`, as lag is pinned in `deno/src/launch.test.ts`), AND run the sampler under a Bun/Deno-shaped fake asserting the traces are ABSENT. +- **Presence vs honesty are different gates.** The `measures*` flags above answer "does this runtime LIE" + (Bun/Deno: API exists, number is wrong). They do not answer "does this API EXIST on the declared floor" + (`@bugsee/node` `engines: >=18`): `process.availableMemory()` is absent on Node 18 (Β§8) and needs β‰₯20.13/22; + `constrainedMemory()` needs β‰₯18.15 (`docs/research/capture-apis/node.md:141-142`). Node launch uses + `createNodeSystemMetricsSampler()` with no flags (`node/src/launch.ts:866`), not the guarded wrapper. So each + reader is added with BOTH: a presence check feature-detected at construction (an absent function β‡’ omitted + reader β€” this is not the banned `typeof` honesty gate, it is floor compatibility) AND per-reader try/catch + β†’ omit. This is mandatory because the provider runs `sample()` synchronously in `onStart`β†’`#emit` with no + try/catch (`system-traces-provider.ts:59-73`), `captureCoordinator.start()` does not isolate per provider + (`capture-coordinator.ts:69-71`) and system events register AFTER traces (`launch.ts:870`): one throw on the + initial snapshot means `process_started` never emits, and a throw on a later tick (e.g. `statfsSync` ENOENT after + a tmp cleaner removes `dataDir`) is an uncaught `setInterval` exception in the host. A FAILED `statfs` must + OMIT β€” never flow through `bytesToMegabytes`, whose 0-on-NaN would report a full disk. Tests: run the sampler + with each API missing and each API throwing; assert launch succeeds, `process_started` still emits, and only + that trace is absent. - **Per-runtime gates, not passive tells.** Anything UNMEASURED on Bun/Deno ships with a runtime flag (like `measuresEventLoopDelay`) defaulting to OFF there until measured; `@bugsee/{bun,deno}` flip it only with a recorded measurement in this doc. @@ -155,9 +170,22 @@ events. Reasons: no API, privacy, or Bugsee-internal state. `captureSystemTraces:false` / `client.stop()` still starts the histogram and fetches BatteryManager. There are THREE composition roots, not two: node (`launch.ts:866`), browser (`launch.ts:798`) and **webview** (`webview/src/launch.ts:671-675`, same `createSystemTracesProvider({ sample: … ?? createBrowserSystemTracesSampler() })`); - all three must call `start()`/`stop()` or webview fps stays 0 / its factory leaks. The public option - `systemMetricsSampler?: () => readonly TraceSample[]` (`browser/src/launch.ts:411`) must keep working: a bare - function is wrapped as sample-only (start/stop no-ops) so every `() => []` test injector survives; the gate is `captureSystemTraces` + provider start/stop. Each standing source + all three must call `start()`/`stop()` or webview fps stays 0 / its factory leaks. **The factories themselves change**: `createNodeSystemMetricsSampler`, `createGuardedSystemMetricsSampler` + (`guarded-system-metrics.ts:116-118`, today `() => TraceSample[]`) and `createBrowserSystemTracesSampler` all + return `LifecycleSampler = { start, stop, sample }`. Bun/Deno do NOT take the `?? createNodeSystemMetricsSampler()` + default β€” they INJECT `createGuardedSystemMetricsSampler(…)` through `options.systemMetricsSampler` + (`bun/src/launch.ts:22`, `deno/src/launch.ts:29`), so they pick up the object. Widen the public option + (`browser/src/launch.ts:411`, node equivalent) to `(() => readonly TraceSample[]) | LifecycleSampler` and wrap + ONLY `typeof === 'function'` values (test stubs like `() => []`) as sample-only; a function wrap at the launch + line cannot tell a stub from production, which is why the production samplers must stop being bare functions. + The Bun/Deno event-loop histogram is enabled at CONSTRUCTION in `guardedEventLoop` + (`guarded-system-metrics.ts:54-56`), NOT in `createEventLoopReader` (`system-metrics.ts:62-63`, node-only): move + BOTH `enable()` sites into `start()` (and `disable` in `stop()`). Getting this half-right fails two ways β€” + moving only the node site leaves Bun leaking a running histogram after `captureSystemTraces:false`/`stop()`; + no-op-wrapping the guarded sampler while moving `enable()` leaves Bun's `event_loop_lag_*` at the idle floor + through a real stall (the Deno lie, on Bun). Bun/Deno tests pin that the DEFAULT sampler has a real `start` + (histogram enabled only after provider start, released on stop), as lag is pinned in + `deno/src/launch.test.ts:278-279`; the gate is `captureSystemTraces` + provider start/stop. Each standing source needs a test proving it is absent when the option is off and released on stop β€” INCLUDING the two pre-existing ones above. - **Cost.** GC observer and rAF counter run only between provider `start`/`stop` (see Lifecycle seam). Budget: the sampler tick must stay well under the existing @@ -231,7 +259,7 @@ Compute Pressure, battery events); sampler tick cost with P1 enabled through the | Slice | Content | Depends on | |---|---|---| | ST0 | Wire-shape decisions Q2/Q3/Q5 + `charging` decision (Β§3.1; default = leave as is) + **sampler `{start,stop,sample}` lifecycle seam** | β€” | -| ST1 | Node/Deno: `v8_heap_*`, ceiling-based `ram_js_heap_*`, `process_memory_limit` (Linux probe first), `process_uptime_s`, optional `ram_system_available` (Node β‰₯22) | probes 2, 4 + Linux | +| ST1 | Node/Deno: `v8_heap_*`, ceiling-based `ram_js_heap_*`, `process_memory_limit` (Linux probe first), `process_uptime_s`, optional `ram_system_available` (Node β‰₯20.13, feature-detected) | probes 2, 4 + Linux | | ST2 | Node: GC observer (`gc_*`) with per-runtime gate | probe 1 | | ST3 | Node: `disk_mem_*` (+ option) | probe 3 | | ST4 | Browser: `app_state`, `fps`, `screen_size` (optional), `main_thread_lag_ms`, dark-mode/freeze/resume events | probe 5 | From 929fd62a7ef0a9df0a89d11cb8b286017cab5aa8 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 11:56:13 +0500 Subject: [PATCH 07/14] =?UTF-8?q?docs(design):=20fifth=20review=20round=20?= =?UTF-8?q?=E2=80=94=20lag=20visibility=20lifecycle,=20electron=20wraps=20?= =?UTF-8?q?the=20node=20sampler?= 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-expansion.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index e4560058..9266b0bc 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -67,7 +67,7 @@ the existing `process_*` / `event_loop_*` style. Units: bytes unless the reused | `v8_heap_size_limit`, `v8_heap_available`, `v8_heap_malloced`, `v8_heap_peak_malloced` | `v8.getHeapStatistics()` | Cheap on Node/Deno (Β΅s). **MEASURED: do not emit on Bun** β€” JSC-derived, limit not a ceiling, ~1 ms/call (Β§8). | | `ram_js_heap_{total,free,used}` on Node/Deno | `v8.getHeapStatistics()`: `total` = `heap_size_limit` (the CEILING, Android `ram_jvm_heap` contract), `used` = `used_heap_size`, `free` = limit βˆ’ used | **Not an alias of `process_memory_heap_*`**: `process_memory_heap_total` is `heapTotal` (committed heap, not the ceiling). Only `used` is the same number in both families; `total` and `free` differ. Keep `process_memory_heap_*` as committed. Same name = same quantity as browser/Android. Decision Q2. | | `process_memory_limit` (cgroup cap) | `process.constrainedMemory()` (Node β‰₯18.15 β€” absent on 18.0–18.14, feature-detect; `SYNTHESIS.md` T1 calls it the real OOM predictor) | Emit ONLY when the value is `> 0` AND `< 2^63` AND `≀ os.totalmem()`; omit for `0` (macOS/unconstrained) and the 2^64 Linux sentinel β€” never gate on `!== 0` alone. **Needs a Linux probe (macOS-only so far).** Deno/Bun semantics unmeasured. | -| `ram_system_available` | `process.availableMemory()` | Sentry uses it. **MEASURED: not comparable across runtimes (24 GB vs 0.9 GB same host) and absent on Node 18 β€” Node β‰₯22 only, if at all (Β§8).** | +| `ram_system_available` | `process.availableMemory()` | Sentry uses it. **MEASURED: not comparable across runtimes (24 GB vs 0.9 GB same host) and absent on Node 18 β€” Node β‰₯20.13 only (feature-detected, never a `major β‰₯ 22` check), if at all (Β§8).** | | `disk_mem_free`, `disk_mem_total` | `fs.statfs` (Node β‰₯18.15) | **Unit: integer MB via the existing `bytesToMegabytes` (`@bugsee/protocol` `units.ts:14-18`: `Math.floor(bytes/1024/1024)`, the Android `(int)` cast β€” binary, NOT `/1e6`; no second definition of "MB"), a deliberate exception to the bytes rule above, from `bsize Γ— bavail` / `bsize Γ— blocks`, change-deduped (β‰₯1 MB). **Path: the filesystem `dataDir` lives on** (`effectiveDataDir`), not `/` or cwd β€” it is that volume's space, not mobile "device storage", and differs from `/` on overlay/container roots. **Omit both traces when `effectiveDataDir` is undefined** (in-memory launch, or disk setup degraded β€” `node/src/launch.ts:580-609` clears it); never fall back to cwd or `/`. The sampler is built today with no `dataDir` dep (`launch.ts:866`), so ST3 must thread it in. Opt-in like iOS `BugseeOptionCaptureDiskSpace`. Β§8 confirmed `statfs` is present and returns sane block counts on node/bun/deno; it did NOT check wire units or path choice. | | `handles_active`, `requests_active` | `process.getActiveResourcesInfo()` | Elastic/OTel. Low priority; **MEASURED: always 0 on Bun/Deno β†’ Node only, enforced by `measuresActiveResources` (Β§5); omit, never emit 0.** | | `process_uptime_s` | `process.uptime()` | Trivial; Sentry/Datadog/OTel. | @@ -84,10 +84,10 @@ the existing `process_*` / `event_loop_*` style. Units: bytes unless the reused | Event-driven `battery`/`charging` | `levelchange`, `chargingchange` | May replace 1 s polling for these two. | | `connection` β€” **keep polling (or add events) but never drop the `onLine`-first mapper** | `window` `online`/`offline` **and** `connection.change`, mapped through `system-trace-values.ts` (`navigator.onLine` outranks `connection.type`) | `connection.change` does NOT fire on `offline` (a Chromium NetworkInformation can still say `wifi` while offline), so replacing the 1 s read with `change` alone leaves the trace stuck on `wifi` after the network drops while `events.system` `offline` fires β€” the streams disagree. Do not remove polling until the online/offline + change pair is specified and tested. | | `hardware_concurrency` (context, not trace) | `navigator.hardwareConcurrency` | Sentry; static β†’ belongs in environment, not a trace. | -| Main-thread responsiveness | `MessageChannel` / `scheduler.yield` round-trip delta (not `setTimeout(0)`, 4 ms clamp) | Name `main_thread_lag_ms`. **Measure only while `document.visibilityState === 'visible'`; omit (never zero) when hidden** β€” background tabs clamp/throttle timers and `MessageChannel` to ~1 s and then ~1 min, so a hidden-tab round-trip measures throttling and would paint a hang that did not happen (the inverse of the Deno `event_loop_lag` lie). Not comparable to iOS `queue.main`, which has no such artifact. Probe 5 must include hidden-tab lag vs a known sync block before ST4. **Mechanics:** the provider calls `sample()` synchronously (`system-traces-provider.ts:69-73`), so a round-trip cannot complete inside it. The ping is a STANDING source: armed in `start()` (post a message, record the delta on receipt), disconnected in `stop()`; `sample()` only reads the LAST COMPLETED delta and omits until one exists (and while hidden). No busy-wait, no fire-and-forget returning 0. | +| Main-thread responsiveness | `MessageChannel` / `scheduler.yield` round-trip delta (not `setTimeout(0)`, 4 ms clamp) | Name `main_thread_lag_ms`. **Measure only while `document.visibilityState === 'visible'`; omit (never zero) when hidden** β€” background tabs clamp/throttle timers and `MessageChannel` to ~1 s and then ~1 min, so a hidden-tab round-trip measures throttling and would paint a hang that did not happen (the inverse of the Deno `event_loop_lag` lie). Not comparable to iOS `queue.main`, which has no such artifact. Probe 5 must include hidden-tab lag vs a known sync block before ST4. **Visibility lifecycle (same rule as `fps`):** the ping loop must RE-ARM on each receipt (on receipt store the delta, post again) β€” a single launch-time ping freezes one number for the whole session. While `visibilityState !== 'visible'`: abort the in-flight ping, CLEAR `lastDelta`, do NOT re-arm. On `visibilitychange`β†’visible: arm again and omit until a ping that both started and completed in the visible window lands; stamp each completion with the visibility at receipt and discard hidden ones. Otherwise a throttled (~1 s / ~1 min) hidden completion becomes the first visible sample and records a hang that never happened. Probe 5 must include hide β†’ wait β†’ show β†’ first sample, not only sampling while hidden. **Mechanics:** the provider calls `sample()` synchronously (`system-traces-provider.ts:69-73`), so a round-trip cannot complete inside it. The ping is a STANDING source: armed in `start()` (post a message, record the delta on receipt), disconnected in `stop()`; `sample()` only reads the LAST COMPLETED delta and omits until one exists (and while hidden). No busy-wait, no fire-and-forget returning 0. | ### P3 β€” Electron main-process telemetry (`@bugsee/electron`) -Gated into the main-process sampler; `electron` is already an injected arg (no package dependency). +There is NO Electron sampler today: `launchMain` (`electron/src/launch-main.ts:110`) forwards `nodeOptions` to `@bugsee/node` `launchCore` with no `systemMetricsSampler`, so Electron main already emits the full node catalog. ST6 must therefore **WRAP, not replace**: at `launchMain`, wrap the node `LifecycleSampler` (the default, or a caller-supplied `systemMetricsSampler`, adapted with the same `typeof === 'function'` rule as ST0) and CONCATENATE the Electron samples. Copying the bun/deno pattern (`systemMetricsSampler: createElectronSystemMetricsSampler(app)`) would drop `process_memory_*`/`cpu_*`/`event_loop_*` from the process that owns the session. `electron`/`powerMonitor`/`screen`/`BrowserWindow` are injected into that wrapper (they are not in `LaunchMainOptions` today and must be added there), never into `@bugsee/node`, where `getAppMetrics` would throw on non-Electron Node. Tests assert node traces AND Electron traces are both present. | Item | Source | |---|---| | Per-process CPU+memory (`app_metrics`: type, pid, cpu %, workingSet) | `app.getAppMetrics()` | @@ -241,7 +241,7 @@ deno 2.8.3. Method: allocate+retain ~100 MB of arrays, churn garbage, read `v8.g `free = limit βˆ’ used` is meaningless, and the cost is ~1000Γ— node. Node and Deno only (real V8). Bun keeps `process_memory_heap_*` (which reads the same JSC data but is already shipped and honest about being "heap used/total"). -3. **`ram_system_available` (`availableMemory`) is not portable** β€” drop from ST1 for Bun/Deno; Node β‰₯22 only, +3. **`ram_system_available` (`availableMemory`) is not portable** β€” drop from ST1 for Bun/Deno; Node β‰₯20.13 only (feature-detected, never a `major β‰₯ 22` check), and name/define it as "Node's availableMemory", not Android's `ram_system_free`. 4. `fs.statfs` is sound on all three β†’ ST3 unblocked (still re-confirm through the SDK path). 5. `handles_active`/`requests_active` is near-useless on Bun/Deno (always 0) β†’ Node only, low priority. From 1cba75f4075e12773941d87645939b8846bdc20f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 12:04:57 +0500 Subject: [PATCH 08/14] =?UTF-8?q?docs(design):=20sixth=20review=20round=20?= =?UTF-8?q?=E2=80=94=20provider=20owns=20sampler=20start/stop,=20events=20?= =?UTF-8?q?on=20the=20events=20source,=20electron=20wrap=20forwards=20life?= =?UTF-8?q?cycle?= 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-expansion.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index 9266b0bc..21a15e05 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -79,15 +79,15 @@ the existing `process_*` / `event_loop_*` style. Units: bytes unless the reused | `app_state` (step trace) | `document.visibilityState` mapped to the Android vocabulary (`foreground`/`background`, per Android `TraceAppState`) | NEVER put raw `visible`/`hidden`/`prerender` on `traces.system`. Verify the exact Android value set and the viewer table (Q5) first; `process_foreground`/`process_background` events already exist, so this trace is optional until the viewer needs the step curve. | | `screen_size` (JS-specific; **not** `displays`) | `screen.width/height`, `devicePixelRatio` on `resize` | Android `displays` is a display LIST (id, density, flags, bounds), not two CSS-pixel numbers, so the browser `Screen` must not reuse the name. Static values already ship as `hardware.screen_width/screen_height/pixel_ratio` (`browser/src/environment.ts:116-118`); add this only if resize-over-time is wanted, with an explicit value shape `{width, height, dpr}` and Q5 viewer confirmation. Otherwise stay environment-only. | | `cpu_pressure` (**not** `thermal_state`) | Compute Pressure `PressureObserver.observe('cpu')` | Chromium-only, experimental, permissions-policy gated. It is CPU CONTENTION (incl. other tabs) with states `nominal/fair/serious/critical`, not Android's 0–6 temperature/throttling enum β€” so it gets a JS-specific name and the API's own state strings. Map to `thermal_state` only if `PressureObserver.knownSources` includes `thermals` AND the value is converted to Android's int enum. Feature-detect the SOURCE, not just the constructor. Off by default. | -| `dark_mode_changed` event | `matchMedia('(prefers-color-scheme: dark)')` change | Existing JS `events.system` names are snake_case; use `dark_mode_changed` unless Q5 shows the viewer keys Android's `DarkModeChanged`. | -| `freeze` / `resume` events | Page Lifecycle | Complements `process_background`. | +| `dark_mode_changed` event β€” on `createBrowserSystemEventsSource` (`system-events.ts`), gated by `captureSystemEvents`, torn down in `onDeactivate`; **never on the traces sampler** | `matchMedia('(prefers-color-scheme: dark)')` change | Existing JS `events.system` names are snake_case; use `dark_mode_changed` unless Q5 shows the viewer keys Android's `DarkModeChanged`. | +| `freeze` / `resume` events β€” same: `createBrowserSystemEventsSource`, `captureSystemEvents` | Page Lifecycle | Complements `process_background`. The traces provider can only write `traces.system` (`system-traces-provider.ts:69-73`); the two options are independent (`captureSystemTraces:false, captureSystemEvents:true` must still record these). | | Event-driven `battery`/`charging` | `levelchange`, `chargingchange` | May replace 1 s polling for these two. | | `connection` β€” **keep polling (or add events) but never drop the `onLine`-first mapper** | `window` `online`/`offline` **and** `connection.change`, mapped through `system-trace-values.ts` (`navigator.onLine` outranks `connection.type`) | `connection.change` does NOT fire on `offline` (a Chromium NetworkInformation can still say `wifi` while offline), so replacing the 1 s read with `change` alone leaves the trace stuck on `wifi` after the network drops while `events.system` `offline` fires β€” the streams disagree. Do not remove polling until the online/offline + change pair is specified and tested. | | `hardware_concurrency` (context, not trace) | `navigator.hardwareConcurrency` | Sentry; static β†’ belongs in environment, not a trace. | | Main-thread responsiveness | `MessageChannel` / `scheduler.yield` round-trip delta (not `setTimeout(0)`, 4 ms clamp) | Name `main_thread_lag_ms`. **Measure only while `document.visibilityState === 'visible'`; omit (never zero) when hidden** β€” background tabs clamp/throttle timers and `MessageChannel` to ~1 s and then ~1 min, so a hidden-tab round-trip measures throttling and would paint a hang that did not happen (the inverse of the Deno `event_loop_lag` lie). Not comparable to iOS `queue.main`, which has no such artifact. Probe 5 must include hidden-tab lag vs a known sync block before ST4. **Visibility lifecycle (same rule as `fps`):** the ping loop must RE-ARM on each receipt (on receipt store the delta, post again) β€” a single launch-time ping freezes one number for the whole session. While `visibilityState !== 'visible'`: abort the in-flight ping, CLEAR `lastDelta`, do NOT re-arm. On `visibilitychange`β†’visible: arm again and omit until a ping that both started and completed in the visible window lands; stamp each completion with the visibility at receipt and discard hidden ones. Otherwise a throttled (~1 s / ~1 min) hidden completion becomes the first visible sample and records a hang that never happened. Probe 5 must include hide β†’ wait β†’ show β†’ first sample, not only sampling while hidden. **Mechanics:** the provider calls `sample()` synchronously (`system-traces-provider.ts:69-73`), so a round-trip cannot complete inside it. The ping is a STANDING source: armed in `start()` (post a message, record the delta on receipt), disconnected in `stop()`; `sample()` only reads the LAST COMPLETED delta and omits until one exists (and while hidden). No busy-wait, no fire-and-forget returning 0. | ### P3 β€” Electron main-process telemetry (`@bugsee/electron`) -There is NO Electron sampler today: `launchMain` (`electron/src/launch-main.ts:110`) forwards `nodeOptions` to `@bugsee/node` `launchCore` with no `systemMetricsSampler`, so Electron main already emits the full node catalog. ST6 must therefore **WRAP, not replace**: at `launchMain`, wrap the node `LifecycleSampler` (the default, or a caller-supplied `systemMetricsSampler`, adapted with the same `typeof === 'function'` rule as ST0) and CONCATENATE the Electron samples. Copying the bun/deno pattern (`systemMetricsSampler: createElectronSystemMetricsSampler(app)`) would drop `process_memory_*`/`cpu_*`/`event_loop_*` from the process that owns the session. `electron`/`powerMonitor`/`screen`/`BrowserWindow` are injected into that wrapper (they are not in `LaunchMainOptions` today and must be added there), never into `@bugsee/node`, where `getAppMetrics` would throw on non-Electron Node. Tests assert node traces AND Electron traces are both present. +There is NO Electron sampler today: `launchMain` (`electron/src/launch-main.ts:110`) forwards `nodeOptions` to `@bugsee/node` `launchCore` with no `systemMetricsSampler`, so Electron main already emits the full node catalog. ST6 must therefore **WRAP, not replace**: at `launchMain`, wrap the node `LifecycleSampler` (the default, or a caller-supplied `systemMetricsSampler`, adapted with the same `typeof === 'function'` rule as ST0) and CONCATENATE the Electron samples. Copying the bun/deno pattern (`systemMetricsSampler: createElectronSystemMetricsSampler(app)`) would drop `process_memory_*`/`cpu_*`/`event_loop_*` from the process that owns the session. `electron`/`powerMonitor`/`screen`/`BrowserWindow` are injected into that wrapper (they are not in `LaunchMainOptions` today and must be added there), never into `@bugsee/node`, where `getAppMetrics` would throw on non-Electron Node. The wrapper is itself a `LifecycleSampler`: `start`/`stop` forward to BOTH the inner node sampler and the Electron listener attach/teardown (a sample-only wrap leaves Electron main's `event_loop_lag_*` at the idle floor β€” the bun no-op-wrap failure on the session owner). Electron `sample()` is try/catch-omit per reader (`getAppMetrics` too, not only `getGPUInfo`) so a throw cannot drop the already-read node samples or abort `#emit`. **Events cannot ride the traces wrap**: the table below mixes traces (`app_metrics`, `displays`) and events (`child_process_gone`, `power_*`, window focus/blur). A second `events.system` provider is rejected (`capture-coordinator.ts:53-55`, unique `provider.name`), and `launch.ts:870` always builds `createNodeSystemEventsSource(proc)` with no extra-source seam β€” so ST6 adds a node option (or an Electron-only extra source passed through) that wraps/muxes the node events source. `ElectronAppLike` is only `on('render-process-gone')` (`launch-main.ts:17-26`); `powerMonitor`/`screen`/`BrowserWindow` are added to `LaunchMainOptions` and destructured out of `...nodeOptions` as `app` already is (`launch-main.ts:76-77`). Tests: node + Electron traces both present; `getAppMetrics` throw β†’ node traces still emit; `child_process_gone` lands on `events.system`; the wrap's `start()` enables the histogram. | Item | Source | |---|---| | Per-process CPU+memory (`app_metrics`: type, pid, cpu %, workingSet) | `app.getAppMetrics()` | @@ -161,7 +161,7 @@ events. Reasons: no API, privacy, or Bugsee-internal state. (`system-traces-provider.ts:33`) built unconditionally at launch (`browser/src/launch.ts:798`, `node/src/launch.ts:866`), and the provider is not tied to an interceptor, so interceptor subscriber-presence does NOT apply to it; `stop()` only clears the 1 s timer. Standing sources (GC `PerformanceObserver`, rAF - counter, the `MessageChannel` ping, `PressureObserver`, battery/connection/`matchMedia` listeners) therefore MUST NOT be installed in the + counter, the `MessageChannel` ping, `PressureObserver`, battery/charging/connection listeners β€” NOT `matchMedia`/Page Lifecycle, which are events-source concerns) therefore MUST NOT be installed in the sampler factory β€” they would run with `captureSystemTraces:false` and survive `client.stop()`. Extend the sampler contract to `{ start(), stop(), sample() }` (or a dedicated provider/interceptor): attach in `start`, tear down in `stop`; **ST0 also migrates the EXISTING factory-time work**: `monitorEventLoopDelay().enable()` @@ -170,7 +170,7 @@ events. Reasons: no API, privacy, or Bugsee-internal state. `captureSystemTraces:false` / `client.stop()` still starts the histogram and fetches BatteryManager. There are THREE composition roots, not two: node (`launch.ts:866`), browser (`launch.ts:798`) and **webview** (`webview/src/launch.ts:671-675`, same `createSystemTracesProvider({ sample: … ?? createBrowserSystemTracesSampler() })`); - all three must call `start()`/`stop()` or webview fps stays 0 / its factory leaks. **The factories themselves change**: `createNodeSystemMetricsSampler`, `createGuardedSystemMetricsSampler` + all three must PASS the lifecycle sampler through to the provider β€” **the launch files do not call `start()`/`stop()` themselves**: registration at `addCaptureProvider` is unconditional (`capture-coordinator.ts:52-61`); the option gate is `startProvider` (`:40-44`), which skips `provider.start()` when `captureSystemTraces` is false. Calling `sampler.start()` at those launch lines would `enable()` the histogram / start rAF with the option OFF; only plumbing `sample: () => sampler.sample()` would leave `start()` never called (Bun/Deno lag at the idle floor). So `SystemTracesProviderOptions` (today `{sample, intervalMs, scheduler, now}`) changes to accept a `LifecycleSampler` (a plain function is wrapped sample-only), and the PROVIDER calls `sampler.start()` at the top of `onStart` (before the initial `#emit`) and `sampler.stop()` in `onStop`. Tests: option off β†’ no `enable()`/no rAF; option on β†’ `start()` then first sample; `client.stop()` β†’ released. **The factories themselves change**: `createNodeSystemMetricsSampler`, `createGuardedSystemMetricsSampler` (`guarded-system-metrics.ts:116-118`, today `() => TraceSample[]`) and `createBrowserSystemTracesSampler` all return `LifecycleSampler = { start, stop, sample }`. Bun/Deno do NOT take the `?? createNodeSystemMetricsSampler()` default β€” they INJECT `createGuardedSystemMetricsSampler(…)` through `options.systemMetricsSampler` @@ -262,7 +262,7 @@ Compute Pressure, battery events); sampler tick cost with P1 enabled through the | ST1 | Node/Deno: `v8_heap_*`, ceiling-based `ram_js_heap_*`, `process_memory_limit` (Linux probe first), `process_uptime_s`, optional `ram_system_available` (Node β‰₯20.13, feature-detected) | probes 2, 4 + Linux | | ST2 | Node: GC observer (`gc_*`) with per-runtime gate | probe 1 | | ST3 | Node: `disk_mem_*` (+ option) | probe 3 | -| ST4 | Browser: `app_state`, `fps`, `screen_size` (optional), `main_thread_lag_ms`, dark-mode/freeze/resume events | probe 5 | +| ST4 | Browser: `app_state`, `fps`, `screen_size` (optional), `main_thread_lag_ms`, (dark-mode/freeze/resume go to the EVENTS source, ST4b) | probe 5 | | ST5 | Browser: event-driven battery/charging; `connection` only with the online/offline + change pair and the `onLine`-first mapper kept | probe 5 | | ST6 | Electron: `app_metrics`, `child_process_gone`, power/screen/window events, GPU context | β€” | | ST7 | Serverless context: Lambda context fields, cloud-region audit | Q4 | From c3b46427a88b27093ed9ca073fe01895d6244b86 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 12:15:57 +0500 Subject: [PATCH 09/14] =?UTF-8?q?docs(design):=20seventh=20review=20round?= =?UTF-8?q?=20=E2=80=94=20freeze/resume=20on=20document,=20browser=20sampl?= =?UTF-8?q?er=20env,=20lazy=20events=20mux,=20GC=20observe=20gate?= 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-expansion.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index 21a15e05..8583c07c 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -80,14 +80,14 @@ the existing `process_*` / `event_loop_*` style. Units: bytes unless the reused | `screen_size` (JS-specific; **not** `displays`) | `screen.width/height`, `devicePixelRatio` on `resize` | Android `displays` is a display LIST (id, density, flags, bounds), not two CSS-pixel numbers, so the browser `Screen` must not reuse the name. Static values already ship as `hardware.screen_width/screen_height/pixel_ratio` (`browser/src/environment.ts:116-118`); add this only if resize-over-time is wanted, with an explicit value shape `{width, height, dpr}` and Q5 viewer confirmation. Otherwise stay environment-only. | | `cpu_pressure` (**not** `thermal_state`) | Compute Pressure `PressureObserver.observe('cpu')` | Chromium-only, experimental, permissions-policy gated. It is CPU CONTENTION (incl. other tabs) with states `nominal/fair/serious/critical`, not Android's 0–6 temperature/throttling enum β€” so it gets a JS-specific name and the API's own state strings. Map to `thermal_state` only if `PressureObserver.knownSources` includes `thermals` AND the value is converted to Android's int enum. Feature-detect the SOURCE, not just the constructor. Off by default. | | `dark_mode_changed` event β€” on `createBrowserSystemEventsSource` (`system-events.ts`), gated by `captureSystemEvents`, torn down in `onDeactivate`; **never on the traces sampler** | `matchMedia('(prefers-color-scheme: dark)')` change | Existing JS `events.system` names are snake_case; use `dark_mode_changed` unless Q5 shows the viewer keys Android's `DarkModeChanged`. | -| `freeze` / `resume` events β€” same: `createBrowserSystemEventsSource`, `captureSystemEvents` | Page Lifecycle | Complements `process_background`. The traces provider can only write `traces.system` (`system-traces-provider.ts:69-73`); the two options are independent (`captureSystemTraces:false, captureSystemEvents:true` must still record these). | +| `freeze` / `resume` events β€” same: `createBrowserSystemEventsSource`, `captureSystemEvents` | Page Lifecycle. **Attach on `this.#document`, like `visibilitychange` (`system-events.ts:68-74`) β€” they fire on Document and do NOT bubble, so a `window` listener beside `pagehide` never runs;** skip when `document` is absent, tear down in `onDeactivate`. Tests: `doc.emit('freeze')` records, `win.emit('freeze')` does not. | Complements `process_background`. The traces provider can only write `traces.system` (`system-traces-provider.ts:69-73`); the two options are independent (`captureSystemTraces:false, captureSystemEvents:true` must still record these). | | Event-driven `battery`/`charging` | `levelchange`, `chargingchange` | May replace 1 s polling for these two. | | `connection` β€” **keep polling (or add events) but never drop the `onLine`-first mapper** | `window` `online`/`offline` **and** `connection.change`, mapped through `system-trace-values.ts` (`navigator.onLine` outranks `connection.type`) | `connection.change` does NOT fire on `offline` (a Chromium NetworkInformation can still say `wifi` while offline), so replacing the 1 s read with `change` alone leaves the trace stuck on `wifi` after the network drops while `events.system` `offline` fires β€” the streams disagree. Do not remove polling until the online/offline + change pair is specified and tested. | | `hardware_concurrency` (context, not trace) | `navigator.hardwareConcurrency` | Sentry; static β†’ belongs in environment, not a trace. | | Main-thread responsiveness | `MessageChannel` / `scheduler.yield` round-trip delta (not `setTimeout(0)`, 4 ms clamp) | Name `main_thread_lag_ms`. **Measure only while `document.visibilityState === 'visible'`; omit (never zero) when hidden** β€” background tabs clamp/throttle timers and `MessageChannel` to ~1 s and then ~1 min, so a hidden-tab round-trip measures throttling and would paint a hang that did not happen (the inverse of the Deno `event_loop_lag` lie). Not comparable to iOS `queue.main`, which has no such artifact. Probe 5 must include hidden-tab lag vs a known sync block before ST4. **Visibility lifecycle (same rule as `fps`):** the ping loop must RE-ARM on each receipt (on receipt store the delta, post again) β€” a single launch-time ping freezes one number for the whole session. While `visibilityState !== 'visible'`: abort the in-flight ping, CLEAR `lastDelta`, do NOT re-arm. On `visibilitychange`β†’visible: arm again and omit until a ping that both started and completed in the visible window lands; stamp each completion with the visibility at receipt and discard hidden ones. Otherwise a throttled (~1 s / ~1 min) hidden completion becomes the first visible sample and records a hang that never happened. Probe 5 must include hide β†’ wait β†’ show β†’ first sample, not only sampling while hidden. **Mechanics:** the provider calls `sample()` synchronously (`system-traces-provider.ts:69-73`), so a round-trip cannot complete inside it. The ping is a STANDING source: armed in `start()` (post a message, record the delta on receipt), disconnected in `stop()`; `sample()` only reads the LAST COMPLETED delta and omits until one exists (and while hidden). No busy-wait, no fire-and-forget returning 0. | ### P3 β€” Electron main-process telemetry (`@bugsee/electron`) -There is NO Electron sampler today: `launchMain` (`electron/src/launch-main.ts:110`) forwards `nodeOptions` to `@bugsee/node` `launchCore` with no `systemMetricsSampler`, so Electron main already emits the full node catalog. ST6 must therefore **WRAP, not replace**: at `launchMain`, wrap the node `LifecycleSampler` (the default, or a caller-supplied `systemMetricsSampler`, adapted with the same `typeof === 'function'` rule as ST0) and CONCATENATE the Electron samples. Copying the bun/deno pattern (`systemMetricsSampler: createElectronSystemMetricsSampler(app)`) would drop `process_memory_*`/`cpu_*`/`event_loop_*` from the process that owns the session. `electron`/`powerMonitor`/`screen`/`BrowserWindow` are injected into that wrapper (they are not in `LaunchMainOptions` today and must be added there), never into `@bugsee/node`, where `getAppMetrics` would throw on non-Electron Node. The wrapper is itself a `LifecycleSampler`: `start`/`stop` forward to BOTH the inner node sampler and the Electron listener attach/teardown (a sample-only wrap leaves Electron main's `event_loop_lag_*` at the idle floor β€” the bun no-op-wrap failure on the session owner). Electron `sample()` is try/catch-omit per reader (`getAppMetrics` too, not only `getGPUInfo`) so a throw cannot drop the already-read node samples or abort `#emit`. **Events cannot ride the traces wrap**: the table below mixes traces (`app_metrics`, `displays`) and events (`child_process_gone`, `power_*`, window focus/blur). A second `events.system` provider is rejected (`capture-coordinator.ts:53-55`, unique `provider.name`), and `launch.ts:870` always builds `createNodeSystemEventsSource(proc)` with no extra-source seam β€” so ST6 adds a node option (or an Electron-only extra source passed through) that wraps/muxes the node events source. `ElectronAppLike` is only `on('render-process-gone')` (`launch-main.ts:17-26`); `powerMonitor`/`screen`/`BrowserWindow` are added to `LaunchMainOptions` and destructured out of `...nodeOptions` as `app` already is (`launch-main.ts:76-77`). Tests: node + Electron traces both present; `getAppMetrics` throw β†’ node traces still emit; `child_process_gone` lands on `events.system`; the wrap's `start()` enables the histogram. +There is NO Electron sampler today: `launchMain` (`electron/src/launch-main.ts:110`) forwards `nodeOptions` to `@bugsee/node` `launchCore` with no `systemMetricsSampler`, so Electron main already emits the full node catalog. ST6 must therefore **WRAP, not replace**: at `launchMain`, wrap the node `LifecycleSampler` (the default, or a caller-supplied `systemMetricsSampler`, adapted with the same `typeof === 'function'` rule as ST0) and CONCATENATE the Electron samples. Copying the bun/deno pattern (`systemMetricsSampler: createElectronSystemMetricsSampler(app)`) would drop `process_memory_*`/`cpu_*`/`event_loop_*` from the process that owns the session. `electron`/`powerMonitor`/`screen`/`BrowserWindow` are injected into that wrapper (they are not in `LaunchMainOptions` today and must be added there), never into `@bugsee/node`, where `getAppMetrics` would throw on non-Electron Node. The wrapper is itself a `LifecycleSampler`: `start`/`stop` forward to BOTH the inner node sampler and the Electron listener attach/teardown (a sample-only wrap leaves Electron main's `event_loop_lag_*` at the idle floor β€” the bun no-op-wrap failure on the session owner). Electron `sample()` is try/catch-omit per reader (`getAppMetrics` too, not only `getGPUInfo`) so a throw cannot drop the already-read node samples or abort `#emit`. **Events cannot ride the traces wrap**: the table below mixes traces (`app_metrics`, `displays`) and events (`child_process_gone`, `power_*`, window focus/blur). A second `events.system` provider is rejected (`capture-coordinator.ts:53-55`, unique `provider.name`), and `launch.ts:870` always builds `createNodeSystemEventsSource(proc)` with no extra-source seam β€” so ST6 adds a node option (or an Electron-only extra source passed through) that wraps/muxes the node events source. **The mux attaches lazily**: it is an `EventSubscribable`/`InterceptorBase` whose `on()`/`onActivate` attaches BOTH the inner `.on('event')` AND the Electron `app`/`powerMonitor`/`screen`/`BrowserWindow` listeners, and `onDeactivate` removes them. Subscribing to `inner` or Electron in the wrap factory (at `launchMain`) would, with `captureSystemEvents:false`, still activate the node source's SIGTERM/SIGINT/`exit`/`beforeExit`/`warning` handlers (`system-events.ts:75-82`; they suppress default termination until `releaseSignalToDefault`) and keep `child-process-gone`/power listeners with nowhere to go, surviving `client.stop()`. The mux must not emit a second `process_started` (inner already does). Tests: option off β†’ no SIGTERM handler, no `child-process-gone` listener; option on β†’ both; `client.stop()` β†’ released. `ElectronAppLike` is only `on('render-process-gone')` (`launch-main.ts:17-26`); `powerMonitor`/`screen`/`BrowserWindow` are added to `LaunchMainOptions` and destructured out of `...nodeOptions` as `app` already is (`launch-main.ts:76-77`). Tests: node + Electron traces both present; `getAppMetrics` throw β†’ node traces still emit; `child_process_gone` lands on `events.system`; the wrap's `start()` enables the histogram. | Item | Source | |---|---| | Per-process CPU+memory (`app_metrics`: type, pid, cpu %, workingSet) | `app.getAppMetrics()` | @@ -127,15 +127,16 @@ events. Reasons: no API, privacy, or Bugsee-internal state. `nodeLaunchCore` reads; flags set there never reach the sampler and are silently ignored. Bun today calls `createGuardedSystemMetricsSampler()` with NO args (`bun/src/launch.ts:22`). New deps (default ON for Node): `measuresGc`, `measuresV8Heap`, `measuresAvailableMemory`, - `measuresConstrainedMemory`, `measuresActiveResources`. Each `false` must INJECT AN OMITTED READER (the way + `measuresConstrainedMemory`, `measuresActiveResources`. For `sample()` readers, each `false` must INJECT AN OMITTED READER (the way `measuresEventLoopDelay: false` injects `eventLoop: unavailableLoop`) or a `NodeSystemMetricsDeps` field the node - sampler branches on β€” a boolean that is merely destructured does nothing. Values: `@bugsee/bun` all five + sampler branches on β€” a boolean that is merely destructured does nothing. **Standing sources are different:** `measuresGc: false` must make `start()` NOT call `PerformanceObserver.observe` at all (a no-op GC lifecycle, analogous to never constructing the histogram) β€” omitting `gc_*` from `sample()` while still forwarding `inner.start()` leaves an observer registered for the process lifetime on every Bun/Deno session. Every attach in `start()` is per-attach try/catch-omit, so a throwing `observe({entryTypes:['gc']})` cannot abort `captureCoordinator.start()` (and block `process_started`) β€” the same isolation required of `sample()`. Tests: bun-shaped fake β†’ no `observe` call; `observe` throws β†’ launch succeeds, `process_started` still emits, `gc_*` absent. Values: `@bugsee/bun` all five `false`; `@bugsee/deno` `measuresGc`/`measuresAvailableMemory`/`measuresConstrainedMemory`/ `measuresActiveResources` `false` (blind, incomparable, unprobed on Linux, always 0), `measuresV8Heap: true` (measured real). Bun's `constrainedMemory` of 68.7 GB passes any numeric sanity filter, so the sentinel check is not a gate. Tests: pin the factory call per runtime (`toHaveBeenCalledWith(expect.objectContaining({ measuresV8Heap: false, … }))`, as lag is pinned in `deno/src/launch.test.ts`), AND run the sampler under a Bun/Deno-shaped fake asserting the traces are ABSENT. +- **Browser sampler env.** The visibility-gated standing sources (`fps` rAF, `app_state`, the lag ping) need `document.visibilityState` and `requestAnimationFrame`, but `BrowserTracesEnv` has only `performance`/`navigator`/`screen` (`browser/src/system-metrics.ts:44-58`) and both roots build the sampler with NO env (`browser/launch.ts:798`, `webview/launch.ts:671-675`) while events get `{window: win}` / `{window: win, document: domDocument}`. Extend `BrowserTracesEnv` with `window`/`document` (or rAF + a visibility reader) and pass the SAME `win`/`domDocument` browser and webview launch already give the events source β€” never read `globalThis.document` while the rest of the SDK is bound to `options.window`, or an injected hidden document drives `process_background` while traces keep a vsync loop on the parent. Probe 5 hideβ†’waitβ†’show must run against launch-injected window/document. - **Presence vs honesty are different gates.** The `measures*` flags above answer "does this runtime LIE" (Bun/Deno: API exists, number is wrong). They do not answer "does this API EXIST on the declared floor" (`@bugsee/node` `engines: >=18`): `process.availableMemory()` is absent on Node 18 (Β§8) and needs β‰₯20.13/22; From 4ad2ae9d56eaedd05cd475c1ae9452d3a432e6cd Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 12:29:01 +0500 Subject: [PATCH 10/14] =?UTF-8?q?docs(design):=20eighth=20review=20round?= =?UTF-8?q?=20=E2=80=94=20document=20pair,=20matchMedia=20env,=20async=20g?= =?UTF-8?q?etGPUInfo,=20window-created=20hooks?= 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-expansion.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index 8583c07c..9941d17a 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -79,7 +79,7 @@ the existing `process_*` / `event_loop_*` style. Units: bytes unless the reused | `app_state` (step trace) | `document.visibilityState` mapped to the Android vocabulary (`foreground`/`background`, per Android `TraceAppState`) | NEVER put raw `visible`/`hidden`/`prerender` on `traces.system`. Verify the exact Android value set and the viewer table (Q5) first; `process_foreground`/`process_background` events already exist, so this trace is optional until the viewer needs the step curve. | | `screen_size` (JS-specific; **not** `displays`) | `screen.width/height`, `devicePixelRatio` on `resize` | Android `displays` is a display LIST (id, density, flags, bounds), not two CSS-pixel numbers, so the browser `Screen` must not reuse the name. Static values already ship as `hardware.screen_width/screen_height/pixel_ratio` (`browser/src/environment.ts:116-118`); add this only if resize-over-time is wanted, with an explicit value shape `{width, height, dpr}` and Q5 viewer confirmation. Otherwise stay environment-only. | | `cpu_pressure` (**not** `thermal_state`) | Compute Pressure `PressureObserver.observe('cpu')` | Chromium-only, experimental, permissions-policy gated. It is CPU CONTENTION (incl. other tabs) with states `nominal/fair/serious/critical`, not Android's 0–6 temperature/throttling enum β€” so it gets a JS-specific name and the API's own state strings. Map to `thermal_state` only if `PressureObserver.knownSources` includes `thermals` AND the value is converted to Android's int enum. Feature-detect the SOURCE, not just the constructor. Off by default. | -| `dark_mode_changed` event β€” on `createBrowserSystemEventsSource` (`system-events.ts`), gated by `captureSystemEvents`, torn down in `onDeactivate`; **never on the traces sampler** | `matchMedia('(prefers-color-scheme: dark)')` change | Existing JS `events.system` names are snake_case; use `dark_mode_changed` unless Q5 shows the viewer keys Android's `DarkModeChanged`. | +| `dark_mode_changed` event β€” **`matchMedia` is a method on `Window` returning a `MediaQueryList` you listen on, not a window event, and it is not on `WindowEvents` (`detection-providers.ts:21-24`, shared with detection β€” do NOT widen it): extend `BrowserSystemEventsEnv` with `matchMedia`; in `onActivate` skip when absent, listen on the returned list, remove in `onDeactivate`, per-attach try/catch (an unguarded throw aborts `captureCoordinator.start()` and with it input/viewtree, which register after events). Tests: fake without `matchMedia` β†’ input still starts, no event; with it β†’ `change` records `dark_mode_changed`.** On `createBrowserSystemEventsSource` (`system-events.ts`), gated by `captureSystemEvents`, torn down in `onDeactivate`; **never on the traces sampler** | `matchMedia('(prefers-color-scheme: dark)')` change | Existing JS `events.system` names are snake_case; use `dark_mode_changed` unless Q5 shows the viewer keys Android's `DarkModeChanged`. | | `freeze` / `resume` events β€” same: `createBrowserSystemEventsSource`, `captureSystemEvents` | Page Lifecycle. **Attach on `this.#document`, like `visibilitychange` (`system-events.ts:68-74`) β€” they fire on Document and do NOT bubble, so a `window` listener beside `pagehide` never runs;** skip when `document` is absent, tear down in `onDeactivate`. Tests: `doc.emit('freeze')` records, `win.emit('freeze')` does not. | Complements `process_background`. The traces provider can only write `traces.system` (`system-traces-provider.ts:69-73`); the two options are independent (`captureSystemTraces:false, captureSystemEvents:true` must still record these). | | Event-driven `battery`/`charging` | `levelchange`, `chargingchange` | May replace 1 s polling for these two. | | `connection` β€” **keep polling (or add events) but never drop the `onLine`-first mapper** | `window` `online`/`offline` **and** `connection.change`, mapped through `system-trace-values.ts` (`navigator.onLine` outranks `connection.type`) | `connection.change` does NOT fire on `offline` (a Chromium NetworkInformation can still say `wifi` while offline), so replacing the 1 s read with `change` alone leaves the trace stuck on `wifi` after the network drops while `events.system` `offline` fires β€” the streams disagree. Do not remove polling until the online/offline + change pair is specified and tested. | @@ -94,8 +94,8 @@ There is NO Electron sampler today: `launchMain` (`electron/src/launch-main.ts:1 | `child_process_gone` event (type, reason, exitCode) | `app.on('child-process-gone')` β€” beyond the existing `render-process-gone` incident path | | `power_suspend/resume`, `power_lock/unlock`, `power_ac/battery` events | `powerMonitor` | | `displays` as an Android-shaped display LIST (id, size, scale, state), + add/remove/metrics-change events | `screen.getAllDisplays()` β€” use the name `displays` ONLY once that list shape is specified/confirmed against the viewer (Q5); else a JS-specific name | -| GPU info (context) | `app.getGPUInfo('basic')` β€” wrap in try/catch (throws on Linux without acceleration, per Sentry) | -| Window focus/blur/show/hide/unresponsive events | `BrowserWindow` | +| GPU info (context, NOT a `sample()` reader) | `app.getGPUInfo('basic')` returns a **Promise** β€” try/catch catches nothing, and `#emit` iterates `sample()` synchronously. Kick it once from `start()`, cache the resolved object, `sample()`/context reads the cache and omits until settled; attach `.catch(() => omit)` (a Linux-without-acceleration rejection is an `unhandledRejection` on the session owner, which Bugsee's own detection would report). Tests: rejected `getGPUInfo` β†’ launch succeeds, no `unhandledrejection`, node traces still emit, no GPU entry | +| Window focus/blur/show/hide events; `unresponsive`/`responsive` | `BrowserWindow` for focus/blur/show/hide; **`unresponsive` fires on `webContents`, not `BrowserWindow`** (`win.on('unresponsive')` never runs). In the mux `onActivate` attach to `BrowserWindow.getAllWindows()` AND `app.on('browser-window-created')` (apps typically call `launchMain` then `createWindow()` from `whenReady()`, so the list is empty at activation) and `webContents` via `app.on('web-contents-created')`; remove all in `onDeactivate`. Expand `ElectronAppLike` beyond `render-process-gone`. Tests: a window created AFTER `launchMain` still records focus; `win.emit('unresponsive')` does not record, `webContents.emit('unresponsive')` does | ### P4 β€” Serverless / edge context | Item | Source | @@ -136,7 +136,7 @@ events. Reasons: no API, privacy, or Bugsee-internal state. is not a gate. Tests: pin the factory call per runtime (`toHaveBeenCalledWith(expect.objectContaining({ measuresV8Heap: false, … }))`, as lag is pinned in `deno/src/launch.test.ts`), AND run the sampler under a Bun/Deno-shaped fake asserting the traces are ABSENT. -- **Browser sampler env.** The visibility-gated standing sources (`fps` rAF, `app_state`, the lag ping) need `document.visibilityState` and `requestAnimationFrame`, but `BrowserTracesEnv` has only `performance`/`navigator`/`screen` (`browser/src/system-metrics.ts:44-58`) and both roots build the sampler with NO env (`browser/launch.ts:798`, `webview/launch.ts:671-675`) while events get `{window: win}` / `{window: win, document: domDocument}`. Extend `BrowserTracesEnv` with `window`/`document` (or rAF + a visibility reader) and pass the SAME `win`/`domDocument` browser and webview launch already give the events source β€” never read `globalThis.document` while the rest of the SDK is bound to `options.window`, or an injected hidden document drives `process_background` while traces keep a vsync loop on the parent. Probe 5 hideβ†’waitβ†’show must run against launch-injected window/document. +- **Browser sampler env.** The visibility-gated standing sources (`fps` rAF, `app_state`, the lag ping) need `document.visibilityState` and `requestAnimationFrame`, but `BrowserTracesEnv` has only `performance`/`navigator`/`screen` (`browser/src/system-metrics.ts:44-58`) and both roots build the sampler with NO env (`browser/launch.ts:798`, `webview/launch.ts:671-675`) while events get `{window: win}` / `{window: win, document: domDocument}`. Extend `BrowserTracesEnv` with `window`/`document` (or rAF + a visibility reader) and pass ONE `{window: win, document: domDocument}` pair to BOTH the events source and the traces sampler. Today only webview does that (`webview/launch.ts:680-684`); browser `launch.ts:803` passes `{ window: win }` only β€” `domDocument` (`options.document ?? win.document`, resolved at `:660-661`) goes to input/viewtree/replay/page-hide flush, and the events factory then defaults a missing `document` to the GLOBAL one (`system-events.ts:89-92`). Fix browser to `{ window: win, ...(domDocument !== undefined ? { document: domDocument } : {}) }` (the webview pattern) and do not copy today's `{window: win}` as the template; `freeze`/`resume` and `visibilitychange` then listen on the same document as input β€” never read `globalThis.document` while the rest of the SDK is bound to `options.window`, or an injected hidden document drives `process_background` while traces keep a vsync loop on the parent. Probe 5 hideβ†’waitβ†’show must run against launch-injected window/document. - **Presence vs honesty are different gates.** The `measures*` flags above answer "does this runtime LIE" (Bun/Deno: API exists, number is wrong). They do not answer "does this API EXIST on the declared floor" (`@bugsee/node` `engines: >=18`): `process.availableMemory()` is absent on Node 18 (Β§8) and needs β‰₯20.13/22; From 5163ce47e97e828daa2c00b2f5de8a7e5d80f5a6 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 12:56:48 +0500 Subject: [PATCH 11/14] docs(design): freeze system traces expansion; track review-round-9 findings as follow-ups 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-expansion.md | 43 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index 9941d17a..a0abab8c 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -1,6 +1,7 @@ -# System traces expansion β€” competitor gap + mobile-parity port plan β€” DESIGNED (not built) +# System traces expansion β€” competitor gap + mobile-parity port plan β€” DESIGNED, FROZEN (not built) -**Status:** DESIGNED 2026-10-14. Nothing here is implemented. Built from five read-only research passes +**Status:** DESIGNED 2026-10-14; **FROZEN 2026-10-14 after review round 9** (PR #53). No further prose rewrites: remaining +edge cases are tracked as the pre-implementation checklist in Β§12 and are resolved test-first in the owning slice. Nothing here is implemented. Built from five read-only research passes (competitor web, competitor server/cloud/edge, competitor Electron/desktop, Bugsee Android SDK, Bugsee iOS SDK). Every runtime-support claim marked **UNMEASURED** must be probed on the real runtime before the slice that depends on it starts (see Β§7) β€” on Bun/Deno "the call succeeded" is not evidence the thing happened @@ -296,3 +297,41 @@ Compute Pressure, battery events); sampler tick cost with P1 enabled through the - Mobile: Android/iOS read from source (no code run). iOS spec `report-bundle-structure/platforms/ios.md:192` says its producer inventory is "still to be audited", so the iOS source is the only authority. - Not researched: Tauri/NW.js; Raygun/Rollbar/LogRocket Electron. + +## 12. Follow-ups from review round 9 (pre-implementation checklist β€” resolve in the owning slice) + +Raised by the automated review on `4ad2ae9`; each was judged plausible but is NOT yet verified against the code +and NOT yet reflected in Β§4–§9. Each slice's author confirms the claim against the real files first, then adds the +tests named here. These supersede the corresponding prose above where they conflict. + +1. **ST4 β€” complete browser traces env (`BrowserTracesEnv`).** The `{window, document}` pair given to the events + source is an events env, not a traces env. Build a COMPLETE traces env from the same injected `win`/`domDocument`: + `performance`/`navigator`/`screen`/`requestAnimationFrame` from that window, `document` from `domDocument`; pass + only `{window, document}` to the events source. Do not spread `realEnv()` over an injected document. Tests: + injected window+document still emit `ram_js_heap_*`/`connection` from THAT window; a missing + `performance.memory` on it omits heap (no fallback to `globalThis`). +2. **ST4b β€” `matchMedia` from the launch-injected window.** Browser and webview launch pass + `...(typeof win.matchMedia === 'function' ? { matchMedia: win.matchMedia.bind(win) } : {})` alongside + `{window: win, document: domDocument}`; never default to `globalThis.matchMedia`. Tests: injected window whose + `matchMedia` fires `change` records `dark_mode_changed`; a `WindowEvents` fake with no `matchMedia` still starts + input and emits no dark-mode event. +3. **ST6 β€” GPU info destination.** `getGPUInfo` has no destination: there is no context stream and the traces + provider writes only `traces.system`. Choose and name ONE: (a) wrap Electron `getEnvironment`/the node probe and + set `hardware.gpu` (or a JS-specific key) once settled, omitted until then; or (b) a one-shot, change-deduped + `traces.system` name with an explicit value shape, Q5-confirmed. `start()` may kick the Promise but must not be + the writer. Tests: rejected `getGPUInfo` β†’ no GPU field and node traces still emit; resolved β†’ the chosen field + is present on the next report (not a per-tick trace unless (b)). +4. **ST6 β€” existing `webContents` and window lifetime.** `web-contents-created` does not replay. In `onActivate`, + for each current `getAllWindows()` attach the window events AND `win.webContents` `unresponsive`/`responsive`, + plus `browser-window-created`/`web-contents-created` for later ones. Remove all in `onDeactivate`, and on + `closed`/`destroyed` so a closed window is not retained until stop. Tests: a window created BEFORE `launchMain` + still records `unresponsive` from `webContents`; `win.emit('unresponsive'|'responsive')` does not record. +5. **ST0/ST5/ST6 β€” abort in-flight Promises on `stop()`.** `getBattery()` (moved into `start()`) and `getGPUInfo` + return Promises that `stop()` does not cancel. Use a generation/`aborted` flag in `start()`; `stop()` flips it and + tears down whatever is already cached; the `then` is a no-op when aborted (no cache, no `levelchange` listener). + Tests: `start()` then `stop()` before the Promise settles β†’ no listener attached, no retained manager, a later + fulfill is ignored. Same token for `getGPUInfo`'s late resolve. + +**Process note.** Nine automated review rounds each confirmed the previous fixes and found the next layer of +attach-site / call-shape detail. That class is better caught against real files under the repo's own Β§7 convergent +review per slice than by further doc rewrites, which is why the doc is frozen here. From 3ec51e74304f82beddeb4c7594bb20e486a96a72 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 13:48:17 +0500 Subject: [PATCH 12/14] =?UTF-8?q?docs(design):=20record=20Q5=20=E2=80=94?= =?UTF-8?q?=20viewer=20+=20specs=20findings,=20charging=20fix,=20orientati?= =?UTF-8?q?on=20correction?= 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-expansion.md | 70 +++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index a0abab8c..00a87042 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -282,8 +282,7 @@ Compute Pressure, battery events); sampler tick cost with P1 enabled through the - **Q3.** Omit vs `"$$unavailable"` for unsupported services. Recommendation: keep omitting (consistent with the ELU/Deno precedent) unless the viewer needs the sentinel β€” verify viewer handling first. - **Q4.** Does the env builder already detect cloud provider/region? **Checked in review: NO** β€” node/browser env builders do not read `AWS_REGION`/`VERCEL`/`FLY_REGION`; `hardware.cpu_count` and `hardware.screen_*`/`pixel_ratio` already exist, so do not re-emit those as traces. -- **Q5.** Which new trace names does the viewer already render? (Viewer tables not inspected.) Any name the - viewer lacks needs a viewer PR (precedent: worker PR 46 / viewer PR 14). +- **Q5.** ANSWERED β€” see Β§13. - **Q6.** `disk_mem_*` privacy/opt-in: mirror iOS's option-gated capture? Recommendation: opt-in. ## 11. Evidence base and its limits @@ -335,3 +334,70 @@ tests named here. These supersede the corresponding prose above where they confl **Process note.** Nine automated review rounds each confirmed the previous fixes and found the next layer of attach-site / call-shape detail. That class is better caught against real files under the repo's own Β§7 convergent review per slice than by further doc rewrites, which is why the doc is frozen here. + +## 13. Q5 answered β€” what the viewer and the specs repo say (2026-10-14) + +Sources: `bugsee/viewer` at `origin/main` `7b65465` (read via git, working copy untouched) and `bugsee/specs` +`6df4b85`. Verified by reading code, not by rendering a report. + +### 13.1 The specs repo is thin on system traces +`sdk/reporting/bundle/traces.md` fixes the envelope (`timestamp`, optional `displayId`, `name`, `value`) and value +schemas for only FOUR traces: `capture`, `cellular`, `fold_state`, `hinge_angle`. Everything else is "scalar, +producer-specific". `platforms/android.md#system-trace-producers` lists Android PRODUCER ids (`connectivity`, +`power_status`, `ram`, `cpu_usage`, `top_activity`, …), not the wire names the producers emit. `platforms/ios.md` +specifies `cellular`, `capture` and `ram_low_memory` only and says the iOS producer inventory is "still pending". +`events-system.md` says only "a platform-reported identifier"; Android names are in `android.md#system-event-names`. +**Consequence: the viewer's tables are the de-facto contract for every other trace name and value shape.** If a new +JS trace/value shape is meant to be cross-platform, it should be added to `traces.md` in the specs repo in the same +change as the viewer entry (the specs README says implementations are checked against the spec). + +### 13.2 How the viewer processes system traces +- Named definitions in `recording-system-traces.constant.ts` give display (`blocks`/`lines`/`areas`), `units`, + `precision`, `measure: dataSize`, `series`/`childs` grouping and `states` tables; titles live in + `recording-traces-titles.constant.ts`; a fixed list `SYSTEM_TRACES_SPECIAL_TREATMENT` routes some names to + processors (`battery`, `charging`, `orientation`, `displays`, `connection`, `wifi`, `cellular`, `capture`, + `fold_state`, `hinge_angle`, `thermal_headroom`, `screen_brightness`, `view`, …). +- A trace with NO definition falls into "process other traces" (`recording-helper.service.ts:245-258`): generic + `blocks`, title = titles-table entry or the raw name, no units, no chart. Numeric series (GC, heap, lag) therefore + appear as value blocks, not lines/areas, until a definition exists. Equal consecutive values are collapsed. +- System EVENTS are fully generic: grouped by `name`, one `ticks` row per name titled with the raw name, filtered to + the recording window. There is NO event-name table, so any name renders; snake_case vs PascalCase only matters for + cross-platform grouping with Android's names, not for rendering. + +### 13.3 Per-name verdicts for this doc's proposals +| Name / shape | Viewer today | Needed | +|---|---|---| +| `ram_js_heap_{total,free,used}` | Defined; viewer comment: same semantics as `ram_jvm_heap`, `total` = heap CEILING | Nothing β€” confirms the ceiling-based Node/Deno design (Q2) | +| `process_memory_{rss,heap_*,external,array_buffers}`, `cpu_usage_{user,system}`, `event_loop_lag*`, `event_loop_utilization` | Defined (`process_memory_js`, `cpu_usage_js`, `event_loop_lag`) | Nothing | +| `disk_mem_{free,total}` | `disk` group, MB `areas` | Nothing β€” confirms integer MB (Β§4 P1) | +| `app_state` | `states`: `started` / `foreground` / `background` (strings) | Emit exactly those strings; never `visible`/`hidden` | +| `displays` | `parseDisplays` expects a LIST of display objects (id, name, width/height or natural size, rotation, …) | `screen_size` stays a separate name; Electron `getAllDisplays()` may use `displays` only in that list shape | +| `thermal_state` | integer enum table (0 Nominal, 1 Fair, …) | `cpu_pressure` must be a separate name | +| `connection` | `processConnection` handles the browser shape, `online`-first, `effectiveType` as label only | Nothing for the current shape; ST5 must keep it | +| `orientation` | `RecordingOrientationsMap` ints 0–4 | Nothing | +| `rendering_fps` | groups `fps` + `fps_max` | `fps` renders with no new entry; `fps_max` has no browser source | +| `v8_heap_*`, `gc_*`, `process_memory_limit`, `process_uptime_s`, `handles_active`, `requests_active`, `main_thread_lag_ms`, `screen_size`, `cpu_pressure`, `app_metrics` | Undefined β†’ generic value blocks | Viewer PR: definitions + titles (+ spec entry in `traces.md` where cross-platform) | +| `dark_mode_changed`, `freeze`, `resume`, `child_process_gone`, `power_*`, window events | Generic ticks rows | Nothing to render; keep names stable and, where Android has one (`DarkModeChanged`), decide on cross-platform grouping | +| `charging` | **see 13.4** | **change the SDK** | + +### 13.4 `charging` is broken today, and the fix is supported +The viewer's `charging` table is keyed by integer states `"0"` unknown, `"1"` unplugged, `"2"` charging, `"3"` full, +and `processCharging` accepts an integer OR an `{state, …}` object (`processingHelpers.ts:364`). The shipped JS value is +a boolean: `false` is dropped by the falsy guard before the processor runs, and `true` looks up `states["true"]` +(undefined), so the row has no state. **Supersedes Β§3.1 and the ST0 default**: emit `{state: 0|1|2|3}` with `source` +OMITTED (never invented), map from `BatteryManager` (`charging` β†’ 2, `!charging` β†’ 1; "full" needs a rule, e.g. +`charging && level === 1` β†’ 3; unknown/unsupported β†’ omit). This is a fix, not a breaking change β€” nothing rendered +before. Q5 for `charging` is closed; the remaining question is only the "full" rule. + +### 13.5 Correction to Β§3 +Clash #4 ("`orientation` ints 3/4 mean opposite things on iOS vs Android") is NOT supported by the viewer's own note: +`recording-orientations-map.constant.ts` says 3/4 "follow the SDK contract, which both platforms state identically" +(Android `Orientation.LandscapeLeft(3)`, iOS `BGSOrientationLandscapeLeft = 3`, `BGSContracts.h`), and that an earlier +swap was a LABEL bug, not a wire difference. The iOS research pass's claim was wrong; no JS action on `orientation`. + +### 13.6 Follow-ups +1. Viewer PR: definitions + titles for the new names in Β§13.3 (group `v8_heap_*`, a `gc` group, `lines` for lag/uptime). +2. Specs PR: add any cross-platform JS trace/value shape (`charging` object, `app_state` strings already implied) to + `bundle/traces.md`; add the JS runtime names to a platform note. +3. Not yet checked: viewer `hints` table (`RecordingTracesHintsConstant`) and the session-level `hardware.*` + fields the viewer reads from `request.json` (so `hardware.gpu`/`cpu_count` placement for ST6 GPU info is open). From 8cfccca91667d3de97f2bc83faec82734ffa63c2 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 14:30:51 +0500 Subject: [PATCH 13/14] docs(design): record Q2/Q3 decisions and GPU destination (environment.hardware.gpu) 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-expansion.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index 00a87042..31e07f01 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -275,11 +275,11 @@ Compute Pressure, battery events); sampler tick cost with P1 enabled through the - **Q1.** Do long tasks / LoAF / web vitals / navigation+resource timing belong in `@bugsee/performance` (already owns `longtask` + web-vitals; it does NOT own `long-animation-frame`, which is research-only in `docs/research/capture-apis/browser.md` β€” LoAF is the open item) rather than `traces.system`? Recommendation: yes β€” keep them out of this doc's scope and confirm `@bugsee/performance` covers long tasks/LoAF; check for overlap before any build. -- **Q2.** Should Node/Deno emit `ram_js_heap_*` *in addition to* `process_memory_heap_*`? Recommendation: yes, +- **Q2.** **DECIDED YES (owner, 2026-10-14).** Should Node/Deno emit `ram_js_heap_*` *in addition to* `process_memory_heap_*`? Recommendation: yes, as a DIFFERENT quantity (ceiling-based total/free, per Β§4 P1), not a duplicate; only `used` repeats. Subsumes `SYNTHESIS.md` T1 (`heap_size_limit` / `constrainedMemory`), which already proposed this β€” this doc must reconcile with it, not restate it. -- **Q3.** Omit vs `"$$unavailable"` for unsupported services. Recommendation: keep omitting (consistent with +- **Q3.** **DECIDED: keep omitting (owner, 2026-10-14).** Omit vs `"$$unavailable"` for unsupported services. Recommendation: keep omitting (consistent with the ELU/Deno precedent) unless the viewer needs the sentinel β€” verify viewer handling first. - **Q4.** Does the env builder already detect cloud provider/region? **Checked in review: NO** β€” node/browser env builders do not read `AWS_REGION`/`VERCEL`/`FLY_REGION`; `hardware.cpu_count` and `hardware.screen_*`/`pixel_ratio` already exist, so do not re-emit those as traces. - **Q5.** ANSWERED β€” see Β§13. @@ -401,3 +401,18 @@ swap was a LABEL bug, not a wire difference. The iOS research pass's claim was w `bundle/traces.md`; add the JS runtime names to a platform note. 3. Not yet checked: viewer `hints` table (`RecordingTracesHintsConstant`) and the session-level `hardware.*` fields the viewer reads from `request.json` (so `hardware.gpu`/`cpu_count` placement for ST6 GPU info is open). + +### 13.7 Decisions and the GPU destination (owner, 2026-10-14) +- **Q2 = yes**, **Q3 = keep omitting** (viewer: an omitted trace simply does not appear; `$$unavailable` is handled only + for wifi/cellular-style traces). +- **GPU info is ENVIRONMENT data, not a trace.** Resolves Β§12 item 3 as option (a). The viewer reads + `environment.hardware.gpu = { vendor, renderer, gl_version?, gl_extensions? }` (`core/types/environment.ts:78`; + rendered in the context and environment panels, `elements-recording-context.component.html:418-439`, + `elements-recording-environment.component.html:197`); the specs list `gpu` under `hardware` + (`api/issues.md#environment-object`). So ST6 sets `hardware.gpu` through the Electron environment builder once + `getGPUInfo` settles, omitted until then, and never as a trace. The mapping from Electron's GPU info to + `{vendor, renderer}` is UNVERIFIED (`basic` returns ids; fuller names may need `'complete'`) β€” confirm against a real + Electron before building. `hardware.gpu` may also be fillable in the browser via the WebGL + `WEBGL_debug_renderer_info` extension; that is out of this doc's scope and needs its own privacy decision. +- `hardware.charging` is a **boolean** in the viewer's environment type (`environment.ts:71`), unlike the `charging` + TRACE (`{state}`, Β§13.4). Do not change the environment field when fixing the trace. From a9fba44bdc31a8bd384ed4fbd77142f4754f4119 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 14 Oct 2026 16:07:34 +0500 Subject: [PATCH 14/14] =?UTF-8?q?docs(design):=20=C2=A714=20=E2=80=94=20ad?= =?UTF-8?q?opt=20the=20mobile=20tracker/producer=20structure;=20supersedes?= =?UTF-8?q?=20the=20lifecycle=20seam?= 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-expansion.md | 71 +++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/docs/design/system-traces-expansion.md b/docs/design/system-traces-expansion.md index 31e07f01..a23b73cf 100644 --- a/docs/design/system-traces-expansion.md +++ b/docs/design/system-traces-expansion.md @@ -1,6 +1,6 @@ # System traces expansion β€” competitor gap + mobile-parity port plan β€” DESIGNED, FROZEN (not built) -**Status:** DESIGNED 2026-10-14; **FROZEN 2026-10-14 after review round 9** (PR #53). No further prose rewrites: remaining +**Status:** DESIGNED 2026-10-14; **FROZEN 2026-10-14 after review round 9** (PR #53); **Β§14 (2026-10-14) adopts the mobile tracker/producer structure and supersedes the lifecycle-seam mechanism in Β§5/Β§9/Β§12.** No further prose rewrites: remaining edge cases are tracked as the pre-implementation checklist in Β§12 and are resolved test-first in the owning slice. Nothing here is implemented. Built from five read-only research passes (competitor web, competitor server/cloud/edge, competitor Electron/desktop, Bugsee Android SDK, Bugsee iOS SDK). Every runtime-support claim marked **UNMEASURED** must be probed on the real runtime before the slice @@ -416,3 +416,72 @@ swap was a LABEL bug, not a wire difference. The iOS research pass's claim was w `WEBGL_debug_renderer_info` extension; that is out of this doc's scope and needs its own privacy decision. - `hardware.charging` is a **boolean** in the viewer's environment type (`environment.ts:71`), unlike the `charging` TRACE (`{state}`, Β§13.4). Do not change the environment field when fixing the trace. + +## 14. Structure: shared trackers + per-trace producers (owner decision, 2026-10-14) β€” SUPERSEDES the lifecycle seam + +The design above was written around a platform `sample()` function and then grew a `{start, stop, sample}` lifecycle +seam (Β§5 "Lifecycle seam", Β§12 item 5, review rounds 2–9) to keep standing resources from leaking. The owner directed +that system traces follow the mobile SDKs' structure instead. Studied from source (Android `shared/trackers/*`, +`capture/providers/traces_system/*`, `BugseeCaptureValueProducer*`; iOS `Shared/Trackers/*`, +`Capture/Providers/TracesSystem/*`), then mapped onto what JS already has. + +### 14.1 The mobile structure +- **Tracker** (`BugseeTrackerBase` / `BGSTrackerBase`): owns a raw OS signal (receiver, callback, 1 s poll), aggregates + the CURRENT STATE, and notifies typed listeners (Android: `addListener`/`addBoundListener`, per-listener try/catch; + iOS: weak listener table, `respondsToSelector:` fan-out, no isolation). One tracker serves many producers β€” Android's + `BugseeTrackerProcess` owns the 1 s timer and feeds `TraceRandomAccessMemory`, `TraceProcessMemory`, `TraceCpuUsage`. +- **Producer** (`BugseeCaptureValueProducer` / `BGSCaptureSystemTraceProducer`): subscribes to tracker(s) in `start()`, + emits a baseline, converts state to wire values, de-duplicates (`emitIfChangedName`, equality checks), and emits via a + listener into the provider. Polled-vs-event-driven is a per-producer fact; cadence is owned by a tracker (Android) or + the provider timer (iOS). +- **Provider** = a container of producers (`TraceValueProducerContainer`): stamps timestamps, keeps a last-value + SNAPSHOT per `displayId:name`, and re-emits it at every capture-part switch so a rolling window always starts with the + current state. Producer construction is guarded per producer on Android. +- **Not copied:** trackers there are process-lifetime singletons that start eagerly and are almost never reset (only + Android's Storage tracker polls solely while it has listeners); Android has no `captureSystemTraces` gate. + +### 14.2 The JS mapping +- **Tracker = `InterceptorBase` subclass** (`core/src/interceptor-base.ts`): already a listenable multi-key emitter with + per-listener isolation, activating on the first subscriber and deactivating on the last β€” the behaviour Android only has + for Storage, here by default, and already the repo's rule ("subscriber-presence drives activation"). The `events.system` + sources (`createNodeSystemEventsSource`, `createBrowserSystemEventsSource`) are this pattern today; `traces.system` was the + outlier. Trackers are created **per launch from injected environment** (as the events sources are), not module + singletons, so they stay testable and compatible with multi-instance coexistence. +- **Producer = `TraceProducerBase`** (`@bugsee/capture`): `start(sink)` subscribes + emits a baseline; `stop()` unsubscribes; + `emit` / `emitIfChanged`. +- **Provider** = producer container with per-producer start/stop isolation (stronger than either mobile SDK), a sink that + stamps time and records the snapshot, and a **refresh** (below). The bare `sample()` option stays as a legacy polled + adapter during migration. +- **Option gate:** `captureSystemTraces` (JS-only) gates the provider; producers start only then, so no tracker activates + otherwise and everything is released on stop by subscriber presence. + +### 14.3 What this dissolves +Most review findings in rounds 2–9 were symptoms of resources living in sampler factories or `start`/`stop` pairs: +the delay histogram and `getBattery()` at construction, rAF/GC-observer/`MessageChannel`/`PressureObserver` standing +sources, hidden-tab gating, late-resolve abort tokens, the Electron "wrap the node sampler" problem, and the lazy +events mux. Under this structure each is a tracker's `onActivate`/`onDeactivate`, gated automatically. The +"same `window`/`document` everywhere" findings (rounds 7–9) become one shared **`PageLifecycleTracker`** bound to the +injected window+document, subscribed by both the system-events source and the traces producers β€” the way Android's +`TraceAppState` and `EventApplicationLifecycle` share the Application tracker. The `measures*` honesty flags live on the +tracker/factory deps (Β§5 stays valid in intent; its mechanism is now `NodeTraceProducerDeps`). The Β§12 items still apply +as test requirements for the trackers that implement them (ST4/ST5/ST6). + +### 14.4 New requirement found: last-value refresh +Android/iOS re-emit the last value of every trace on each part switch. JS stores roll a time window with no part hook +(`CaptureStore.tick` evicts by `maxRecordingTimeMs`; `chunk-capture-store.ts`), and today every trace is re-polled every +second so nothing ages out. Event-driven producers with de-duplication (battery, orientation, connection) WOULD age +out of every report. The provider therefore re-emits any held value older than `refreshMs` (default 10 s, bounded by a +quarter of the recording window) with the current timestamp. The viewer collapses equal consecutive values, so the cost is +bytes, not noise. + +### 14.5 Slices (replaces the Β§9 ordering for ST0) +ST0β€² (`docs/design/system-traces-st0-plan.md`): producer contract + container provider + refresh; node `ProcessStatsTracker` +(+ guarded lazy event-loop source) and five producers; node/Bun/Deno wiring; browser `BatteryTracker` + `BatteryProducer` +and the `charging` fix. Follow-ons: migrate the rest of the browser sampler to trackers (memory, connection, orientation), +shared `PageLifecycleTracker`, delete the legacy samplers, then ST1+ traces as producers, then Electron trackers (ST6). + +### 14.6 Verified / not verified +Verified by reading source: the mobile tracker/producer/provider structure (above), `InterceptorBase`/`MultiKeyEmitterBase` +semantics, the JS store's window-based eviction with no part hook. Not verified: Android's polled-trace scheduling details, +how `TraceFps`/FrameMetrics interplay would map to browser `rAF`, and whether per-launch (non-singleton) trackers need any +registration with the internal DI container for multi-instance coexistence.