Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/browser-traces-on-trackers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@bugsee/browser': minor
'@bugsee/webview': minor
---

Browser system traces (heap, connection, orientation) now come from trackers and producers, and `createBrowserSystemTracesSampler` / `BrowserTracesEnv` are removed.

Import `createBrowserSystemTraceProducers` (and `defaultBrowserTraceEnv`) instead. Trace names and wire values are unchanged, with these differences:

- A `systemMetricsSampler` still replaces the heap, connection and orientation traces; battery always runs.
- `connection`, `orientation` and `ram_system_advertised` are emitted when they change (and kept alive by the provider's refresh) instead of on every tick.
- On a real browser the old sampler copied `navigator` with a spread, which dropped `onLine`, `connection` and `deviceMemory`; those are now read live, so real values are reported.
16 changes: 14 additions & 2 deletions docs/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Read this first; then `docs/design/sdk-design.md` (Draft v3) for the full archit
**`@bugsee/browser`**:
- `buildBrowserEnvironment(input, probe)` — §8.6 envelope (`platform.type: 'web'`) via injectable `BrowserProbe` (navigator/screen/Intl; raw UA as `platform.version` — backend parses; deviceMemory/hardwareConcurrency optional). `optionsToWire` on `sdk.options`.
- `createWindowErrorProvider` / `createUnhandledRejectionProvider` — window `error` → crash / `unhandledrejection` → error; `parseStack` dispatches V8 (`at fn (loc)`) vs SpiderMonkey/JSC (`fn@loc`) dialects (core's `parseLocation` reused).
- `createBrowserSystemTracesSampler` (traces: `ram_js_heap_*` (grouped, Android `ram_jvm_heap` parity) + `connection` (transport name from the viewer's CONNECTION_STATES vocabulary, `navigator.onLine` first) + `orientation` (the Android/iOS `Orientation` int, never the browser's `{type, angle}`), each degrading where its API is absent; `battery`/`charging` moved OUT of the sampler in ST0′ — they come from `BatteryProducer` over `BatteryTracker`, see `createBrowserSystemTraceProducers`) + `createBrowserSystemEventsSource` (events: `process_started`, `pagehide`→`process_exiting`, `visibilitychange`→`process_foreground`/`process_background`, `online`/`offline`, `orientationchange`→`orientation_changed`). See the capture-completeness milestone in §7.
- `createBrowserSystemTraceProducers` (the browser `traces.system` set; the old `createBrowserSystemTracesSampler` is deleted — see the BT1 note in §7; traces: `ram_js_heap_*` (grouped, Android `ram_jvm_heap` parity) + `connection` (transport name from the viewer's CONNECTION_STATES vocabulary, `navigator.onLine` first) + `orientation` (the Android/iOS `Orientation` int, never the browser's `{type, angle}`), each degrading where its API is absent; `battery`/`charging` moved OUT of the sampler in ST0′ — they come from `BatteryProducer` over `BatteryTracker`, see `createBrowserSystemTraceProducers`) + `createBrowserSystemEventsSource` (events: `process_started`, `pagehide`→`process_exiting`, `visibilitychange`→`process_foreground`/`process_background`, `online`/`offline`, `orientationchange`→`orientation_changed`). See the capture-completeness milestone in §7.
- `createBrowserInputSource` (input: capture-phase/passive DOM listeners → the dedicated **`input`** stream (`input.json`) via the runtime-agnostic `createInputProvider`) — **pointerdown/pointerup/pointercancel** (Pointer Events, so `pointerType` gives the wire `tool` for free and a touchpad correctly reports Mouse; `pointerId` keys the gesture; `button` covers secondary/middle) + **keydown**, emitting the viewer's `RecordingTouchEvent` shape. (`change`/`submit`/`focus` left this stream on 2026-08-31 — they are state-change signals, not device input; see `createUiBreadcrumbSource` below.) PII-safe `describeTarget` (tag/id/class/type/text/selector) masks anything matching the SHARED sensitive-input definition or `[data-bugsee-hidden]`; typed text never captured (AltGr/emoji/IME-robust); **every keystroke aimed at a sensitive/masked field is dropped outright**; throw-isolated. **It does NOT write to `events.user`** — see the `*.user` rule below. See the capture-completeness milestone in §7.
- `createUiBreadcrumbSource` / `createUiBreadcrumbProvider` (**UI breadcrumbs**, 2026-08-31): the state-change half of the DOM interaction split — capture-phase/passive listeners for `change`/`submit`/`focusin` → `client.addBreadcrumb` (so the app's `breadcrumbFilter` runs) in Android `BreadcrumbInputGesture`'s shape: `{type:'user', category:'ui.change'|'ui.submit'|'ui.focus', level:'info', data:{'view.id','view.class','view.tag'}, timestamp: the DOM event's own moment (`performance.timeOrigin + Event.timeStamp`, clamped to now)}`. `type:'user'` is Android's literal for a user-ORIGINATED breadcrumb — **not** the `*.user` stream (these are `breadcrumbs` entries). Why not `input`: `InputEvent.type` is Android's `InputEventStage` (`unknown|begin|move|end|scroll|keydown|keyup`), which a DOM-only value cannot join, and Android already splits its input dispatcher (→ `input.json`) from its gesture dispatcher (→ breadcrumbs). Reuses `describeTarget`; a sensitive/app-masked target **drops the breadcrumb outright** (the focus/change rhythm on a masked field is itself a side channel). Gated by `captureInteractions` (the option means "do not watch what I click and type", whatever stream it lands on). Wired into `@bugsee/browser` **and** `@bugsee/webview` (which also now declares `breadcrumbs` in `hello.caps`).
- `createDomSnapshot` / `createViewtreeSnapshotSource` (view hierarchy: an at-report DOM-tree snapshot → `viewtree`, via the core `reportSnapshots` pull-seam) — reuses `describeTarget` per node + rounded `getBoundingClientRect`; masked subtrees collapse to `{tag, masked, rect}`; bounded (maxNodes/maxDepth) + per-node throw-isolated. Gated by `captureViewHierarchy`. See §7.
Expand Down Expand Up @@ -125,7 +125,7 @@ Pluggable extensions (APM/replay/etc.) remain the contract for non-core features
| Durable bundle queue | `BundleStore` (type) + `createDurableUploadPipeline` | `createNodeBundleStore` (fs files) | IndexedDB-backed |
| Capture sources | `console`, `fetch`, `xhr`, `sendBeacon`, `ws`, `sse`, `webtransport` (self-skip if absent) | `node:http` added via `installNetworkCapture({ additionalSources })` | DOM-specific sources |
| Process events | — | `process` (`uncaughtException`, `unhandledRejection`, `exit`, `warning`) | `window.onerror`, `unhandledrejection` |
| System metrics | trace producers over shared trackers (`TraceProducer`); legacy `TraceSample[]` sampler still accepted | `createNodeSystemTraceProducers` (legacy `createNodeSystemMetricsSampler` only as an override) | `createBrowserSystemTracesSampler` (`performance.memory` etc.) + `createBrowserSystemTraceProducers` (battery) |
| System metrics | trace producers over shared trackers (`TraceProducer`); legacy `TraceSample[]` sampler still accepted | `createNodeSystemTraceProducers` (legacy `createNodeSystemMetricsSampler` only as an override) | `createBrowserSystemTraceProducers` (battery, heap, connection, orientation; `systemMetricsSampler` is only an override) |
| Clock / Scheduler / SystemProbe | injectable interfaces in core | real defaults (`Date.now`, `setInterval`, `os.*`) | platform defaults |

Cross-runtime capture interceptors **self-skip when their global is absent**, so `@bugsee/capture` can be imported safely on any runtime; only the runtime-specific *additionalSources* differ.
Expand Down Expand Up @@ -391,6 +391,18 @@ solid), in slices:
- **CE2 — system TRACES breadth (DONE, `main`):** `createBrowserSystemTracesSampler` adds `connection`
(navigator.connection), `orientation` (screen.orientation), `battery`/`charging` (since ST0′ via the
event-driven `BatteryTracker`, not the sampler) to the existing `ram_js_heap_*`. Android trace-name parity; degrades per-API.
- **BT1 — browser traces onto trackers (DONE, `feat/browser-trackers`):** the rest of the browser `traces.system`
sampler moved onto the ST0′ tracker/producer structure and **`createBrowserSystemTracesSampler` / `BrowserTracesEnv`
are deleted** (`systemMetricsSampler` stays as a legacy override that REPLACES the heap/connection/orientation traces, as on Node; battery still runs). New `InterceptorBase` trackers in
`packages/browser/src/trackers/`: `MemoryTracker` (the one `performance.memory` poll timer, started only on a
subscriber and never where the engine has no `performance.memory`), `ConnectionTracker` (`online`/`offline` + `navigator.connection`
`change`), `OrientationTracker` (`screen.orientation` `change` + `orientationchange`). Producers: `MemoryProducer`
(`ram_js_heap_*`, polled), `AdvertisedMemoryProducer` (`ram_system_advertised`), `ConnectionProducer`, `OrientationProducer`
(event-driven, de-duplicated, held so the provider refresh keeps them in a rolling window). The producers read the LIVE
`navigator`/`performance`/`screen`/`window` (`defaultBrowserTraceEnv()`); the old sampler copied `navigator` with a
spread, which drops the prototype accessors (`onLine`, `connection`, `deviceMemory`) on a real browser. Launch passes
its injected `window` and `scheduler`. Still open: the shared `PageLifecycleTracker` (the events source and these trackers
each still attach their own `online`/`offline`/`orientationchange` listeners), then ST1+.
- **ST0′ (DONE, `feat/trace-producers`):** `traces.system` is now producers over shared trackers.
`@bugsee/capture` has `TraceProducer`/`TraceProducerBase`; the traces provider is a producer container
(per-producer start/stop isolation, last-value refresh so a rolling window never loses a rarely-changing
Expand Down
7 changes: 5 additions & 2 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,12 @@ export {
} from './report-dialog';
export { parseStack } from './stack';
export { type BrowserSystemEventsEnv, createBrowserSystemEventsSource } from './system-events';
export { type BrowserTracesEnv, createBrowserSystemTracesSampler } from './system-metrics';
export { chargingToWire } from './system-trace-values';
export { createBrowserSystemTraceProducers } from './trace-producers';
export {
type BrowserTraceEnv,
createBrowserSystemTraceProducers,
defaultBrowserTraceEnv,
} from './trace-producers';
export {
createUiBreadcrumbProvider,
createUiBreadcrumbSource,
Expand Down
133 changes: 133 additions & 0 deletions packages/browser/src/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ function uploadTransport() {
// --- harness -------------------------------------------------------------------------------------

const clients: Bugsee[] = [];
const clientsStop = (): Array<Promise<boolean>> => clients.splice(0).map((c) => c.stop());
afterEach(async () => {
await Promise.all(clients.splice(0).map((c) => c.stop()));
resetBrowserDeviceIdCache();
Expand Down Expand Up @@ -1117,6 +1118,138 @@ describe('launch', () => {
expect(getBattery).not.toHaveBeenCalled();
});

describe('default sensor traces (tracker-backed producers, no sampler override)', () => {
// The shipped path: no `systemMetricsSampler`, so heap / connection / orientation come from the
// trackers over the injected window + the live globals.
const noSampler = (over: Partial<BugseeLaunchOptions> = {}): BugseeLaunchOptions => {
const { systemMetricsSampler: _legacy, ...rest } = baseOptions(over);
return rest;
};
const stubSensors = (): void => {
vi.stubGlobal('navigator', {
onLine: true,
deviceMemory: 4,
connection: { type: 'wifi', effectiveType: '4g' },
});
vi.stubGlobal('performance', {
memory: { usedJSHeapSize: 10, totalJSHeapSize: 20, jsHeapSizeLimit: 100 },
});
vi.stubGlobal('screen', { orientation: { type: 'portrait-primary', angle: 0 } });
};

it('captures heap, advertised RAM, connection and orientation from the real globals', async () => {
stubSensors();
const store = memStore();
await launchTracked('tok', noSampler({ captureStore: store }));
const rows = new Map(
((await drain(store, 'traces.system')) ?? []).map((e) => {
const d = e.data as { name: string; value: unknown };
return [d.name, d.value];
}),
);
expect(rows.get('ram_js_heap_total')).toBe(100);
expect(rows.get('ram_js_heap_free')).toBe(90);
expect(rows.get('ram_js_heap_used')).toBe(10);
expect(rows.get('ram_system_advertised')).toBe(4 * 1024 ** 3);
expect(rows.get('connection')).toMatchObject({ type: 'wifi', effective_type: '4g' });
expect(rows.get('orientation')).toBe(1);
});

it('listens on the INJECTED window for connectivity only while capturing, and releases it on stop', async () => {
stubSensors();
const w = fakeWindow();
const client = await launchTracked(
'tok',
// the system-events source listens on the same window, so isolate the trace trackers
noSampler({ window: w.win, captureStore: memStore(), captureSystemEvents: false }),
);
expect(w.count('online')).toBe(1);
expect(w.count('offline')).toBe(1);
expect(w.count('orientationchange')).toBe(1);
await client.stop();
expect(w.count('online')).toBe(0);
expect(w.count('offline')).toBe(0);
expect(w.count('orientationchange')).toBe(0);
});

it('with captureSystemTraces:false no sensor listener is attached and no timer is started', async () => {
stubSensors();
const w = fakeWindow();
const timers = vi.fn(() => 'h');
await launchTracked(
'tok',
noSampler({
window: w.win,
captureStore: memStore(),
captureSystemTraces: false,
captureSystemEvents: false, // the events source listens on the same window
scheduler: { setInterval: timers, clearInterval: () => {} },
}),
);
expect(w.count('online') + w.count('offline') + w.count('orientationchange')).toBe(0);
// the capture-store tick may use the scheduler; the heap poll must not add its own 1 s timer
const heapTimers = timers.mock.calls.length;
expect(heapTimers).toBeLessThanOrEqual(1);
});

it('drives the heap poll through the launch scheduler (one extra interval only where the engine has performance.memory)', async () => {
const intervals = async (withHeap: boolean): Promise<number> => {
stubSensors();
if (!withHeap) vi.stubGlobal('performance', {});
const timers = vi.fn((_cb: () => void, _ms: number) => 'h');
await launchTracked(
'tok',
noSampler({
captureStore: memStore(),
scheduler: { setInterval: timers, clearInterval: () => {} },
}),
);
return timers.mock.calls.filter(([, ms]) => ms === 1000).length;
};
const without = await intervals(false);
await Promise.all(clientsStop());
const withHeap = await intervals(true);
expect(withHeap).toBe(without + 1);
});

it('a systemMetricsSampler REPLACES the sensor traces (no duplicate heap rows, no sensor listeners); battery still runs', async () => {
stubSensors();
const w = fakeWindow();
const store = memStore();
await launchTracked(
'tok',
baseOptions({
window: w.win,
captureStore: store,
captureSystemEvents: false,
systemMetricsSampler: () => [{ name: 'ram_js_heap_used', value: 42 }],
}),
);
const names = ((await drain(store, 'traces.system')) ?? []).map(
(e) => (e.data as { name: string; value: unknown }).name,
);
expect(names.filter((n) => n === 'ram_js_heap_used')).toHaveLength(1);
expect(names).not.toContain('connection');
expect(names).not.toContain('orientation');
expect(w.count('online') + w.count('offline') + w.count('orientationchange')).toBe(0);
});

it('an offline event is captured as a not_reachable connection row', async () => {
stubSensors();
const nav = (globalThis as { navigator: { onLine: boolean } }).navigator;
const w = fakeWindow();
const store = memStore();
await launchTracked('tok', noSampler({ window: w.win, captureStore: store }));
nav.onLine = false;
w.emit('offline', {});
const types = ((await drain(store, 'traces.system')) ?? [])
.map((e) => e.data as { name: string; value: { type?: string } })
.filter((d) => d.name === 'connection')
.map((d) => d.value.type);
expect(types).toEqual(['wifi', 'not_reachable']);
});
});

it('wires the network capture provider (captureNetwork on): a fetch yields a network entry', async () => {
const slot = globalThis as unknown as {
fetch?: (i: unknown, init?: unknown) => Promise<unknown>;
Expand Down
23 changes: 18 additions & 5 deletions packages/browser/src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,7 @@ import {
} from './report-dialog';
import { parseStack } from './stack';
import { createBrowserSystemEventsSource } from './system-events';
import { createBrowserSystemTracesSampler } from './system-metrics';
import { createBrowserSystemTraceProducers } from './trace-producers';
import { createBrowserSystemTraceProducers, defaultBrowserTraceEnv } from './trace-producers';
import { createUiBreadcrumbProvider, createUiBreadcrumbSource } from './ui-breadcrumb-source';
import { createUserIdentifierStore } from './user-identifier-store';
import { createBrowserViewportSource } from './viewport-source';
Expand Down Expand Up @@ -439,7 +438,10 @@ export interface BugseeLaunchOptions {
triggerPipeline?: TriggerPipeline;
/** System probe for the environment envelope. Default realBrowserProbe. */
systemProbe?: BrowserProbe;
/** System-traces sampler. Default the performance.memory sampler. */
/**
* LEGACY polled system-traces override. It REPLACES the default heap/connection/orientation traces (as on
* Node); battery stays a tracker-backed producer. The defaults are producers, not a sampler.
*/
systemMetricsSampler?: () => readonly TraceSample[];
/** Durable bundle store override (crash recovery across reloads). Default none (until B5). */
bundleStore?: BundleStore;
Expand Down Expand Up @@ -841,8 +843,19 @@ async function buildLaunch(
client.addCaptureProvider(network.provider);
client.addCaptureProvider(
createSystemTracesProvider({
sample: options.systemMetricsSampler ?? createBrowserSystemTracesSampler(),
producers: createBrowserSystemTraceProducers(undefined, options.onError),
// A `systemMetricsSampler` REPLACES the sensor traces (heap, connection, orientation), as on Node; battery still runs.
...(options.systemMetricsSampler !== undefined
? { sample: options.systemMetricsSampler }
: {}),
producers: createBrowserSystemTraceProducers(
{
...defaultBrowserTraceEnv(),
...(options.systemMetricsSampler !== undefined ? { sensors: false } : {}),
window: win,
...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}),
},
options.onError,
),
refreshMs: deriveRefreshMs(resolved.options.get(BugseeOption.Duration, 60) * 1000),
...(options.onError !== undefined ? { onError: options.onError } : {}),
...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}),
Expand Down
Loading
Loading