Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/page-lifecycle-tracker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@bugsee/browser': minor
'@bugsee/webview': minor
---

The browser and webview launches now bind the window and document lifecycle listeners once, in a shared `PageLifecycleTracker` (exported from `@bugsee/browser`). The system-events source, the connection trace and the orientation trace subscribe to it instead of each attaching their own `online`/`offline`/`orientationchange` listeners.

`createBrowserSystemEventsSource` and `createBrowserSystemTraceProducers` accept an optional `lifecycle` to share one; without it they behave as before. The browser launch now also passes the document, so `process_foreground` / `process_background` events are captured there too.
5 changes: 5 additions & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ export {
createBrowserSystemTraceProducers,
defaultBrowserTraceEnv,
} from './trace-producers';
export {
type PageLifecycleEnv,
type PageLifecycleEvents,
PageLifecycleTracker,
} from './trackers/page-lifecycle-tracker';
export {
createUiBreadcrumbProvider,
createUiBreadcrumbSource,
Expand Down
27 changes: 27 additions & 0 deletions packages/browser/src/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,33 @@ describe('launch', () => {
expect(events?.map((e) => (e.data as { name: string }).name)).toContain('process_started');
});

it('ONE shared lifecycle tracker serves traces and events: a single listener per type; visibility reaches events.system via the injected document', async () => {
const store = memStore();
const w = fakeWindow();
const doc = fakeWindow();
const docWin = { ...doc.win, visibilityState: 'hidden' };
const client = await launchTracked(
'tok',
baseOptions({
window: w.win,
document: docWin as unknown as Document,
captureStore: store,
systemMetricsSampler: undefined,
}),
);
// traces (connection/orientation) AND the events source are both live, yet each type is bound once
expect(w.count('online')).toBe(1);
expect(w.count('orientationchange')).toBe(1);
doc.emit('visibilitychange', {});
const names = ((await drain(store, 'events.system')) ?? []).map(
(e) => (e.data as { name: string }).name,
);
expect(names).toContain('process_background');
await client.stop();
expect(w.count('online') + w.count('orientationchange')).toBe(0);
expect(doc.count('visibilitychange')).toBe(0);
});

// Re-pointed from "captures a document interaction (click) as an events.user entry". What that test
// was really protecting: launch() actually WIRES the DOM input source into the client, so a real
// document interaction lands in the bundle. That still matters — but the stream it lands in was the
Expand Down
16 changes: 15 additions & 1 deletion packages/browser/src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import {
import { parseStack } from './stack';
import { createBrowserSystemEventsSource } from './system-events';
import { createBrowserSystemTraceProducers, defaultBrowserTraceEnv } from './trace-producers';
import { PageLifecycleTracker } from './trackers/page-lifecycle-tracker';
import { createUiBreadcrumbProvider, createUiBreadcrumbSource } from './ui-breadcrumb-source';
import { createUserIdentifierStore } from './user-identifier-store';
import { createBrowserViewportSource } from './viewport-source';
Expand Down Expand Up @@ -841,6 +842,12 @@ async function buildLaunch(
const maxBodyBytes = resolved.options.get(BugseeOption.CaptureNetworkBodySizeLimit, 20480);
const network = installNetworkCapture({ carrier, captureBodies, maxBodyBytes });
client.addCaptureProvider(network.provider);
// ONE lifecycle tracker over the injected window+document, shared by the traces' connection/orientation
// producers and the events source: the listeners are bound in a single place.
const lifecycle = new PageLifecycleTracker(
{ window: win, ...(domDocument !== undefined ? { document: domDocument } : {}) },
options.onError,
);
client.addCaptureProvider(
createSystemTracesProvider({
// A `systemMetricsSampler` REPLACES the sensor traces (heap, connection, orientation), as on Node; battery still runs.
Expand All @@ -852,6 +859,7 @@ async function buildLaunch(
...defaultBrowserTraceEnv(),
...(options.systemMetricsSampler !== undefined ? { sensors: false } : {}),
window: win,
lifecycle,
...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}),
},
options.onError,
Expand All @@ -862,7 +870,13 @@ async function buildLaunch(
}),
);
client.addCaptureProvider(
createSystemEventsProvider(createBrowserSystemEventsSource({ window: win })),
createSystemEventsProvider(
createBrowserSystemEventsSource({
window: win,
lifecycle,
...(options.onError !== undefined ? { onError: options.onError } : {}),
}),
),
);
// Input capture: one carrier-shared DOM source (capture-phase, passive, observe-only) → the dedicated
// `input` stream (`input.json`). NOT `events.user` — that stream belongs to the application's own
Expand Down
51 changes: 51 additions & 0 deletions packages/browser/src/system-events.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SystemEvent } from '@bugsee/capture';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { type BrowserSystemEventsEnv, createBrowserSystemEventsSource } from './system-events';
import { PageLifecycleTracker } from './trackers/page-lifecycle-tracker';

afterEach(() => {
vi.unstubAllGlobals();
Expand Down Expand Up @@ -144,4 +145,54 @@ describe('createBrowserSystemEventsSource', () => {
expect(doc.count('visibilitychange')).toBe(1);
off();
});

it('subscribes to an injected shared lifecycle tracker instead of attaching its own listeners', () => {
const { env, win, doc } = fakeEnv();
const lifecycle = new PageLifecycleTracker({ window: win, document: env.document as never });
const a = activate({ ...env, lifecycle });
const b = activate({ ...env, lifecycle });
// two sources over ONE tracker → one listener per type
expect(win.count('online')).toBe(1);
expect(doc.count('visibilitychange')).toBe(1);
win.emit('offline', {});
expect(a.events.at(-1)).toEqual({ name: 'offline' });
expect(b.events.at(-1)).toEqual({ name: 'offline' });
a.off();
expect(win.count('offline')).toBe(1); // still held by the other source
b.off();
expect(win.count('offline')).toBe(0);
});

it('reports a lifecycle failure to the injected onError, not into event dispatch', () => {
const { env, win, doc } = fakeEnv();
const onError = vi.fn();
Object.defineProperty(env.document, 'visibilityState', {
get(): never {
throw new Error('getter');
},
});
const { off } = activate({ ...env, onError });
expect(() => doc.emit('visibilitychange', {})).not.toThrow();
expect(onError).toHaveBeenCalledTimes(1);
expect(win.count('pagehide')).toBe(1);
off();
});

it('over a shared tracker, re-subscribing after a full release re-emits process_started and resubscribes', () => {
const { env, win } = fakeEnv();
const lifecycle = new PageLifecycleTracker({ window: win, document: env.document as never });
const source = createBrowserSystemEventsSource({ ...env, lifecycle });
const events: SystemEvent[] = [];
const first = source.onAny((_s, e) => events.push(e));
first();
expect(win.count('online')).toBe(0);
const second = source.onAny((_s, e) => events.push(e));
win.emit('online', {});
expect(events).toEqual([
{ name: 'process_started' },
{ name: 'process_started' },
{ name: 'online' },
]);
second();
});
});
96 changes: 49 additions & 47 deletions packages/browser/src/system-events.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SystemEvent } from '@bugsee/capture';
import { type Interceptor, InterceptorBase } from '@bugsee/core';
import type { WindowEvents } from './detection-providers';
import { PageLifecycleTracker } from './trackers/page-lifecycle-tracker';

// Browser system-event SOURCE for @bugsee/capture's systemEventsProvider (the node lifecycle-source
// analog). A listenable InterceptorBase: on activate it emits a 'process_started' marker and subscribes
Expand All @@ -12,10 +13,8 @@ import type { WindowEvents } from './detection-providers';
// orientationchange → orientation_changed ({type, angle} from screen.orientation)
// Each signal degrades gracefully where its global is absent (e.g. no document/screen in a worker).

/** A DOM event target (window or document) limited to the add/remove listener surface. */
type EventTarget = WindowEvents;
/** The document surface the source needs: visibilitychange + the current visibility state. */
interface DocumentLike extends EventTarget {
interface DocumentLike extends WindowEvents {
readonly visibilityState: DocumentVisibilityState;
}
/** The screen surface: the current orientation (absent on some browsers). */
Expand All @@ -25,70 +24,73 @@ interface ScreenLike {

/** Injected globals (each defaults to the real global; an absent one is skipped). */
export interface BrowserSystemEventsEnv {
window?: EventTarget;
window?: WindowEvents;
document?: DocumentLike;
screen?: ScreenLike;
/** The shared lifecycle tracker; when absent one is created over `window` + `document`. */
lifecycle?: Pick<PageLifecycleTracker, 'on'>;
onError?: (error: unknown) => void;
}

class BrowserSystemEventsSource extends InterceptorBase<{ event: SystemEvent }> {
readonly name = 'browser-system-events';
readonly #window: EventTarget | undefined;
readonly #document: DocumentLike | undefined;
readonly #lifecycle: Pick<PageLifecycleTracker, 'on'>;
readonly #screen: ScreenLike | undefined;
#unsubscribe: Array<() => void> = [];

readonly #onPageHide = (event: Event): void => {
this.emit('event', {
name: 'process_exiting',
params: { persisted: (event as PageTransitionEvent).persisted },
});
};
readonly #onVisibility = (): void => {
const visible = this.#document?.visibilityState === 'visible';
this.emit('event', { name: visible ? 'process_foreground' : 'process_background' });
};
readonly #onOnline = (): void => this.emit('event', { name: 'online' });
readonly #onOffline = (): void => this.emit('event', { name: 'offline' });
readonly #onOrientation = (): void => {
const orientation = this.#screen?.orientation;
this.emit('event', {
name: 'orientation_changed',
...(orientation !== undefined
? { params: { type: orientation.type, angle: orientation.angle } }
: {}),
});
};

constructor(env: BrowserSystemEventsEnv) {
constructor(lifecycle: Pick<PageLifecycleTracker, 'on'>, screenLike: ScreenLike | undefined) {
super();
this.#window = env.window;
this.#document = env.document;
this.#screen = env.screen;
this.#lifecycle = lifecycle;
this.#screen = screenLike;
}

protected onActivate(): void {
const l = this.#lifecycle;
this.emit('event', { name: 'process_started' });
this.#window?.addEventListener('pagehide', this.#onPageHide);
this.#window?.addEventListener('online', this.#onOnline);
this.#window?.addEventListener('offline', this.#onOffline);
this.#window?.addEventListener('orientationchange', this.#onOrientation);
this.#document?.addEventListener('visibilitychange', this.#onVisibility);
this.#unsubscribe = [
l.on('pagehide', ({ persisted }) =>
this.emit('event', { name: 'process_exiting', params: { persisted } }),
),
l.on('visibility', ({ visible }) =>
this.emit('event', { name: visible ? 'process_foreground' : 'process_background' }),
),
l.on('online', () => this.emit('event', { name: 'online' })),
l.on('offline', () => this.emit('event', { name: 'offline' })),
l.on('orientationchange', () => {
const orientation = this.#screen?.orientation;
this.emit('event', {
name: 'orientation_changed',
...(orientation !== undefined
? { params: { type: orientation.type, angle: orientation.angle } }
: {}),
});
}),
];
}

protected override onDeactivate(): void {
this.#window?.removeEventListener('pagehide', this.#onPageHide);
this.#window?.removeEventListener('online', this.#onOnline);
this.#window?.removeEventListener('offline', this.#onOffline);
this.#window?.removeEventListener('orientationchange', this.#onOrientation);
this.#document?.removeEventListener('visibilitychange', this.#onVisibility);
const unsubscribe = this.#unsubscribe;
this.#unsubscribe = [];
for (const off of unsubscribe) off();
}
}

export function createBrowserSystemEventsSource(
env: BrowserSystemEventsEnv = {},
): Interceptor<{ event: SystemEvent }> {
return new BrowserSystemEventsSource({
window: env.window ?? (typeof window !== 'undefined' ? window : undefined),
document: env.document ?? (typeof document !== 'undefined' ? document : undefined),
screen: env.screen ?? (typeof screen !== 'undefined' ? screen : undefined),
});
const win = env.window ?? (typeof window !== 'undefined' ? window : undefined);
const doc = env.document ?? (typeof document !== 'undefined' ? document : undefined);
const lifecycle =
env.lifecycle ??
new PageLifecycleTracker(
{
...(win !== undefined ? { window: win } : {}),
...(doc !== undefined ? { document: doc } : {}),
},
env.onError,
);
return new BrowserSystemEventsSource(
lifecycle,
env.screen ?? (typeof screen !== 'undefined' ? screen : undefined),
);
}
5 changes: 5 additions & 0 deletions packages/browser/src/trace-producers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type OrientationState,
OrientationTracker,
} from './trackers/orientation-tracker';
import type { PageLifecycleTracker } from './trackers/page-lifecycle-tracker';

// Browser `traces.system` producers (mirrors the mobile tracker/producer split). Each producer subscribes to
// one shared tracker in start() and turns its state into the Android-vocabulary wire values; the trackers own
Expand Down Expand Up @@ -195,6 +196,8 @@ export interface BrowserTraceEnv extends BatteryEnv {
sensors?: boolean;
/** Drives the heap poll; default the global timers. */
scheduler?: Scheduler;
/** Shared page-lifecycle tracker for connection/orientation; when absent each listens on `window`. */
lifecycle?: Pick<PageLifecycleTracker, 'on'>;
}

/** The real globals, read lazily (workers / Node tests may have none of them). */
Expand Down Expand Up @@ -249,6 +252,7 @@ export function createBrowserSystemTraceProducers(
new ConnectionProducer(
new ConnectionTracker(
{
...(env.lifecycle !== undefined ? { lifecycle: env.lifecycle } : {}),
...(env.window !== undefined ? { window: env.window } : {}),
...(nav !== undefined ? { navigator: nav } : {}),
},
Expand All @@ -258,6 +262,7 @@ export function createBrowserSystemTraceProducers(
new OrientationProducer(
new OrientationTracker(
{
...(env.lifecycle !== undefined ? { lifecycle: env.lifecycle } : {}),
...(env.window !== undefined ? { window: env.window } : {}),
...(env.screen !== undefined ? { screen: env.screen } : {}),
},
Expand Down
21 changes: 21 additions & 0 deletions packages/browser/src/trackers/connection-tracker.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { ConnectionTracker } from './connection-tracker';
import { PageLifecycleTracker } from './page-lifecycle-tracker';

function mkTarget() {
const l = new Map<string, Set<() => void>>();
Expand Down Expand Up @@ -186,4 +187,24 @@ describe('ConnectionTracker', () => {
win.fire('online');
expect(seen).not.toHaveBeenCalled();
});

it('with a shared lifecycle it takes online/offline from it and attaches NO window listener of its own', () => {
const win = mkTarget();
const connection = mkTarget();
const lifecycle = new PageLifecycleTracker({ window: win as never });
const t = new ConnectionTracker({
lifecycle,
window: win,
navigator: { onLine: true, connection },
});
const seen: unknown[] = [];
const off = t.on('state', (s) => seen.push(s));
expect(win.count()).toBe(4); // the lifecycle's own four window listeners; the tracker added none
expect(connection.count()).toBe(1);
win.fire('online');
win.fire('offline');
expect(seen).toHaveLength(2);
off();
expect(win.count() + connection.count()).toBe(0);
});
});
Loading
Loading