From 86cb8098463394100774db0511261f6db0358497 Mon Sep 17 00:00:00 2001 From: Jesse Wang Date: Wed, 11 Nov 2026 12:12:48 -0800 Subject: [PATCH] feat(amp-semantic): capture replay recording boundaries --- packages/amp-semantic/README.md | 8 ++- packages/amp-semantic/e2e/replay.spec.ts | 24 ++++++- packages/amp-semantic/schema.json | 33 +++++++++ packages/amp-semantic/src/index.ts | 5 +- .../amp-semantic/src/recorders/recording.ts | 9 +++ packages/amp-semantic/src/types.ts | 7 ++ packages/amp-semantic/test/recording.test.ts | 21 ++++++ packages/amp-semantic/test/schema.test.ts | 4 +- .../src/semantic-types.ts | 5 ++ .../session-replay-browser/src/semantic.ts | 31 +++++++- .../src/session-replay.ts | 26 ++++--- .../test/semantic-integration.test.ts | 71 ++++++++++++++++++- .../test/semantic.test.ts | 33 +++++++-- .../sr-semantic-test.html | 4 ++ .../sr-semantic-test.js | 2 +- 15 files changed, 256 insertions(+), 27 deletions(-) create mode 100644 packages/amp-semantic/src/recorders/recording.ts create mode 100644 packages/amp-semantic/test/recording.test.ts diff --git a/packages/amp-semantic/README.md b/packages/amp-semantic/README.md index 12570fe60..927e2f4c5 100644 --- a/packages/amp-semantic/README.md +++ b/packages/amp-semantic/README.md @@ -10,7 +10,7 @@ Recorders register teardown immediately with `onCleanup` when setup can fail. `c The `snapshot` host signal describes an accepted full snapshot; the runtime does not subscribe to rrweb itself. The host remains responsible for privacy-safe payloads, recording consent, transport, and any persisted tab identity. -Context, visibility, browser lifecycle, and navigation are the default recorders in this increment. Session Replay accepts this package through a locally supplied createCapture callback; capture remains disabled by default. +Context, visibility, browser lifecycle, navigation, and recording are the default recorders in this increment. Session Replay accepts this package through a locally supplied createCapture callback; capture remains disabled by default. ## Development @@ -18,7 +18,7 @@ From the repository root, install dependencies and build with Node 24, `pnpm ins ## Context recorder -`createSemanticCapture(config, host)` installs the context, visibility, lifecycle, and navigation recorders by default. The host additionally supplies `sdk`, `getPrivacy`, and optional analytics context and stylesheet-inlining configuration. Call `notify('snapshot', undefined)` after an accepted full snapshot to emit context. Starting capture alone emits nothing. +`createSemanticCapture(config, host)` installs the context, visibility, lifecycle, navigation, and recording recorders by default. The host additionally supplies `sdk`, `getPrivacy`, and optional analytics context and stylesheet-inlining configuration. Call `notify('snapshot', undefined)` after an accepted full snapshot to emit context. Starting capture alone emits nothing. `amp-semantic/context` records the effective mask level for the snapshot URL (including URL overrides), stylesheet counts, viewport, a device heuristic, language, user agent, visibility and focus snapshots, analytics SDK configuration, and dropped-event counts. The runtime stamps version, timestamp and document identity. It never captures input values or page text. @@ -45,3 +45,7 @@ The initial state is present in context. The visibility budget is 200 events per ## Browser lifecycle recorder `amp-semantic/lifecycle` records `pagehide`, `pageshow`, `pageshow_bfcache`, `freeze`, and `resume` as browser boundaries. The BFCache variant is selected when `pageshow.persisted` is true. Its payload contains only the boundary type plus version and identity. These events support deriving replay gaps and resume intervals downstream; they do not claim that a recording stopped or restarted. The listener is removed on stop. Disable this family with `families: { lifecycle: false }`. The budget is 200 events per session. + +## Recording recorder + +`amp-semantic/recording` records SDK supplied start, stop, pause, and resume boundaries with a compact reason. Start/resume follows the first accepted replay full snapshot; stop/pause is emitted before rrweb teardown so it can be uploaded with the closing sequence. No event is emitted when semantic capture is disabled or sampled out. Disable only this family with `families: { recording: false }`. The budget is 200 events per session. diff --git a/packages/amp-semantic/e2e/replay.spec.ts b/packages/amp-semantic/e2e/replay.spec.ts index ede43e627..efd829777 100644 --- a/packages/amp-semantic/e2e/replay.spec.ts +++ b/packages/amp-semantic/e2e/replay.spec.ts @@ -76,7 +76,7 @@ for (const query of [ '?enabled=false', '?sampleRate=0', '?remoteEnabled=false', - '?disabled=context,visibility,lifecycle,navigation', + '?disabled=context,visibility,lifecycle,navigation,recording', '?createCapture=false&remoteEnabled=true', ]) { test(`capture gates suppress semantic output: ${query}`, async ({ page }) => { @@ -94,10 +94,10 @@ test('stop removes listeners and resume emits fresh context without resetting do await expect .poll(async () => (await flush(page)).filter((event) => event.data.tag === 'amp-semantic/context').length) .toBe(1); - const before = (await flush(page)).length; + const before = (await flush(page)).filter((event) => event.data.tag !== 'amp-semantic/recording').length; await page.evaluate(() => window.semanticHarness.stop()); await visibility(page, 'hidden'); - expect((await flush(page)).length).toBe(before); + expect((await flush(page)).filter((event) => event.data.tag !== 'amp-semantic/recording')).toHaveLength(before); await page.evaluate(() => window.semanticHarness.start()); await expect .poll(async () => (await flush(page)).filter((e) => e.data.tag === 'amp-semantic/context').length) @@ -145,3 +145,21 @@ test('URL plugin observations produce templated navigation events', async ({ pag expect(navigation[1].data.payload.url).toContain('#/accounts/:id'); for (const event of navigation) expect(validate(event), JSON.stringify(validate.errors)).toBe(true); }); + +test('recording boundaries bracket a customer stop and resume', async ({ page }) => { + await open(page); + await expect + .poll(async () => (await flush(page)).filter((event) => event.data.tag === 'amp-semantic/recording').length) + .toBe(1); + await page.evaluate(() => window.semanticHarness.stop()); + const stopped = (await flush(page)).filter((event) => event.data.tag === 'amp-semantic/recording'); + expect(stopped.map((event) => event.data.payload.state)).toEqual(['start', 'stop']); + expect(stopped[1].data.payload.reason).toBe('customer_stop'); + await page.evaluate(() => window.semanticHarness.start()); + await expect + .poll(async () => (await flush(page)).filter((event) => event.data.tag === 'amp-semantic/recording').length) + .toBe(3); + const recording = (await flush(page)).filter((event) => event.data.tag === 'amp-semantic/recording'); + expect(recording[2].data.payload.state).toBe('resume'); + for (const event of recording) expect(validate(event), JSON.stringify(validate.errors)).toBe(true); +}); diff --git a/packages/amp-semantic/schema.json b/packages/amp-semantic/schema.json index 98990fa45..09628ab32 100644 --- a/packages/amp-semantic/schema.json +++ b/packages/amp-semantic/schema.json @@ -107,6 +107,10 @@ "navigation": { "type": "integer", "minimum": 0 + }, + "recording": { + "type": "integer", + "minimum": 0 } }, "additionalProperties": false @@ -252,6 +256,35 @@ }, "required": ["type", "timestamp", "data"], "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { "const": 5 }, + "timestamp": { "type": "number" }, + "data": { + "type": "object", + "properties": { + "tag": { "const": "amp-semantic/recording" }, + "payload": { + "type": "object", + "properties": { + "state": { "enum": ["start", "stop", "pause", "resume"] }, + "reason": { "enum": ["init", "new_session", "customer_stop", "sampled_out", "opt_out", "targeting", "focus", "error"] }, + "tab_id": { "type": "string", "minLength": 1 }, + "document_id": { "type": "string", "minLength": 1 }, + "v": { "const": 1 } + }, + "required": ["state", "reason", "v", "tab_id", "document_id"], + "additionalProperties": false + } + }, + "required": ["tag", "payload"], + "additionalProperties": false + } + }, + "required": ["type", "timestamp", "data"], + "additionalProperties": false } ] } diff --git a/packages/amp-semantic/src/index.ts b/packages/amp-semantic/src/index.ts index ac354b54f..97b0bb926 100644 --- a/packages/amp-semantic/src/index.ts +++ b/packages/amp-semantic/src/index.ts @@ -3,21 +3,24 @@ import { visibilityRecorder } from './recorders/visibility'; import { contextRecorder } from './recorders/context'; import { lifecycleRecorder } from './recorders/lifecycle'; import { navigationRecorder } from './recorders/navigation'; +import { recordingRecorder } from './recorders/recording'; import type { SemanticCaptureConfig, SemanticHost, SemanticRecorder } from './types'; export { createCapture, DEFAULT_SEMANTIC_CAP } from './capture'; -export { contextRecorder, visibilityRecorder, lifecycleRecorder, navigationRecorder }; +export { contextRecorder, visibilityRecorder, lifecycleRecorder, navigationRecorder, recordingRecorder }; export const defaultRecorders: readonly SemanticRecorder[] = [ contextRecorder, visibilityRecorder, lifecycleRecorder, navigationRecorder, + recordingRecorder, ]; export const SEMANTIC_CAPS = { context: DEFAULT_SEMANTIC_CAP, visibility: DEFAULT_SEMANTIC_CAP, lifecycle: DEFAULT_SEMANTIC_CAP, navigation: DEFAULT_SEMANTIC_CAP, + recording: DEFAULT_SEMANTIC_CAP, }; export * from './types'; export type { SemanticCapture } from './capture'; diff --git a/packages/amp-semantic/src/recorders/recording.ts b/packages/amp-semantic/src/recorders/recording.ts new file mode 100644 index 000000000..bc4f6a552 --- /dev/null +++ b/packages/amp-semantic/src/recorders/recording.ts @@ -0,0 +1,9 @@ +import type { SemanticRecorder } from '../types'; + +/** The host identifies recording boundaries; this recorder only stamps and transports them. */ +export const recordingRecorder: SemanticRecorder = { + families: ['recording'], + setup(context) { + return context.on('recording', (signal) => context.emit('recording', signal)); + }, +}; diff --git a/packages/amp-semantic/src/types.ts b/packages/amp-semantic/src/types.ts index c9e80510d..f8c74b8d6 100644 --- a/packages/amp-semantic/src/types.ts +++ b/packages/amp-semantic/src/types.ts @@ -21,6 +21,7 @@ export interface SemanticPayloads { visibility: { state: 'hidden' | 'visible' }; lifecycle: { type: 'pagehide' | 'pageshow' | 'pageshow_bfcache' | 'freeze' | 'resume' }; navigation: { url: string }; + recording: RecordingSignal; context: { sdk: string; mask: 'light' | 'medium' | 'conservative'; @@ -51,6 +52,12 @@ export interface HostSignals { snapshot: undefined; /** An accepted URL tracking plugin event, after host UGC filtering. */ url: { url: string; timestamp: number }; + /** SDK-owned recording boundary, forwarded before rrweb teardown. */ + recording: RecordingSignal; +} +export interface RecordingSignal { + state: 'start' | 'stop' | 'pause' | 'resume'; + reason: 'init' | 'new_session' | 'customer_stop' | 'sampled_out' | 'opt_out' | 'targeting' | 'focus' | 'error'; } export type Cleanup = () => void; /** The host owns transport; the runtime has no rrweb dependency. */ diff --git a/packages/amp-semantic/test/recording.test.ts b/packages/amp-semantic/test/recording.test.ts new file mode 100644 index 000000000..d743360af --- /dev/null +++ b/packages/amp-semantic/test/recording.test.ts @@ -0,0 +1,21 @@ +import { createSemanticCapture, SemanticEvent } from '../src'; +import { recordingRecorder } from '../src/recorders/recording'; + +test('forwards host recording boundaries and removes the subscription on stop', () => { + const events: SemanticEvent[] = []; + const capture = createSemanticCapture( + { enabled: true }, + { document, window, sdk: 'test', getPrivacy: () => ({}), emit: (event) => events.push(event) }, + [recordingRecorder], + ); + capture.start(); + capture.notify('recording', { state: 'start', reason: 'init' }); + capture.notify('recording', { state: 'stop', reason: 'customer_stop' }); + expect(events.map((event) => (event.name === 'recording' ? event.payload.state : undefined))).toEqual([ + 'start', + 'stop', + ]); + capture.stop(); + capture.notify('recording', { state: 'resume', reason: 'focus' }); + expect(events).toHaveLength(2); +}); diff --git a/packages/amp-semantic/test/schema.test.ts b/packages/amp-semantic/test/schema.test.ts index 1c7a836f3..6020ff943 100644 --- a/packages/amp-semantic/test/schema.test.ts +++ b/packages/amp-semantic/test/schema.test.ts @@ -28,8 +28,9 @@ test('validates shipped event families and rejects unshipped families', () => { document.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('pagehide')); capture.notify('url', { url: 'https://example.com/accounts/8812?private=hidden', timestamp: 5 }); + capture.notify('recording', { state: 'start', reason: 'init' }); capture.stop(); - expect(events.map((event) => event.name)).toEqual(['context', 'visibility', 'lifecycle', 'navigation']); + expect(events.map((event) => event.name)).toEqual(['context', 'visibility', 'lifecycle', 'navigation', 'recording']); for (const event of events) expect(validate(wire(event))).toBe(true); const invalid = wire(events[1]); invalid.data.tag = 'amp-semantic/click'; @@ -46,6 +47,7 @@ test('default capture exposes context and skips payload reads after its cap', () semantic.visibilityRecorder, semantic.lifecycleRecorder, semantic.navigationRecorder, + semantic.recordingRecorder, ]); const host = { document, window, sdk: 'test', getPrivacy: () => ({}), emit: jest.fn() }; const capture = semantic.createSemanticCapture({ enabled: true }, host); diff --git a/packages/session-replay-browser/src/semantic-types.ts b/packages/session-replay-browser/src/semantic-types.ts index 3feb865af..fa5a2fe51 100644 --- a/packages/session-replay-browser/src/semantic-types.ts +++ b/packages/session-replay-browser/src/semantic-types.ts @@ -38,6 +38,11 @@ export interface SemanticCaptureController { export interface SemanticHostSignals { snapshot: undefined; url: { url: string; timestamp: number }; + recording: SemanticRecordingSignal; +} +export interface SemanticRecordingSignal { + state: 'start' | 'stop' | 'pause' | 'resume'; + reason: 'init' | 'new_session' | 'customer_stop' | 'sampled_out' | 'opt_out' | 'targeting' | 'focus' | 'error'; } export type SemanticCaptureFactory = ( config: SemanticCaptureConfig, diff --git a/packages/session-replay-browser/src/semantic.ts b/packages/session-replay-browser/src/semantic.ts index 2d52382be..2b7197548 100644 --- a/packages/session-replay-browser/src/semantic.ts +++ b/packages/session-replay-browser/src/semantic.ts @@ -3,6 +3,7 @@ import type { SemanticCaptureController, SemanticCaptureFactory, SemanticCaptureHost, + SemanticRecordingSignal, } from './semantic-types'; /** Transport startup queue bound, independent of recorder-specific event budgets. */ @@ -16,6 +17,10 @@ export class ReplaySemanticAdapter { private readonly capture: SemanticCaptureController; private sessionId?: string | number; private ready = false; + private firstSnapshot = false; + private hasStartedInSession = false; + private startSignal: SemanticRecordingSignal = { state: 'start', reason: 'init' }; + private resumeReason: SemanticRecordingSignal['reason'] = 'focus'; private pendingSnapshots = 0; private pendingUrls = 0; private pendingEvents: DeferredObservation[] = []; @@ -27,9 +32,18 @@ export class ReplaySemanticAdapter { prepare(config: SemanticCaptureConfig, sessionId: string | number): void { this.stop(); + const newSession = this.sessionId !== sessionId; + const startReason = this.sessionId === undefined ? 'init' : newSession ? 'new_session' : this.startSignal.reason; this.config = config; this.capture.updateConfig(config); - if (this.sessionId !== sessionId) this.capture.resetSession(); + if (newSession) { + this.capture.resetSession(); + this.hasStartedInSession = false; + } + this.startSignal = { + state: this.hasStartedInSession ? 'resume' : 'start', + reason: this.hasStartedInSession ? this.resumeReason : startReason, + }; this.sessionId = sessionId; } @@ -80,11 +94,22 @@ export class ReplaySemanticAdapter { } return; } - if (this.capture.isActive()) this.capture.notify('snapshot', undefined); + if (!this.capture.isActive()) return; + if (!this.firstSnapshot) { + this.firstSnapshot = true; + this.hasStartedInSession = true; + this.capture.notify('recording', this.startSignal); + } + this.capture.notify('snapshot', undefined); } - stop(): void { + stop(reason: SemanticRecordingSignal['reason'] = 'targeting', state: 'stop' | 'pause' = 'stop'): void { + if (this.ready && this.firstSnapshot) { + this.resumeReason = reason; + this.capture.notify('recording', { state, reason }); + } this.ready = false; + this.firstSnapshot = false; this.pendingSnapshots = 0; this.pendingUrls = 0; this.pendingEvents = []; diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index 56e95a13d..0b4004c84 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -1,4 +1,5 @@ import { ReplaySemanticAdapter } from './semantic'; +import type { SemanticRecordingSignal } from './semantic-types'; import { getSemanticIdentity } from './semantic-identity'; import { createPrivacyRecorderOptions, @@ -491,7 +492,7 @@ export class SessionReplay implements AmplitudeSessionReplay { if (isSessionChange && this.config?.semanticCapture?.enabled && this.semanticAdapter) { // Keep rrweb recording through the async config/targeting pass, as it does // without semantic capture. Only detach the old session's semantic runtime. - this.useSemanticAdapter((adapter) => adapter.stop()); + this.useSemanticAdapter((adapter) => adapter.stop('new_session')); } // Drop any beacon-buffered events from the previous session BEFORE installing the @@ -1074,7 +1075,10 @@ export class SessionReplay implements AmplitudeSessionReplay { const shouldRecord = this.getShouldRecord(); const sessionId = this.identifiers?.sessionId; if (!shouldRecord || !sessionId || !config) { - if (this.semanticAdapter && this.recordCancelCallback) this.stopRecordingEvents(); + if (this.semanticAdapter && this.recordCancelCallback) + this.stopRecordingEvents( + this.shouldOptOut() ? 'opt_out' : config?.targetingConfig ? 'targeting' : 'sampled_out', + ); return; } this.stopRecordingEvents(); @@ -1174,13 +1178,17 @@ export class SessionReplay implements AmplitudeSessionReplay { config, hooks, (event: eventWithTime) => { - if (this.shouldOptOut() || !this.recordingEnabled) { + const semanticStop = + event.type === RRWebEventType.Custom && + event.data.tag === 'amp-semantic/recording' && + ['stop', 'pause'].includes((event.data.payload as { state?: string } | undefined)?.state ?? ''); + if (!semanticStop && (this.shouldOptOut() || !this.recordingEnabled)) { this.loggerProvider.log( this.shouldOptOut() ? `Opting session ${sessionId} out of recording due to optOut config.` : `Session Replay capture stopping for ${sessionId}.`, ); - this.stopRecordingEvents(); + this.stopRecordingEvents(this.shouldOptOut() ? 'opt_out' : 'customer_stop'); this.sendEvents(); return; } @@ -1219,7 +1227,7 @@ export class SessionReplay implements AmplitudeSessionReplay { void this.addCustomRRWebEvent(CustomRRwebEvent.METADATA, this.metadata); } } catch (error) { - this.useSemanticAdapter((adapter) => adapter.stop()); + this.useSemanticAdapter((adapter) => adapter.stop('error')); this.loggerProvider.warn('Failed to initialize session replay:', error); } } @@ -1403,9 +1411,9 @@ export class SessionReplay implements AmplitudeSessionReplay { } }; - stopRecordingEvents = () => { + stopRecordingEvents = (reason: SemanticRecordingSignal['reason'] = 'targeting', state: 'stop' | 'pause' = 'stop') => { try { - this.useSemanticAdapter((adapter) => adapter.stop()); + this.useSemanticAdapter((adapter) => adapter.stop(reason, state)); this.loggerProvider.log('Session Replay capture stopping.'); this.recordCancelCallback && this.recordCancelCallback(); this.recordCancelCallback = null; @@ -1446,7 +1454,7 @@ export class SessionReplay implements AmplitudeSessionReplay { this.recordingGeneration++; this.cancelHiddenSend(); this.windowFocus.stop(); - this.stopRecordingEvents(); + this.stopRecordingEvents('customer_stop'); this.sendEvents(); } @@ -1469,7 +1477,7 @@ export class SessionReplay implements AmplitudeSessionReplay { this.crossOriginParentSignalCleanup?.(); this.crossOriginParentSignalCleanup = null; this.teardownEventListeners(true); - this.stopRecordingEvents(); + this.stopRecordingEvents('customer_stop'); this.sendEvents(); } diff --git a/packages/session-replay-browser/test/semantic-integration.test.ts b/packages/session-replay-browser/test/semantic-integration.test.ts index 658243e18..1b759674c 100644 --- a/packages/session-replay-browser/test/semantic-integration.test.ts +++ b/packages/session-replay-browser/test/semantic-integration.test.ts @@ -268,6 +268,71 @@ describe('Session Replay context integration', () => { expect(stop).not.toHaveBeenCalled(); }); + test.each([ + { optOut: true, targeting: false, reason: 'opt_out' }, + { optOut: false, targeting: true, reason: 'targeting' }, + { optOut: false, targeting: false, reason: 'sampled_out' }, + ])('classifies an ineligible recording as $reason', async ({ optOut, targeting, reason }) => { + const { sdk, internal } = prepare(true); + sdk.identifiers = { sessionId: 1, deviceId: 'device' } as typeof sdk.identifiers; + sdk.recordCancelCallback = jest.fn(); + sdk.config = { ...sdk.config!, targetingConfig: targeting ? ({} as any) : undefined }; + jest.spyOn(internal, 'getShouldRecord').mockReturnValue(false); + jest.spyOn(internal, 'shouldOptOut').mockReturnValue(optOut); + const stop = jest.spyOn(sdk, 'stopRecordingEvents'); + await internal._recordEvents(); + expect(stop).toHaveBeenCalledWith(reason); + }); + + test('handles an ineligible recording before config is available', async () => { + const { sdk, internal } = prepare(true); + sdk.recordCancelCallback = jest.fn(); + sdk.config = undefined; + jest.spyOn(internal, 'getShouldRecord').mockReturnValue(false); + jest.spyOn(internal, 'shouldOptOut').mockReturnValue(false); + const stop = jest.spyOn(sdk, 'stopRecordingEvents'); + await internal._recordEvents(); + expect(stop).toHaveBeenCalledWith('sampled_out'); + }); + + test('allows terminal recording custom events through the stop guard', async () => { + const { sdk, record, internal } = prepare(true); + sdk.identifiers = { sessionId: 1, deviceId: 'device' } as typeof sdk.identifiers; + internal.recordingEnabled = true; + jest.spyOn(internal, 'getShouldRecord').mockReturnValue(true); + jest.spyOn(internal, 'getRecordFunction').mockResolvedValue(record); + jest.spyOn(internal, 'initializeNetworkObservers').mockResolvedValue(undefined); + jest.spyOn(internal, 'getRecordingPlugins').mockResolvedValue([]); + jest.spyOn(sdk, 'addCustomRRWebEvent').mockResolvedValue(undefined); + record.mockImplementation(() => jest.fn()); + await internal._recordEvents(); + const options = record.mock.calls[0][0]; + const enqueued = jest.fn(); + internal.eventCompressor = { enqueueEvent: enqueued }; + const stop = jest.spyOn(sdk, 'stopRecordingEvents'); + options.emit({ type: EventType.Custom, timestamp: 0, data: { tag: 'amp-semantic/recording' } }); + options.emit({ + type: EventType.Custom, + timestamp: 0, + data: { tag: 'amp-semantic/recording', payload: { state: 'start' } }, + }); + options.emit({ type: EventType.Custom, timestamp: 0, data: { tag: 'other', payload: { state: 'stop' } } }); + enqueued.mockClear(); + internal.recordingEnabled = false; + options.emit({ + type: EventType.Custom, + timestamp: 1, + data: { tag: 'amp-semantic/recording', payload: { state: 'stop' } }, + }); + options.emit({ + type: EventType.Custom, + timestamp: 2, + data: { tag: 'amp-semantic/recording', payload: { state: 'pause' } }, + }); + expect(enqueued).toHaveBeenCalledTimes(2); + expect(stop).not.toHaveBeenCalled(); + }); + test.each(['stop', 'shutdown'] as const)('%s tears down semantics before cancelling rrweb', (method) => { const { sdk } = prepare(true); const observed = jest.fn(); @@ -296,16 +361,18 @@ describe('Session Replay context integration', () => { await internal._recordEvents(); const options = record.mock.calls[0][0]; expect(options.hooks.mouseInteraction).toBeUndefined(); + expect(capture.notify).toHaveBeenCalledWith('recording', { state: 'start', reason: 'init' }); expect(capture.notify).toHaveBeenCalledWith('snapshot', undefined); const enqueued = jest.fn(); internal.eventCompressor = { enqueueEvent: enqueued }; options.emit({ type: EventType.Custom, timestamp: 2, data: { tag: 'amp-semantic/context', payload: {} } }); expect(enqueued).toHaveBeenCalledTimes(1); - expect(capture.notify).toHaveBeenCalledTimes(1); + expect(capture.notify).toHaveBeenCalledTimes(2); jest.spyOn(internal, 'shouldOptOut').mockReturnValue(true); options.emit({ type: EventType.FullSnapshot, timestamp: 3, data: {} }); expect(enqueued).toHaveBeenCalledTimes(1); - expect(capture.notify).toHaveBeenCalledTimes(1); + expect(capture.notify).toHaveBeenCalledWith('recording', { state: 'stop', reason: 'opt_out' }); + expect(capture.notify).toHaveBeenCalledTimes(3); jest.spyOn(internal, 'shouldOptOut').mockReturnValue(false); record.mockImplementationOnce(() => { throw new Error('rrweb failed'); diff --git a/packages/session-replay-browser/test/semantic.test.ts b/packages/session-replay-browser/test/semantic.test.ts index 296038df1..d7d1cd29e 100644 --- a/packages/session-replay-browser/test/semantic.test.ts +++ b/packages/session-replay-browser/test/semantic.test.ts @@ -28,11 +28,12 @@ describe('ReplaySemanticAdapter', () => { adapter.recordingReady(); adapter.recordingReady(); expect(capture.start).toHaveBeenCalledTimes(1); + expect(capture.notify).toHaveBeenCalledWith('recording', { state: 'start', reason: 'init' }); expect(capture.notify).toHaveBeenCalledWith('snapshot', undefined); adapter.event({ type: EventType.Custom, timestamp: 2, data: { tag: 'amp-semantic/context', payload: {} } }); - expect(capture.notify).toHaveBeenCalledTimes(1); - adapter.event(snapshot); expect(capture.notify).toHaveBeenCalledTimes(2); + adapter.event(snapshot); + expect(capture.notify).toHaveBeenCalledTimes(3); }); test('clears queued snapshots on stop and prepare; preserves session sampling until the session changes', () => { @@ -70,7 +71,7 @@ describe('ReplaySemanticAdapter', () => { adapter.prepare({ enabled: true }, 1); for (let i = 0; i < 1000; i++) adapter.event(snapshot); adapter.recordingReady(); - expect(capture.notify).toHaveBeenCalledTimes(MAX_PENDING_SEMANTIC_SNAPSHOTS); + expect(capture.notify).toHaveBeenCalledTimes(MAX_PENDING_SEMANTIC_SNAPSHOTS + 1); }); test('forwards accepted URL plugin events in order and ignores other plugin payloads', () => { @@ -87,6 +88,7 @@ describe('ReplaySemanticAdapter', () => { adapter.recordingReady(); expect(capture.notify.mock.calls).toEqual([ ['url', { url: 'https://example.com/first', timestamp: 7 }], + ['recording', { state: 'start', reason: 'init' }], ['snapshot', undefined], ]); adapter.event(plugin('https://example.com/second', 8)); @@ -102,9 +104,30 @@ describe('ReplaySemanticAdapter', () => { data: { plugin: 'amplitude/url-tracking@1', payload: null }, }); expect(capture.notify).toHaveBeenLastCalledWith('url', { url: 'https://example.com/second', timestamp: 8 }); - expect(capture.notify).toHaveBeenCalledTimes(3); + expect(capture.notify).toHaveBeenCalledTimes(4); adapter.prepare({ enabled: false }, 1); adapter.event(plugin('https://example.com/disabled', 12)); - expect(capture.notify).toHaveBeenCalledTimes(3); + expect(capture.notify).toHaveBeenLastCalledWith('recording', { state: 'stop', reason: 'targeting' }); + expect(capture.notify).toHaveBeenCalledTimes(5); + }); + + test('emits stop before teardown and resumes the same session after a new snapshot', () => { + const adapter = new ReplaySemanticAdapter(createSemanticCapture, host); + adapter.prepare({ enabled: true }, 1); + adapter.event(snapshot); + adapter.recordingReady(); + adapter.stop('customer_stop'); + expect(capture.notify).toHaveBeenLastCalledWith('recording', { state: 'stop', reason: 'customer_stop' }); + expect(capture.stop).toHaveBeenCalledTimes(2); // prepare and explicit stop + adapter.prepare({ enabled: true }, 1); + adapter.event(snapshot); + adapter.recordingReady(); + expect(capture.notify).toHaveBeenLastCalledWith('snapshot', undefined); + expect(capture.notify).toHaveBeenCalledWith('recording', { state: 'resume', reason: 'customer_stop' }); + adapter.stop('new_session'); + adapter.prepare({ enabled: true }, 2); + adapter.event(snapshot); + adapter.recordingReady(); + expect(capture.notify).toHaveBeenCalledWith('recording', { state: 'start', reason: 'new_session' }); }); }); diff --git a/test-server/session-replay-browser/sr-semantic-test.html b/test-server/session-replay-browser/sr-semantic-test.html index 0f961b988..69efe307f 100644 --- a/test-server/session-replay-browser/sr-semantic-test.html +++ b/test-server/session-replay-browser/sr-semantic-test.html @@ -44,6 +44,10 @@

Semantic capture playground

Navigation: URL tracking plugin observations, with origin and query removed and identifier-shaped path segments templated. Change the hash route to see a navigation event.

+

+ Recording: start, stop, pause, or resume boundaries supplied by the replay SDK. Use the stop and + resume buttons to inspect customer-controlled boundaries. +

Local transport only. No API key required and no replay data sent to Amplitude.

Initializing…

diff --git a/test-server/session-replay-browser/sr-semantic-test.js b/test-server/session-replay-browser/sr-semantic-test.js index f30330a44..180ad5f06 100644 --- a/test-server/session-replay-browser/sr-semantic-test.js +++ b/test-server/session-replay-browser/sr-semantic-test.js @@ -65,7 +65,7 @@ async function boot() { const families = Object.fromEntries( (params.get('disabled') ?? '') .split(',') - .filter((name) => ['context', 'visibility', 'lifecycle', 'navigation'].includes(name)) + .filter((name) => ['context', 'visibility', 'lifecycle', 'navigation', 'recording'].includes(name)) .map((name) => [name, false]), ); await sessionReplay.init('semantic-local-test-key', {