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
19 changes: 19 additions & 0 deletions packages/browser/src/interaction-source.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createBlackoutState } from '@bugsee/core';
import { describe, expect, it } from 'vitest';
import {
createBrowserInteractionSource,
Expand Down Expand Up @@ -239,4 +240,22 @@ describe('createBrowserInteractionSource', () => {
off();
expect(instances[0]?.disconnected).toBe(true);
});

it('emits nothing during a blackout, and an entry seen then is not replayed after it ends', () => {
const { Ctor, instances } = fakeObserver();
const blackout = createBlackoutState();
const { seen } = wire({ PerformanceObserver: Ctor, blackout });
const click = (id: number) => ({
name: 'click',
duration: 90,
interactionId: id,
target: { tagName: 'BUTTON', id: `b${id}` },
});
instances[0]?.push([click(1)]);
blackout.start();
instances[0]?.push([click(2)]);
blackout.end();
instances[0]?.push([click(2), click(3)]); // 2 was consumed during the blackout
expect(seen.map((d) => d.interactionId)).toEqual([1, 3]);
});
});
9 changes: 8 additions & 1 deletion packages/browser/src/interaction-source.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Interceptor, InterceptorBase } from '@bugsee/core';
import { type BlackoutGate, type Interceptor, InterceptorBase } from '@bugsee/core';
import { componentNameFromElement } from './component-name';
import { describeTarget } from './input-source';

Expand Down Expand Up @@ -56,6 +56,8 @@ export interface InteractionEnv {
durationThreshold?: number;
/** Mask selector forwarded to `describeTarget`. Default `[data-bugsee-hidden]`. */
maskSelector?: string;
/** Blackout gate: while active, interactions are not emitted (they are interaction capture). */
blackout?: BlackoutGate;
}

const DEFAULT_DURATION_THRESHOLD = 40;
Expand All @@ -78,6 +80,7 @@ class BrowserInteractionSource extends InterceptorBase<{ interact: InteractionDe
readonly #Ctor: PerformanceObserverCtorLike | undefined;
readonly #threshold: number;
readonly #mask: string;
readonly #blackout: BlackoutGate | undefined;
#observer: PerformanceObserverLike | undefined;
// Dedupe by the monotonic interactionId high-water mark: Event Timing entries arrive in startTime order
// and interactionId increases per interaction, so emitting only for ids ABOVE the mark yields exactly
Expand All @@ -91,13 +94,16 @@ class BrowserInteractionSource extends InterceptorBase<{ interact: InteractionDe
this.#Ctor = env.PerformanceObserver;
this.#threshold = env.durationThreshold ?? DEFAULT_DURATION_THRESHOLD;
this.#mask = env.maskSelector ?? DEFAULT_MASK;
this.#blackout = env.blackout;
}

#onEntries(entries: EventTimingEntryLike[]): void {
for (const entry of entries) {
const id = entry.interactionId ?? 0;
if (id <= this.#maxSeenId) continue; // a non-interaction (id 0) or an already-seen interaction
this.#maxSeenId = id;
// Blackout: the id is still consumed above, so a buffered entry cannot be replayed after it ends.
if (this.#blackout?.isActive() === true) continue;
Comment thread
cursor[bot] marked this conversation as resolved.
const target = targetLabel(entry.target, this.#mask);
const component = componentNameFromElement(entry.target); // D2: nearest data-bugsee-component
this.emit('interact', {
Expand Down Expand Up @@ -137,5 +143,6 @@ export function createBrowserInteractionSource(env: InteractionEnv = {}): Intera
PerformanceObserver: env.PerformanceObserver ?? g.PerformanceObserver,
...(env.durationThreshold !== undefined ? { durationThreshold: env.durationThreshold } : {}),
...(env.maskSelector !== undefined ? { maskSelector: env.maskSelector } : {}),
...(env.blackout !== undefined ? { blackout: env.blackout } : {}),
});
}
118 changes: 115 additions & 3 deletions packages/browser/src/launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,114 @@ describe('launch', () => {
]);
});

describe('blackout', () => {
const press = (doc: ReturnType<typeof fakeWindow>, id: number): void =>
doc.emit('pointerdown', {
pointerId: id,
pointerType: 'mouse',
target: {
tagName: 'BUTTON',
getAttribute: () => null,
closest: () => null,
matches: () => false,
},
clientX: id,
clientY: id,
button: 0,
pressure: 0.5,
});
const field = {
tagName: 'INPUT',
id: 'qty',
type: 'number',
getAttribute: () => null,
closest: () => null,
matches: () => false,
};

it('exposes startBlackout / endBlackout / isBlackout on the launched client', async () => {
const client = await launchTracked('tok', baseOptions({ captureStore: memStore() }));
expect(client.isBlackout()).toBe(false);
client.startBlackout();
expect(client.isBlackout()).toBe(true);
client.endBlackout();
expect(client.isBlackout()).toBe(false);
});

it('suppresses input presses and ui.* breadcrumbs for the blackout window only', async () => {
const store = memStore();
const doc = fakeWindow();
const client = await launchTracked(
'tok',
baseOptions({ captureStore: store, document: doc.win as unknown as Document }),
);
press(doc, 1);
doc.emit('focusin', { target: field });
client.startBlackout();
press(doc, 2);
doc.emit('change', { target: field });
doc.emit('submit', { target: field });
client.endBlackout();
press(doc, 3);
doc.emit('change', { target: field });

const input = await drain(store, 'input');
expect(input?.map((e) => (e.data as { x: number }).x)).toEqual([1, 3]);
const crumbs = await drain(store, 'breadcrumbs');
expect(crumbs?.map((e) => (e.data as { category: string }).category)).toEqual([
'ui.focus',
'ui.change',
]);
});

it('does NOT suppress non-visual capture: logs, events and app breadcrumbs continue', async () => {
const store = memStore();
const client = await launchTracked('tok', baseOptions({ captureStore: store }));
client.startBlackout();
client.log('kept');
client.event('kept-event');
client.addBreadcrumb({ category: 'app.step', message: 'kept' });
expect(await drain(store, 'log')).toHaveLength(1);
expect(await drain(store, 'events.user')).toHaveLength(1);
expect(
(await drain(store, 'breadcrumbs'))?.map((e) => (e.data as { category: string }).category),
).toEqual(['app.step']);
});

it('a report built DURING a blackout carries no viewtree; one built after it does', async () => {
const transport = uploadTransport();
const doc = fakeDocument(viewEl('body', { children: [viewEl('main')] }));
const client = await launchTracked(
'tok',
baseOptions({ transport, captureStore: memStore(), document: doc }),
);
const bundleFiles = (n: number): Record<string, Uint8Array> => {
const puts = transport.mock.calls.filter(([url]) => url === 'https://s3.test/put');
return unzipSync((puts[n]?.[1] as HttpRequestOptions).body as Uint8Array);
};
client.startBlackout();
await client.upload({ summary: 'during' });
expect(bundleFiles(0)['viewtree.json']).toBeUndefined();
client.endBlackout();
await client.upload({ summary: 'after' });
expect(bundleFiles(1)['viewtree.json']).toBeDefined();
});

it('hands the replay recorder the client blackout gate so it follows the blackout', async () => {
const client = await launchTracked(
'tok',
baseOptions({ document: fakeDocument(viewEl('body')) }),
);
await vi.waitFor(() => expect(registerReplay).toHaveBeenCalledTimes(1));
const options = registerReplay.mock.calls[0]?.[2] as {
blackout: { isActive(): boolean };
};
expect(options.blackout.isActive()).toBe(false);
client.startBlackout();
expect(options.blackout.isActive()).toBe(true);
});
});

it('omits the viewtree when captureViewHierarchy is disabled', async () => {
const transport = uploadTransport();
const doc = fakeDocument(viewEl('body'));
Expand Down Expand Up @@ -1756,7 +1864,7 @@ describe('launch — session replay (lazy)', () => {
'function',
);
expect(call[1]).toEqual({}); // the shared fileEncoders map (replay writes its encoder into it)
expect(call[2]).toEqual({}); // no caller overrides → replay's own fail-closed defaults apply
expect(call[2]).toEqual({ blackout: expect.any(Object) }); // no caller overrides → replay's own fail-closed defaults apply
});

it('records when the document arrives via window.document (the real-browser path)', async () => {
Expand Down Expand Up @@ -1803,7 +1911,11 @@ describe('launch — session replay (lazy)', () => {
'function',
);
expect(call[1]).toEqual({}); // the shared fileEncoders map (replay writes its encoder into it)
expect(call[2]).toEqual({ maskAllText: false, checkoutEveryNms: 5000 }); // options forwarded
expect(call[2]).toEqual({
maskAllText: false,
checkoutEveryNms: 5000,
blackout: expect.any(Object),
}); // options forwarded
});

it('forwards the launch onError so a dropped masking selector is actually reported', async () => {
Expand All @@ -1818,7 +1930,7 @@ describe('launch — session replay (lazy)', () => {
it('enables replay with default options when replay is `true`', async () => {
await launchTracked('tok', domReplayOptions({ replay: true }));
await vi.waitFor(() => expect(registerReplay).toHaveBeenCalledTimes(1));
expect(registerReplay.mock.calls[0]?.[2]).toEqual({}); // no options object → {}
expect(registerReplay.mock.calls[0]?.[2]).toEqual({ blackout: expect.any(Object) }); // no options object
});

// `replay: false` is now THE errors-only path — it inherits the guarantee the old default carried: no
Expand Down
29 changes: 24 additions & 5 deletions packages/browser/src/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type TraceSample,
} from '@bugsee/capture';
import {
BlackoutToken,
BUGSEE_SDK_VERSION,
type BugseeApi,
type BugseeClient,
Expand Down Expand Up @@ -486,8 +487,16 @@ export async function launchCore(
// View hierarchy: a DOM snapshot PULLED at report assembly → a `viewtree` entry (browser/DOM-only,
// gated). The source self-noops where there is no DOM, so it is safe to register unconditionally when on.
const captureViewtree = resolved.options.get(BugseeOption.CaptureViewHierarchy, true);
let clientRef: BugseeClient | undefined;
const reportSnapshots = captureViewtree
? [createViewtreeSnapshotSource({ document: domDocument })]
? [
createViewtreeSnapshotSource({
document: domDocument,
// The source is built before the client (it is an input to it), so it reads the client's blackout
// through a late-bound reference. A report cannot be assembled before the client exists.
blackout: { isActive: () => clientRef?.isBlackout() === true },
}),
]
: undefined;

// Session replay (lazy). When enabled, a shared fileEncoders map is threaded into the client's report
Expand Down Expand Up @@ -539,6 +548,8 @@ export async function launchCore(
...(options.onError !== undefined ? { onError: options.onError } : {}),
});

clientRef = client;

// Run every contributed service manifest against the now-populated container.
for (const manifest of getServiceManifests(carrier)) {
manifest(client);
Expand Down Expand Up @@ -579,7 +590,8 @@ export async function launchCore(
() => createBrowserInputSource({ target: domDocument }),
carrier,
);
client.addCaptureProvider(createInputProvider(inputSource));
const blackout = client.getService(BlackoutToken);
client.addCaptureProvider(createInputProvider(inputSource, { blackout }));
Comment thread
cursor[bot] marked this conversation as resolved.
// The FRAME those coordinates belong to, over time → the `video.aux` stream. Without it a consumer has
// to guess the frame from `environment.hardware.screen` — the whole monitor, captured once at launch,
// in CSS px, so it shrinks under page zoom while `clientX` does not, and the click renders off the
Expand All @@ -602,9 +614,13 @@ export async function launchCore(
carrier,
);
client.addCaptureProvider(
createUiBreadcrumbProvider(uiBreadcrumbSource, (crumb) => {
client.addBreadcrumb(crumb);
}),
createUiBreadcrumbProvider(
uiBreadcrumbSource,
(crumb) => {
client.addBreadcrumb(crumb);
},
blackout,
),
);

// Session replay: lazy-`import()` @bugsee/replay unless it was opted OUT (`replay: false`) or this host
Expand All @@ -629,6 +645,9 @@ export async function launchCore(
: undefined;
m.registerReplay(client, fileEncoders, {
...replayMasking,
// The recorder follows the client's blackout: stopped for its whole span, restarted (a fresh full
// snapshot) at the end — and never started at all if a blackout began before this chunk loaded.
blackout: client.getService(BlackoutToken),
...(canvas !== undefined ? { canvas } : {}),
// Give replay's masking resolver a sink: it drops an invalid caller selector rather than letting
// it disable privacy page-wide, and that downgrade must not be silent (Wave 1.4).
Expand Down
25 changes: 24 additions & 1 deletion packages/browser/src/ui-breadcrumb-source.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { BreadcrumbInput, CaptureProviderInit, OptionsContainer } from '@bugsee/core';
import {
type BreadcrumbInput,
type CaptureProviderInit,
createBlackoutState,
type OptionsContainer,
} from '@bugsee/core';
import { BugseeOption } from '@bugsee/protocol';
import { describe, expect, it, vi } from 'vitest';
import {
Expand Down Expand Up @@ -329,6 +334,24 @@ describe('createUiBreadcrumbProvider', () => {
expect(sink).toHaveBeenCalledTimes(1); // still 1 — stop() really unsubscribed
});

it('drops breadcrumbs while a blackout is in force and resumes when it ends', () => {
const target = fakeTarget();
const source = createUiBreadcrumbSource({ target, maskSelector: MASK, now: () => 3 });
const sink = vi.fn();
const blackout = createBlackoutState();
const provider = createUiBreadcrumbProvider(source, sink, blackout);
provider.init(init);
provider.start(noOptions);
const fire = (id: string): void => target.emit('change', { target: el({ tag: 'input', id }) });

fire('a');
blackout.start();
fire('b');
blackout.end();
fire('c');
expect(sink.mock.calls.map(([crumb]) => crumb.data['view.id'])).toEqual(['a', 'c']);
});

it('tolerates a stop() without a start() (idempotent teardown)', () => {
const provider = createUiBreadcrumbProvider(createUiBreadcrumbSource({}), () => {});
provider.init(init);
Expand Down
19 changes: 16 additions & 3 deletions packages/browser/src/ui-breadcrumb-source.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type BlackoutGate,
type BreadcrumbInput,
type CaptureProvider,
CaptureProviderBase,
Expand Down Expand Up @@ -249,16 +250,27 @@ class UiBreadcrumbProvider extends CaptureProviderBase {
readonly controllingOption = BugseeOption.CaptureInteractions;
readonly #source: UiBreadcrumbSource;
readonly #sink: (breadcrumb: BreadcrumbInput) => void;
readonly #blackout: BlackoutGate | undefined;
#off: (() => void) | null = null;

constructor(source: UiBreadcrumbSource, sink: (breadcrumb: BreadcrumbInput) => void) {
constructor(
source: UiBreadcrumbSource,
sink: (breadcrumb: BreadcrumbInput) => void,
blackout?: BlackoutGate,
) {
super();
this.#source = source;
this.#sink = sink;
this.#blackout = blackout;
}

protected onStart(): void {
this.#off = this.#source.on('breadcrumb', (crumb) => this.#sink(crumb));
this.#off = this.#source.on('breadcrumb', (crumb) => {
// Blackout: interaction breadcrumbs (keyboard/focus/change/submit) are visual capture — dropped.
if (this.#blackout?.isActive() !== true) {
this.#sink(crumb);
}
});
}

protected override onStop(): void {
Expand All @@ -270,6 +282,7 @@ class UiBreadcrumbProvider extends CaptureProviderBase {
export function createUiBreadcrumbProvider(
source: UiBreadcrumbSource,
sink: (breadcrumb: BreadcrumbInput) => void,
blackout?: BlackoutGate,
): CaptureProvider {
return new UiBreadcrumbProvider(source, sink);
return new UiBreadcrumbProvider(source, sink, blackout);
}
Loading
Loading