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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions packages/amp-semantic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ 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

From the repository root, install dependencies and build with Node 24, `pnpm install`, and `pnpm build`. Run `pnpm --filter @amplitude/amp-semantic test` for runtime lifecycle and public contract tests.

## 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.

Expand All @@ -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.
24 changes: 21 additions & 3 deletions packages/amp-semantic/e2e/replay.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand All @@ -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)
Expand Down Expand Up @@ -147,3 +147,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);
});
33 changes: 33 additions & 0 deletions packages/amp-semantic/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@
"navigation": {
"type": "integer",
"minimum": 0
},
"recording": {
"type": "integer",
"minimum": 0
}
},
"additionalProperties": false
Expand Down Expand Up @@ -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
}
]
}
5 changes: 4 additions & 1 deletion packages/amp-semantic/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
9 changes: 9 additions & 0 deletions packages/amp-semantic/src/recorders/recording.ts
Original file line number Diff line number Diff line change
@@ -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));
},
};
7 changes: 7 additions & 0 deletions packages/amp-semantic/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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. */
Expand Down
21 changes: 21 additions & 0 deletions packages/amp-semantic/test/recording.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
4 changes: 3 additions & 1 deletion packages/amp-semantic/test/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions packages/session-replay-browser/src/semantic-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 28 additions & 3 deletions packages/session-replay-browser/src/semantic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
SemanticCaptureController,
SemanticCaptureFactory,
SemanticCaptureHost,
SemanticRecordingSignal,
} from './semantic-types';

/** Transport startup queue bound, independent of recorder-specific event budgets. */
Expand All @@ -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[] = [];
Expand All @@ -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;
}

Expand Down Expand Up @@ -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 = [];
Expand Down
26 changes: 17 additions & 9 deletions packages/session-replay-browser/src/session-replay.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ReplaySemanticAdapter } from './semantic';
import type { SemanticRecordingSignal } from './semantic-types';
import { getSemanticIdentity } from './semantic-identity';
import {
createPrivacyRecorderOptions,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1446,7 +1454,7 @@ export class SessionReplay implements AmplitudeSessionReplay {
this.recordingGeneration++;
this.cancelHiddenSend();
this.windowFocus.stop();
this.stopRecordingEvents();
this.stopRecordingEvents('customer_stop');
this.sendEvents();
}

Expand All @@ -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();
}

Expand Down
Loading
Loading