From 8a59c9b8ac687706f1ad27242e75666f52864860 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 12 Oct 2026 08:09:01 +0500 Subject: [PATCH 1/2] feat(core,browser): blackout (startBlackout/endBlackout/isBlackout) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core owns a tiny idempotent (not counted) BlackoutState, exposed through the service container (BlackoutToken) with an internal onChange subscription for the upcoming BlackoutStarted/BlackoutEnded lifecycle events. Visual providers take a read-only BlackoutGate: the rrweb recorder is stopped for the window and restarted (fresh full snapshot) at the end, input presses, ui.* breadcrumbs, ui.interaction spans and the report-time viewtree are suppressed. Logs, network, traces and events continue. Real-Chromium e2e proves a DOM change made during a blackout is absent from the report's replay. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) --- .../browser/src/interaction-source.test.ts | 19 ++ packages/browser/src/interaction-source.ts | 9 +- packages/browser/src/launch.test.ts | 118 +++++++- packages/browser/src/launch.ts | 29 +- .../browser/src/ui-breadcrumb-source.test.ts | 25 +- packages/browser/src/ui-breadcrumb-source.ts | 19 +- packages/browser/src/viewtree.test.ts | 23 +- packages/browser/src/viewtree.ts | 8 +- packages/bugsee/src/launch.test.ts | 16 ++ packages/bugsee/src/launch.ts | 4 +- packages/capture/src/input-provider.test.ts | 32 +++ packages/capture/src/input-provider.ts | 12 + packages/core/src/blackout.test.ts | 115 ++++++++ packages/core/src/blackout.ts | 68 +++++ packages/core/src/client.test.ts | 58 ++++ packages/core/src/client.ts | 30 ++ packages/core/src/index.ts | 6 + .../test/blackout.e2e.ts | 259 ++++++++++++++++++ packages/replay/src/recorder.test.ts | 114 +++++++- packages/replay/src/recorder.ts | 68 ++++- packages/replay/src/register.test.ts | 20 +- packages/replay/src/register.ts | 7 +- 22 files changed, 1033 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/blackout.test.ts create mode 100644 packages/core/src/blackout.ts create mode 100644 packages/instrumentation-tests/test/blackout.e2e.ts diff --git a/packages/browser/src/interaction-source.test.ts b/packages/browser/src/interaction-source.test.ts index 91fec5a5..9a767b25 100644 --- a/packages/browser/src/interaction-source.test.ts +++ b/packages/browser/src/interaction-source.test.ts @@ -1,3 +1,4 @@ +import { createBlackoutState } from '@bugsee/core'; import { describe, expect, it } from 'vitest'; import { createBrowserInteractionSource, @@ -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]); + }); }); diff --git a/packages/browser/src/interaction-source.ts b/packages/browser/src/interaction-source.ts index ecc06c57..c7d6f5ed 100644 --- a/packages/browser/src/interaction-source.ts +++ b/packages/browser/src/interaction-source.ts @@ -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'; @@ -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; @@ -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 @@ -91,6 +94,7 @@ 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 { @@ -98,6 +102,8 @@ class BrowserInteractionSource extends InterceptorBase<{ interact: InteractionDe 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; const target = targetLabel(entry.target, this.#mask); const component = componentNameFromElement(entry.target); // D2: nearest data-bugsee-component this.emit('interact', { @@ -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 } : {}), }); } diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index f1677fbd..d2574b31 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -705,6 +705,114 @@ describe('launch', () => { ]); }); + describe('blackout', () => { + const press = (doc: ReturnType, 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 => { + 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')); @@ -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 () => { @@ -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 () => { @@ -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 diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 15caa2e2..3bb88c06 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -18,6 +18,7 @@ import { type TraceSample, } from '@bugsee/capture'; import { + BlackoutToken, BUGSEE_SDK_VERSION, type BugseeApi, type BugseeClient, @@ -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 @@ -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); @@ -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 })); // 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 @@ -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 @@ -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). diff --git a/packages/browser/src/ui-breadcrumb-source.test.ts b/packages/browser/src/ui-breadcrumb-source.test.ts index e8a1b069..2a5868e7 100644 --- a/packages/browser/src/ui-breadcrumb-source.test.ts +++ b/packages/browser/src/ui-breadcrumb-source.test.ts @@ -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 { @@ -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); diff --git a/packages/browser/src/ui-breadcrumb-source.ts b/packages/browser/src/ui-breadcrumb-source.ts index 10ac9c60..b33959c0 100644 --- a/packages/browser/src/ui-breadcrumb-source.ts +++ b/packages/browser/src/ui-breadcrumb-source.ts @@ -1,4 +1,5 @@ import { + type BlackoutGate, type BreadcrumbInput, type CaptureProvider, CaptureProviderBase, @@ -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 { @@ -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); } diff --git a/packages/browser/src/viewtree.test.ts b/packages/browser/src/viewtree.test.ts index 84eb46d1..1a131639 100644 --- a/packages/browser/src/viewtree.test.ts +++ b/packages/browser/src/viewtree.test.ts @@ -1,4 +1,4 @@ -import type { CaptureDataEntry } from '@bugsee/core'; +import { type CaptureDataEntry, createBlackoutState } from '@bugsee/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { createDomSnapshot, @@ -213,4 +213,25 @@ describe('createViewtreeSnapshotSource', () => { const source = createViewtreeSnapshotSource({ document: {} }); expect(source(1)).toEqual([]); }); + + it('produces no entry โ€” and never reads the DOM โ€” while a blackout is in force', () => { + const blackout = createBlackoutState(); + const body = el({ tag: 'body' }); + const read = vi.fn(() => body); + const source = createViewtreeSnapshotSource({ + document: { + get body() { + return read(); + }, + }, + blackout, + }); + expect(source(1)).toHaveLength(1); + read.mockClear(); + blackout.start(); + expect(source(2)).toEqual([]); + expect(read).not.toHaveBeenCalled(); + blackout.end(); + expect(source(3)).toHaveLength(1); + }); }); diff --git a/packages/browser/src/viewtree.ts b/packages/browser/src/viewtree.ts index c15487eb..15b5fd43 100644 --- a/packages/browser/src/viewtree.ts +++ b/packages/browser/src/viewtree.ts @@ -1,4 +1,4 @@ -import { CaptureDataEntryBase, type ReportSnapshotSource } from '@bugsee/core'; +import { type BlackoutGate, CaptureDataEntryBase, type ReportSnapshotSource } from '@bugsee/core'; import { describeTarget } from './input-source'; // Browser VIEW HIERARCHY (Android viewtree parity). A DOM-tree snapshot taken AT REPORT TIME (the @@ -43,6 +43,8 @@ export interface DomSnapshotEnv { maxNodes?: number; /** Max tree depth captured. Default 32. */ maxDepth?: number; + /** The blackout gate. {@link createViewtreeSnapshotSource} yields NOTHING (and never reads the DOM) while active. */ + blackout?: Pick; } const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template']); @@ -124,6 +126,10 @@ export function createDomSnapshot(env: DomSnapshotEnv = {}): () => ViewNode | un export function createViewtreeSnapshotSource(env?: DomSnapshotEnv): ReportSnapshotSource { const snapshot = createDomSnapshot(env); return (now: number) => { + // Blackout: the view hierarchy is visual capture. A report built while it is in force carries none. + if (env?.blackout?.isActive() === true) { + return []; + } const tree = snapshot(); return tree !== undefined ? [new CaptureDataEntryBase('viewtree', now, tree)] : []; }; diff --git a/packages/bugsee/src/launch.test.ts b/packages/bugsee/src/launch.test.ts index 50dd9e43..d60a4b4e 100644 --- a/packages/bugsee/src/launch.test.ts +++ b/packages/bugsee/src/launch.test.ts @@ -260,6 +260,22 @@ describe('bugsee umbrella launch', () => { expect(active.getAttributes()['ui.interaction_type']).toBe('click'); }); + it('opens no `ui.interaction` transaction during a blackout', async () => { + const { pushEvent } = stubPerformanceObserver(); + vi.stubGlobal('window', { addEventListener: () => {}, removeEventListener: () => {} }); + const client = await track(await launch('tok', base({ carrier: {} }))); + client.startBlackout(); + pushEvent({ name: 'click', duration: 90, interactionId: 5, target: { tagName: 'BUTTON' } }); + expect((client.ext('performance').getActiveSpan() as Transaction).getOperation()).toBe( + 'pageload', + ); + client.endBlackout(); + pushEvent({ name: 'click', duration: 90, interactionId: 6, target: { tagName: 'BUTTON' } }); + expect((client.ext('performance').getActiveSpan() as Transaction).getOperation()).toBe( + 'ui.interaction', + ); + }); + it('does NOT wire interactions when traceInteractions is false', async () => { const { pushEvent } = stubPerformanceObserver(); vi.stubGlobal('window', { addEventListener: () => {}, removeEventListener: () => {} }); diff --git a/packages/bugsee/src/launch.ts b/packages/bugsee/src/launch.ts index 2b64590a..d909f114 100644 --- a/packages/bugsee/src/launch.ts +++ b/packages/bugsee/src/launch.ts @@ -6,6 +6,7 @@ import { launchCore, readMetaTraceContinuation, } from '@bugsee/browser'; +import { BlackoutToken } from '@bugsee/core'; import { type UmbrellaExtensionOptions, wireUmbrella } from './wire'; // The `bugsee` umbrella launch() for the BROWSER โ€” the batteries-included entry. It runs the browser @@ -35,7 +36,8 @@ export async function launch( { pageload: true }, { createNavigationSource: createBrowserNavigationSource, - createInteractionSource: createBrowserInteractionSource, + createInteractionSource: () => + createBrowserInteractionSource({ blackout: client.getService(BlackoutToken) }), readMetaTraceContinuation, }, ); diff --git a/packages/capture/src/input-provider.test.ts b/packages/capture/src/input-provider.test.ts index 3f7a4779..83c054c6 100644 --- a/packages/capture/src/input-provider.test.ts +++ b/packages/capture/src/input-provider.test.ts @@ -1,6 +1,7 @@ import { type CaptureProviderInit, type CaptureStore, + createBlackoutState, createCaptureAggregator, createCaptureCoordinator, createCaptureExporter, @@ -142,4 +143,35 @@ describe('createInputProvider', () => { offSource.emit('input', { type: 'begin' }); expect((await createCaptureExporter(offStore).drain()).size).toBe(0); }); + + describe('blackout', () => { + it('drops input while a blackout is in force and resumes when it ends', async () => { + const store = mkStore(); + const source = mkSource(); + const blackout = createBlackoutState(); + const p = createInputProvider(source, { now: () => 5, blackout }); + p.init(buildInit(store)); + p.start(options); + source.emit('input', { type: 'begin', x: 1 }); + blackout.start(); + source.emit('input', { type: 'begin', x: 2 }); + source.emit('input', { type: 'end', x: 3 }); + blackout.end(); + source.emit('input', { type: 'begin', x: 4 }); + const entries = await drainInput(store); + expect(entries?.map((e) => (e.data as { x: number }).x)).toStrictEqual([1, 4]); + }); + + it('honours a blackout that was already active when the provider started', async () => { + const store = mkStore(); + const source = mkSource(); + const blackout = createBlackoutState(); + blackout.start(); + const p = createInputProvider(source, { now: () => 5, blackout }); + p.init(buildInit(store)); + p.start(options); + source.emit('input', { type: 'begin', x: 9 }); + expect(await drainInput(store)).toBeUndefined(); + }); + }); }); diff --git a/packages/capture/src/input-provider.ts b/packages/capture/src/input-provider.ts index bd5bbc5c..fe1df268 100644 --- a/packages/capture/src/input-provider.ts +++ b/packages/capture/src/input-provider.ts @@ -1,4 +1,5 @@ import { + type BlackoutGate, type CaptureProvider, CaptureProviderBase, type EventSubscribable, @@ -33,6 +34,12 @@ export type InputSource = EventSubscribable<{ input: InputEventDetail }>; export interface InputProviderOptions { /** Wall-clock source for the entry timestamp; injectable for tests. Default Date.now. */ now?: () => number; + /** + * The blackout gate. While a blackout is in force every input event is dropped (interaction capture is + * visual capture โ€” see `startBlackout`). Read per event, so a blackout already active at start, or + * begun at any later point, is honoured with no subscription to leak. + */ + blackout?: BlackoutGate; } class InputProvider extends CaptureProviderBase { @@ -40,16 +47,21 @@ class InputProvider extends CaptureProviderBase { readonly controllingOption = BugseeOption.CaptureInteractions; readonly #source: InputSource; readonly #now: () => number; + readonly #blackout: BlackoutGate | undefined; #off: (() => void) | null = null; constructor(source: InputSource, options: InputProviderOptions = {}) { super(); this.#source = source; this.#now = options.now ?? (() => Date.now()); + this.#blackout = options.blackout; } protected onStart(): void { this.#off = this.#source.on('input', (event) => { + if (this.#blackout?.isActive() === true) { + return; // blackout: interactions are not recorded + } const timestamp = event.timestamp ?? this.#now(); // `timestamp` written LAST so the resolved value always wins, including over a detail that // carries an explicit `timestamp: undefined` (which the spread would otherwise reinstate). diff --git a/packages/core/src/blackout.test.ts b/packages/core/src/blackout.test.ts new file mode 100644 index 00000000..d5737884 --- /dev/null +++ b/packages/core/src/blackout.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createBlackoutState } from './blackout'; + +describe('createBlackoutState', () => { + it('starts inactive', () => { + expect(createBlackoutState().isActive()).toBe(false); + }); + + it('start() activates and end() deactivates', () => { + const state = createBlackoutState(); + state.start(); + expect(state.isActive()).toBe(true); + state.end(); + expect(state.isActive()).toBe(false); + }); + + it('is idempotent and NOT counted: one end() clears any number of start()s', () => { + const state = createBlackoutState(); + const listener = vi.fn(); + state.onChange(listener); + state.start(); + state.start(); + state.start(); + expect(listener).toHaveBeenCalledTimes(1); + state.end(); + expect(state.isActive()).toBe(false); + state.end(); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it('end() without a start() is a silent no-op', () => { + const state = createBlackoutState(); + const listener = vi.fn(); + state.onChange(listener); + state.end(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('notifies listeners with the new state, in subscription order', () => { + const state = createBlackoutState(); + const seen: string[] = []; + state.onChange((active) => seen.push(`a:${active}`)); + state.onChange((active) => seen.push(`b:${active}`)); + state.start(); + state.end(); + expect(seen).toEqual(['a:true', 'b:true', 'a:false', 'b:false']); + }); + + it('the state is already updated when a listener runs', () => { + const state = createBlackoutState(); + const observed: boolean[] = []; + state.onChange(() => observed.push(state.isActive())); + state.start(); + state.end(); + expect(observed).toEqual([true, false]); + }); + + it('unsubscribe stops notifications and is itself idempotent', () => { + const state = createBlackoutState(); + const listener = vi.fn(); + const off = state.onChange(listener); + off(); + off(); + state.start(); + expect(listener).not.toHaveBeenCalled(); + }); + + it('a throwing listener goes to onError and never blocks the others or the toggle', () => { + const onError = vi.fn(); + const state = createBlackoutState(onError); + const boom = new Error('boom'); + const after = vi.fn(); + state.onChange(() => { + throw boom; + }); + state.onChange(after); + expect(() => state.start()).not.toThrow(); + expect(state.isActive()).toBe(true); + expect(onError).toHaveBeenCalledWith(boom); + expect(after).toHaveBeenCalledWith(true); + }); + + it('works without an onError sink', () => { + const state = createBlackoutState(); + state.onChange(() => { + throw new Error('x'); + }); + expect(() => state.start()).not.toThrow(); + }); + + it('a listener subscribed during dispatch only hears the NEXT change', () => { + const state = createBlackoutState(); + const late = vi.fn(); + let subscribed = false; + state.onChange(() => { + if (!subscribed) { + subscribed = true; + state.onChange(late); + } + }); + state.start(); + expect(late).not.toHaveBeenCalled(); + state.end(); + expect(late).toHaveBeenCalledWith(false); + }); + + it('a listener unsubscribing during dispatch does not skip the next listener', () => { + const state = createBlackoutState(); + const second = vi.fn(); + const off = state.onChange(() => off()); + state.onChange(second); + state.start(); + expect(second).toHaveBeenCalledWith(true); + }); +}); diff --git a/packages/core/src/blackout.ts b/packages/core/src/blackout.ts new file mode 100644 index 00000000..6ee7b778 --- /dev/null +++ b/packages/core/src/blackout.ts @@ -0,0 +1,68 @@ +import { serviceToken } from '@bugsee/service'; + +// BLACKOUT state (mobile `Bugsee.startBlackout/endBlackout/isBlackout`, iOS Bugsee.h / Android Bugsee.java). +// While active, every VISUAL / INTERACTION capture source is suppressed (replay/video, touches and +// gestures, keyboard/focus breadcrumbs, the view hierarchy); logs, network, traces and events carry on. +// +// The kernel owns only this tiny boolean and its change subscription. It knows nothing about WHICH +// providers are visual: each visual provider is handed the read-only {@link BlackoutGate} and decides for +// itself (pub/sub, not piercing the core). Idempotent and NOT counted โ€” matches mobile, where one +// `endBlackout()` ends any number of `startBlackout()`s. + +/** The read-only view of the blackout state a capture provider is handed. */ +export interface BlackoutGate { + /** Whether a blackout is currently in force. */ + isActive(): boolean; + /** + * Subscribe to changes; the listener receives the NEW state (true = blackout began). Fires only on a + * real transition. Returns an unsubscribe function. + */ + onChange(listener: (active: boolean) => void): () => void; +} + +/** The mutable state the Client drives. */ +export interface BlackoutState extends BlackoutGate { + /** Begin a blackout (no-op when already active). */ + start(): void; + /** End the blackout (no-op when not active). */ + end(): void; +} + +/** The blackout state's identity in the internal container (resolve with `client.getService`). */ +export const BlackoutToken = serviceToken('blackout'); + +/** + * Create the blackout state. A throwing listener is isolated to `onError` so one misbehaving subscriber + * can neither block the others nor leave the state half-toggled. + */ +export function createBlackoutState(onError?: (error: unknown) => void): BlackoutState { + let active = false; + const listeners = new Set<(active: boolean) => void>(); + + const set = (next: boolean): void => { + if (active === next) { + return; + } + active = next; + // Snapshot: a listener that unsubscribes (or subscribes) mid-dispatch must not disturb this round. + for (const listener of [...listeners]) { + try { + listener(next); + } catch (error) { + onError?.(error); + } + } + }; + + return { + isActive: () => active, + start: () => set(true), + end: () => set(false), + onChange(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/packages/core/src/client.test.ts b/packages/core/src/client.test.ts index 411f7b90..23f330e1 100644 --- a/packages/core/src/client.test.ts +++ b/packages/core/src/client.test.ts @@ -2,6 +2,7 @@ import type { EnvironmentEnvelope, FileType } from '@bugsee/protocol'; import { createServiceContainer, defineService, serviceToken } from '@bugsee/service'; import { strFromU8, unzipSync } from '@bugsee/util'; import { describe, expect, it, vi } from 'vitest'; +import { BlackoutToken } from './blackout'; import { CaptureDataEntryBase } from './capture-data-entry'; import { createCaptureExporter } from './capture-exporter'; import { createClient, type Scheduler, SchedulerToken } from './client'; @@ -2300,3 +2301,60 @@ describe('createClient โ€” prepareUpload (capture frozen at preparation)', () => expect(enqueue).toHaveBeenCalledTimes(1); }); }); + +describe('createClient โ€” blackout', () => { + it('starts not blacked out', () => { + expect(createClient().isBlackout()).toBe(false); + }); + + it('startBlackout()/endBlackout() toggle isBlackout()', () => { + const client = createClient(); + client.startBlackout(); + expect(client.isBlackout()).toBe(true); + client.endBlackout(); + expect(client.isBlackout()).toBe(false); + }); + + it('is idempotent and not counted (one end clears any number of starts)', () => { + const client = createClient(); + client.startBlackout(); + client.startBlackout(); + client.endBlackout(); + expect(client.isBlackout()).toBe(false); + }); + + it('publishes the state through the container so providers can subscribe (internal onBlackoutChange)', () => { + const client = createClient(); + const seen: boolean[] = []; + client.getService(BlackoutToken).onChange((active) => seen.push(active)); + client.startBlackout(); + client.startBlackout(); + client.endBlackout(); + expect(seen).toEqual([true, false]); + }); + + it('routes a throwing subscriber to onError without breaking the toggle', () => { + const onError = vi.fn(); + const client = createClient({ onError }); + const boom = new Error('boom'); + client.getService(BlackoutToken).onChange(() => { + throw boom; + }); + client.startBlackout(); + expect(client.isBlackout()).toBe(true); + expect(onError).toHaveBeenCalledWith(boom); + }); + + it('does not suppress non-visual capture (logs/events/traces continue)', async () => { + const store = createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }); + const client = createClient({ captureStore: store }); + client.startBlackout(); + client.log('still logged'); + client.event('still-evented'); + client.trace('still-traced', 1); + const drained = await createCaptureExporter(store).drain(); + expect(drained.get('log')).toHaveLength(1); + expect(drained.get('events.user')).toHaveLength(1); + expect(drained.get('traces.user')).toHaveLength(1); + }); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 5baad255..d0a7eb5a 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -21,6 +21,7 @@ import type { SeverityName, } from '@bugsee/types'; import { isThenable } from '@bugsee/util'; +import { type BlackoutState, BlackoutToken, createBlackoutState } from './blackout'; import { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; import { createCaptureAggregator } from './capture-aggregator'; import { createCaptureCoordinator, type OptionGate } from './capture-coordinator'; @@ -238,6 +239,18 @@ export interface BugseeClient extends Client, ServiceResolver, ServiceRegistrar */ prepareUpload(): PreparedUpload | null; + // Blackout (mobile `startBlackout`/`endBlackout`/`isBlackout`). + /** + * Suppress ALL visual / interaction capture (session replay, input presses, `ui.*` interaction + * breadcrumbs, the report-time view hierarchy) until {@link endBlackout}. Logs, network, traces and + * events continue. Idempotent and NOT counted: one `endBlackout()` ends any number of calls, as on mobile. + */ + startBlackout(): void; + /** End a blackout; visual capture resumes (replay with a fresh full snapshot). No-op when not active. */ + endBlackout(): void; + /** Whether a blackout is currently in force. */ + isBlackout(): boolean; + // Lifecycle (ยง7.1). /** Start capture + detection. Idempotent โ€” a second call while launched is ignored. */ launch(): void; @@ -396,6 +409,11 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { // via getService(CaptureStoreToken), alongside transport/filters (DI Phase 3). services.addService(defineService(CaptureStoreToken, () => captureStore)); services.addService(defineService(ClockToken, () => clock)); + // The blackout state: visual providers read it (and subscribe to its changes) via the container, so the + // kernel never has to know which of them are visual. `client.getService(BlackoutToken).onChange` is the + // internal subscription the lifecycle events (BlackoutStarted/Ended) hang off. + const blackout: BlackoutState = createBlackoutState(onErrorSafe); + services.addService(defineService(BlackoutToken, () => blackout)); // The active-request context provider (framework adapters). When injected, the aggregator stamps each // entry's correlation ids and the provider is resolvable process-wide; absent โ†’ no-op. const contextProvider = options.contextProvider; @@ -951,6 +969,18 @@ export function createClient(options: CreateClientOptions = {}): BugseeClient { return launched; }, + startBlackout(): void { + blackout.start(); + }, + + endBlackout(): void { + blackout.end(); + }, + + isBlackout(): boolean { + return blackout.isActive(); + }, + launch(): void { // A killed client (collector KILL_SDK) is permanently dead: re-launching must not re-arm it. if (killed || launched) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 22821791..e806d54b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,12 @@ export { serviceToken, } from '@bugsee/service'; export type { AttributeValue } from '@bugsee/types'; +export { + type BlackoutGate, + type BlackoutState, + BlackoutToken, + createBlackoutState, +} from './blackout'; export { type BugseeApiOptions, createBugseeApi } from './bugsee-api'; export { assembleBundle, type BundleAssemblyContext } from './bundle-assembler'; export { createBundleUploader } from './bundle-uploader'; diff --git a/packages/instrumentation-tests/test/blackout.e2e.ts b/packages/instrumentation-tests/test/blackout.e2e.ts new file mode 100644 index 00000000..5373d734 --- /dev/null +++ b/packages/instrumentation-tests/test/blackout.e2e.ts @@ -0,0 +1,259 @@ +// The REAL-BROWSER e2e for `client.startBlackout()` / `endBlackout()` / `isBlackout()`. +// +// The unit suites prove each provider drops its data. What only a real Chromium + the REAL rrweb can +// prove is the property that matters: a DOM change made during a blackout is NOT in the replay.bin the +// report ships โ€” neither as a mutation event, nor in a snapshot, nor leaked by the recorder resuming. +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { + assertBundleIntegrity, + assertNoContractViolations, + type MockCollector, + type ParsedBundle, + parseBundles, + readJson, + startMockCollector, +} from '@bugsee/e2e-kit'; +import { build } from 'esbuild'; +import { gunzipSync, strFromU8 } from 'fflate'; +import { type Browser, chromium, type Page } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +// The report-dialog scenario is exactly "launch the real browser SDK and expose it on window". +const scenarioPath = fileURLToPath(new URL('../app/report-dialog-scenario.ts', import.meta.url)); + +async function bundleScenario(): Promise { + const result = await build({ + entryPoints: [scenarioPath], + bundle: true, + format: 'iife', + platform: 'browser', + target: 'chrome110', + write: false, + define: { 'process.env.NODE_ENV': '"production"' }, + }); + const out = result.outputFiles[0]; + if (out === undefined) throw new Error('esbuild produced no output'); + return out.text; +} + +const PAGE = `blackout e2e +

PRE_MARKER_ALPHA

+`; + +async function startPageServer( + script: string, +): Promise<{ url: string; close: () => Promise }> { + const server: Server = createServer((req, res) => { + if ((req.url ?? '/').startsWith('/scenario.js')) { + res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }); + res.end(script); + return; + } + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end(PAGE); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as AddressInfo).port; + return { + url: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolve) => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }), + }; +} + +interface RrwebEvent { + type: number; + timestamp: number; +} + +const replayOf = (bundle: ParsedBundle): { text: string; events: RrwebEvent[] } => { + const bytes = bundle.files['replay.bin']; + if (bytes === undefined) throw new Error('bundle has no replay.bin'); + const text = strFromU8(gunzipSync(bytes)); + return { text, events: JSON.parse(text) as RrwebEvent[] }; +}; + +const FULL_SNAPSHOT = 2; + +type BlackoutWindow = Window & { + __bugsee: { + startBlackout(): void; + endBlackout(): void; + isBlackout(): boolean; + log(message: string): void; + upload(options: { summary: string }): Promise; + }; +}; + +describe('blackout โ€” REAL Chromium + REAL rrweb end to end', () => { + let browser: Browser; + let script: string; + + beforeAll(async () => { + script = await bundleScenario(); + browser = await chromium.launch({ headless: true }); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + }); + + async function openPage(collector: MockCollector, serverUrl: string): Promise { + const context = await browser.newContext({ viewport: { width: 900, height: 600 } }); + const page = await context.newPage(); + await page.addInitScript( + ([collectorUrl, opts]) => { + const w = window as unknown as Record; + w.__E2E_COLLECTOR__ = collectorUrl; + w.__E2E_OPTIONS__ = opts; + }, + // maskAllText off so the markers are readable in the replay (the default is fail-closed masking, + // under which this test could not tell "blacked out" from "masked"). + [collector.url, { replay: { maskAllText: false } }] as const, + ); + await page.goto(serverUrl); + await page.waitForFunction(() => (window as unknown as { __ready?: boolean }).__ready === true); + // The replay chunk is lazy-loaded: wait for the recorder to have written its opening snapshot. + await page.waitForTimeout(600); + return page; + } + + async function sendReport( + page: Page, + collector: MockCollector, + summary: string, + ): Promise { + const before = collector.uploads.length; + await page.evaluate( + (s) => (window as unknown as BlackoutWindow).__bugsee.upload({ summary: s }), + summary, + ); + const deadline = Date.now() + 20_000; + while (collector.uploads.length <= before && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + const bundle = parseBundles(collector)[before]; + if (bundle === undefined) throw new Error(`no bundle uploaded for "${summary}"`); + assertBundleIntegrity(bundle); + return bundle; + } + + it('keeps everything done during a blackout out of the report, and resumes with a fresh snapshot', async () => { + const collector = await startMockCollector(); + const server = await startPageServer(script); + try { + const page = await openPage(collector, server.url); + const call = (name: K) => + page.evaluate( + (n) => (window as unknown as BlackoutWindow).__bugsee[n as 'startBlackout'](), + name, + ); + + // --- control: BEFORE the blackout everything is captured -------------------------------------- + await page.click('#btn'); + await page.focus('#field'); + await page.waitForTimeout(150); + + // --- the blackout ---------------------------------------------------------------------------- + await call('startBlackout'); + expect( + await page.evaluate(() => (window as unknown as BlackoutWindow).__bugsee.isBlackout()), + ).toBe(true); + await page.evaluate(() => { + const w = window as unknown as BlackoutWindow; + w.__bugsee.log('LOG_DURING_BLACKOUT'); + const pre = document.getElementById('pre') as HTMLElement; + pre.textContent = 'BLACKOUT_MUTATED_TEXT'; + const persistent = document.createElement('p'); + persistent.textContent = 'BLACKOUT_PERSISTENT_TEXT'; + document.getElementById('app')?.appendChild(persistent); + const transient = document.createElement('p'); + transient.id = 'BLACKOUT_TRANSIENT_NODE'; + transient.textContent = 'BLACKOUT_TRANSIENT_TEXT'; + document.getElementById('app')?.appendChild(transient); + }); + await page.click('#btn'); + await page.focus('#field'); + await page.keyboard.type('x'); + await page.waitForTimeout(400); // let any (wrongly) running recorder flush its mutation batch + + // A report built DURING the blackout. + const during = await sendReport(page, collector, 'during'); + const duringReplay = replayOf(during); + expect(duringReplay.text).toContain('PRE_MARKER_ALPHA'); // pre-blackout replay is intact + for (const secret of [ + 'BLACKOUT_MUTATED_TEXT', + 'BLACKOUT_PERSISTENT_TEXT', + 'BLACKOUT_TRANSIENT', + ]) { + expect(duringReplay.text).not.toContain(secret); + } + expect(during.files['viewtree.json']).toBeUndefined(); + const duringInput = + during.files['input.json'] === undefined + ? [] + : readJson<{ events: unknown[] }>(during, 'input.json').events; + const duringCrumbs = readJson>(during, 'breadcrumbs'); + // Non-visual capture carries on... + expect( + readJson>(during, 'logs.json').map((l) => l.message), + ).toContain('LOG_DURING_BLACKOUT'); + + // --- end of the blackout: the transient node is gone, the mutated text restored ------------- + await page.evaluate(() => { + document.getElementById('BLACKOUT_TRANSIENT_NODE')?.remove(); + (document.getElementById('pre') as HTMLElement).textContent = 'PRE_MARKER_ALPHA'; + }); + await call('endBlackout'); + expect( + await page.evaluate(() => (window as unknown as BlackoutWindow).__bugsee.isBlackout()), + ).toBe(false); + await page.evaluate(() => { + const post = document.createElement('p'); + post.textContent = 'POST_MARKER_OMEGA'; + document.getElementById('app')?.appendChild(post); + }); + await page.click('#btn'); + await page.waitForTimeout(400); + + const after = await sendReport(page, collector, 'after'); + const afterReplay = replayOf(after); + // Resumed recording is visible... + expect(afterReplay.text).toContain('POST_MARKER_OMEGA'); + // ...but nothing that existed only DURING the blackout is: not the intermediate text, not the node + // that was added and removed inside the window. + expect(afterReplay.text).not.toContain('BLACKOUT_MUTATED_TEXT'); + expect(afterReplay.text).not.toContain('BLACKOUT_TRANSIENT'); + // The resumed stream opens with a fresh full snapshot (a restarted recorder), so the player never + // applies post-blackout mutations onto a DOM it did not see. + const snapshots = afterReplay.events.filter((e) => e.type === FULL_SNAPSHOT); + expect(snapshots.length).toBeGreaterThanOrEqual(2); + // KNOWN, DOCUMENTED behaviour (same as mobile video after endBlackout): DOM state that PERSISTS past + // the end of the window is, by definition, on screen afterwards, so the fresh snapshot shows it. + expect(afterReplay.text).toContain('BLACKOUT_PERSISTENT_TEXT'); + + // The viewtree is back, and the interaction streams record again. + expect(after.files['viewtree.json']).toBeDefined(); + const afterInput = readJson<{ events: unknown[] }>(after, 'input.json').events; + const afterCrumbs = readJson>(after, 'breadcrumbs'); + // Control: the pre-blackout press + the post-blackout press are there; the blackout one is not, so + // the DURING report holds exactly the pre-blackout count and the AFTER report strictly more. + expect(duringInput.length).toBeGreaterThan(0); + expect(afterInput.length).toBeGreaterThan(duringInput.length); + const focusCount = (crumbs: Array<{ category: string }>): number => + crumbs.filter((c) => c.category === 'ui.focus').length; + expect(focusCount(duringCrumbs)).toBe(2); // pre-blackout: #btn then #field; none from inside + expect(focusCount(afterCrumbs)).toBe(3); // + the post-blackout #btn focus; the blackout ones never landed + assertNoContractViolations(collector); + await page.context().close(); + } finally { + await server.close(); + await collector.close(); + } + }, 90_000); +}); diff --git a/packages/replay/src/recorder.test.ts b/packages/replay/src/recorder.test.ts index 34bb1e00..4c2a7f17 100644 --- a/packages/replay/src/recorder.test.ts +++ b/packages/replay/src/recorder.test.ts @@ -1,4 +1,9 @@ -import type { CaptureDataEntry, CaptureProviderInit } from '@bugsee/core'; +import { + type BlackoutGate, + type CaptureDataEntry, + type CaptureProviderInit, + createBlackoutState, +} from '@bugsee/core'; import type { eventWithTime, recordOptions } from '@bugsee/rrweb'; import { describe, expect, it, vi } from 'vitest'; import { resolveReplayMaskingOptions } from './masking'; @@ -17,6 +22,7 @@ function fakeRecord() { stop, getOptions: () => options, emit: (event: eventWithTime) => (options?.emit as (e: eventWithTime) => void)(event), + latestEmit: () => options?.emit as (e: eventWithTime) => void, }; } @@ -101,6 +107,112 @@ describe('createReplayCaptureProvider', () => { expect(added[0]?.timestamp).toBe(2); }); + describe('blackout (privacy: fail closed)', () => { + const setup = (blackout?: BlackoutGate) => { + const rec = fakeRecord(); + const { init, added } = fakeInit(); + const provider = createReplayCaptureProvider({ + record: rec.record, + masking: resolveReplayMaskingOptions(), + ...(blackout !== undefined ? { blackout } : {}), + }); + provider.init(init); + return { rec, added, provider }; + }; + + it('STOPS the rrweb recorder for the whole blackout rather than only muting it', () => { + const { rec, provider } = setup(); + provider.start(options()); + provider.startBlackout(); + expect(rec.stop).toHaveBeenCalledTimes(1); + provider.startBlackout(); // idempotent: nothing left to stop + expect(rec.stop).toHaveBeenCalledTimes(1); + expect(rec.record).toHaveBeenCalledTimes(1); + }); + + it('resumes by RESTARTING rrweb (a fresh Meta + FullSnapshot), never by un-muting the old session', () => { + const { rec, provider } = setup(); + provider.start(options()); + provider.startBlackout(); + provider.stopBlackout(); + expect(rec.record).toHaveBeenCalledTimes(2); + provider.stopBlackout(); // not blacked out โ†’ no third session + expect(rec.record).toHaveBeenCalledTimes(2); + }); + + it('drops a LATE emit from the pre-blackout session even after the recorder restarted', () => { + const { rec, added, provider } = setup(); + provider.start(options()); + const stale = rec.latestEmit(); // e.g. a throttled scroll sample that fires after the stop + provider.startBlackout(); + stale(evt(1)); + provider.stopBlackout(); + stale(evt(2)); // the old closure stays dead after the restart + rec.emit(evt(3)); + expect(added.map((e) => e.timestamp)).toEqual([3]); + }); + + it('never records at all when a blackout was already active at start (lazy replay load)', () => { + const gate = createBlackoutState(); + gate.start(); + const { rec, added, provider } = setup(gate); + provider.start(options()); + expect(rec.record).not.toHaveBeenCalled(); + expect(added).toHaveLength(0); + }); + + it('follows the gate: pauses on start, restarts on end, and ignores a repeated notification', () => { + const gate = createBlackoutState(); + const { rec, added, provider } = setup(gate); + provider.start(options()); + rec.emit(evt(1)); + gate.start(); + expect(rec.stop).toHaveBeenCalledTimes(1); + gate.end(); + expect(rec.record).toHaveBeenCalledTimes(2); + rec.emit(evt(2)); + expect(added.map((e) => e.timestamp)).toEqual([1, 2]); + }); + + it('starts the deferred recording when the gate ends a blackout that was active at start', () => { + const gate = createBlackoutState(); + gate.start(); + const { rec, provider } = setup(gate); + provider.start(options()); + gate.end(); + expect(rec.record).toHaveBeenCalledTimes(1); + }); + + it('stop() unsubscribes from the gate, so a stopped provider never starts recording', () => { + const real = createBlackoutState(); + const unsubscribed = vi.fn(); + const gate: BlackoutGate = { + isActive: () => real.isActive(), + onChange: (listener) => { + const off = real.onChange(listener); + return () => { + unsubscribed(); + off(); + }; + }, + }; + const { rec, provider } = setup(gate); + provider.start(options()); + provider.stop(); + expect(unsubscribed).toHaveBeenCalledTimes(1); + real.start(); + real.end(); + expect(rec.record).toHaveBeenCalledTimes(1); + }); + + it('a manual stopBlackout() on a provider that was never started does not start recording', () => { + const { rec, provider } = setup(); + provider.startBlackout(); + provider.stopBlackout(); + expect(rec.record).not.toHaveBeenCalled(); + }); + }); + it('stops the rrweb recorder on stop()', () => { const rec = fakeRecord(); const provider = createReplayCaptureProvider({ diff --git a/packages/replay/src/recorder.ts b/packages/replay/src/recorder.ts index 65bd0aa0..45e696ad 100644 --- a/packages/replay/src/recorder.ts +++ b/packages/replay/src/recorder.ts @@ -6,7 +6,7 @@ // // The `record` fn is INJECTED (the real one from `@bugsee/rrweb`, or a fake in tests), so this unit needs no // real DOM. -import { type CaptureProvider, CaptureProviderBase } from '@bugsee/core'; +import { type BlackoutGate, type CaptureProvider, CaptureProviderBase } from '@bugsee/core'; import type { eventWithTime, listenerHandler, recordOptions } from '@bugsee/rrweb'; import type { ResolvedReplayMasking } from './masking'; @@ -36,13 +36,26 @@ export interface ReplayCaptureProviderOptions { checkoutEveryNms?: number; /** Opt-in canvas recording (from `@bugsee/replay-canvas`); `undefined` โ‡’ DOM-only (unchanged). */ canvas?: CanvasRecordConfig; + /** + * The client's blackout gate. The recorder follows it (pause on start, FRESH-SNAPSHOT resume on end), + * and a blackout already in force when the recorder starts means it never records at all โ€” which matters + * because replay is lazy-loaded and can resolve after the app called `startBlackout()`. + */ + blackout?: BlackoutGate; } /** A capture provider that also exposes blackout controls (wired to the client `startBlackout`). */ export interface ReplayRecorder extends CaptureProvider { - /** Pause replay capture (errors/network/logs continue). */ + /** + * Suspend replay capture (errors/network/logs continue). The rrweb recorder is STOPPED, not merely + * muted: no DOM mutation, input value or scroll is observed, buffered or computed for the whole window. + */ startBlackout(): void; - /** Resume replay capture. */ + /** + * Resume replay capture by restarting the recorder, which opens with a fresh Meta + FullSnapshot. The + * replayed stream therefore jumps from the pre-blackout DOM straight to the post-blackout DOM; the + * intermediate mutations never existed in it, so nothing recorded during the blackout can be replayed. + */ stopBlackout(): void; } @@ -52,8 +65,15 @@ class ReplayCaptureProvider extends CaptureProviderBase implements ReplayRecorde readonly #masking: ResolvedReplayMasking; readonly #checkoutEveryNms: number; readonly #canvas: CanvasRecordConfig | undefined; + readonly #blackout: BlackoutGate | undefined; #stop: listenerHandler | undefined; #blackedOut = false; + #started = false; + // Bumped whenever a recording session ends. An `emit` closure belongs to ONE session and is dead after + // it: a throttled rrweb callback that fires late from a stopped session (scroll/input sampling) must not + // land in the stream after the blackout began โ€” or after the recorder was restarted. + #session = 0; + #offBlackout: (() => void) | undefined; constructor(options: ReplayCaptureProviderOptions) { super(); @@ -61,9 +81,30 @@ class ReplayCaptureProvider extends CaptureProviderBase implements ReplayRecorde this.#masking = options.masking; this.#checkoutEveryNms = options.checkoutEveryNms ?? 60_000; this.#canvas = options.canvas; + this.#blackout = options.blackout; } protected onStart(): void { + this.#started = true; + if (this.#blackout !== undefined) { + // Read the gate BEFORE recording: a blackout begun before the lazy replay chunk resolved must never + // see even the opening snapshot. + this.#blackedOut = this.#blackout.isActive(); + this.#offBlackout = this.#blackout.onChange((active) => { + if (active) { + this.startBlackout(); + } else { + this.stopBlackout(); + } + }); + } + if (!this.#blackedOut) { + this.#begin(); + } + } + + #begin(): void { + const session = this.#session; this.#stop = this.#record({ ...this.#masking, // Opt-in canvas options (recordCanvas / sampling.canvas / dataURLOptions); nothing when DOM-only. @@ -73,25 +114,40 @@ class ReplayCaptureProvider extends CaptureProviderBase implements ReplayRecorde // blockSelector (fail-closed, RP1). recordCrossOriginIframes: false, emit: (event) => { - // Blackout pauses VISUAL capture only (design ยง401 / startBlackout). - if (!this.#blackedOut) { + // Blackout suspends VISUAL capture only (design ยง401 / startBlackout); a stale session is dead. + if (session === this.#session && !this.#blackedOut) { this.capture('replay', event.timestamp, event); } }, }); } - protected override onStop(): void { + #halt(): void { + this.#session += 1; this.#stop?.(); this.#stop = undefined; } + protected override onStop(): void { + this.#started = false; + this.#offBlackout?.(); + this.#offBlackout = undefined; + this.#halt(); + } + startBlackout(): void { this.#blackedOut = true; + this.#halt(); } stopBlackout(): void { + if (!this.#blackedOut) { + return; + } this.#blackedOut = false; + if (this.#started) { + this.#begin(); // a restarted recorder opens with a fresh full snapshot + } } } diff --git a/packages/replay/src/register.test.ts b/packages/replay/src/register.test.ts index 278faa2b..dd15713d 100644 --- a/packages/replay/src/register.test.ts +++ b/packages/replay/src/register.test.ts @@ -1,5 +1,10 @@ // @vitest-environment jsdom -- selector validation (Wave 1.4) needs a DOM to parse against -import type { CaptureDataEntry, CaptureProvider, CaptureProviderInit } from '@bugsee/core'; +import { + type CaptureDataEntry, + type CaptureProvider, + type CaptureProviderInit, + createBlackoutState, +} from '@bugsee/core'; import type { eventWithTime, recordOptions } from '@bugsee/rrweb'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { encodeReplay } from './encoder'; @@ -53,6 +58,19 @@ describe('registerReplay', () => { expect(typeof recorder.stopBlackout).toBe('function'); }); + it('threads the blackout gate into the recorder: a blackout active at registration never records', () => { + const blackout = createBlackoutState(); + blackout.start(); + const client = fakeClient(); + const fake = fakeRecord(); + mustRegister(client, {}, { record: fake.record, blackout }); + client.providers[0]?.init(fakeInit()); + client.providers[0]?.start({} as never); + expect(fake.record).not.toHaveBeenCalled(); + blackout.end(); + expect(fake.record).toHaveBeenCalledTimes(1); + }); + it('reports a dropped invalid masking selector through the caller onError', () => { // Wave 1.4. Without this wiring the resolver's report has nowhere to go, and a developer's typo'd // selector is silently downgraded โ€” which is how the original defect stayed invisible in the first place. diff --git a/packages/replay/src/register.ts b/packages/replay/src/register.ts index f3cf5046..4333b5f9 100644 --- a/packages/replay/src/register.ts +++ b/packages/replay/src/register.ts @@ -3,8 +3,8 @@ // (`client.addCaptureProvider`), and registers the `replay.bin` encoder into the client's shared // `fileEncoders` map (read at each report's bundle assembly, RP5a). The browser launch lazy-`import()`s this // module + calls `registerReplay` when the `replay` option is truthy (RP5c). Returns the recorder so the -// caller can wire `client.startBlackout` โ†’ its blackout controls. -import type { CaptureProvider } from '@bugsee/core'; +// caller can wire `client.startBlackout` โ†’ its blackout controls (or pass `blackout`, and the recorder follows it). +import type { BlackoutGate, CaptureProvider } from '@bugsee/core'; import { record as rrwebRecord } from '@bugsee/rrweb'; import { encodeReplay } from './encoder'; import { type ReplayMaskingOptions, resolveReplayMaskingOptions } from './masking'; @@ -35,6 +35,8 @@ export interface RegisterReplayOptions extends ReplayMaskingOptions { /** Internal-error sink. Today: an invalid masking selector that was dropped and escalated (Wave 1.4) โ€” * without it the downgrade is silent, which is precisely how the original fail-open survived. */ onError?: (error: unknown) => void; + /** The client's blackout gate (`client.getService(BlackoutToken)`): the recorder pauses/resumes with it. */ + blackout?: BlackoutGate; } /** @@ -74,6 +76,7 @@ export function registerReplay( ? { checkoutEveryNms: options.checkoutEveryNms } : {}), ...(options.canvas !== undefined ? { canvas: options.canvas } : {}), + ...(options.blackout !== undefined ? { blackout: options.blackout } : {}), }); client.addCaptureProvider(provider); // Register the encoder into the SHARED map the client reads at assembly time (RP5a) โ€” so the next report's From e8826c8676abe7c852f8c8cf2b1e63b53c09bdf6 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Mon, 12 Oct 2026 13:39:24 +0500 Subject: [PATCH 2/2] ci: retrigger checks and automated review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)