diff --git a/packages/amp-semantic/README.md b/packages/amp-semantic/README.md new file mode 100644 index 0000000000..96363c3b92 --- /dev/null +++ b/packages/amp-semantic/README.md @@ -0,0 +1,116 @@ +# @amplitude/amp-semantic + +Composable semantic capture for Session Replay. This first increment ships only +`amp-semantic/context` and `amp-semantic/visibility`. Capture is disabled by default. + +## Enable through Session Replay + +```ts +sessionReplay.init(API_KEY, { + deviceId: DEVICE_ID, + sessionId: SESSION_ID, + sampleRate: 1, + semanticCapture: { + enabled: true, + sampleRate: 1, + families: { context: true, visibility: true }, + }, +}); +``` + +The analytics Session Replay plugin accepts the same `semanticCapture` option. +Semantic sampling is additional to replay sampling. A stable random draw is kept +across stop/start cycles within the session; a new session resets sampling and caps. +Each family allows at most 200 events per session in this capture instance. +`families` disables individual registered families when set to `false`; omitted +families are enabled. Only context and visibility are registered in this release. + +The remote `sr_semantic_config` can override `enabled`, `sampleRate`, and individual +family settings. A partial remote config does not enable capture unless local or +remote `enabled` is true. Remote `enabled: false` is the kill switch. + +## Events + +Both are rrweb custom events (`type: 5`). The envelope has the rrweb timestamp and +`data.tag`; the payload has `v: 1`, `tab_id`, and `document_id`. `schema.json` +describes the two supported wire payloads. + +| Event | When | Payload | +| --- | --- | --- | +| `amp-semantic/context` | Each accepted rrweb full snapshot, after recording is ready | SDK, effective default mask level, stylesheet counts, viewport, device heuristic, language, user agent, current visibility and focus, analytics configuration, optional dropped-event counts | +| `amp-semantic/visibility` | Document visibility changes, with duplicates suppressed | `state: "hidden"` or `"visible"` | + +```json +{ + "type": 5, + "timestamp": 1792627218062, + "data": { + "tag": "amp-semantic/visibility", + "payload": { + "v": 1, + "tab_id": "tab-uuid", + "document_id": "document-uuid", + "state": "hidden" + } + } +} +``` + +Context is a snapshot, not continuous tracking: `visibility_state` and `focused` +describe that moment. Visibility events update the document visibility timeline; +focus/blur do not emit events. A visible document need not have keyboard focus. +Browser notification timing can vary, particularly in embedded browsers. Visibility +does not measure element visibility, user activity, or explain all replay gaps. + +`css.inlined` counts readable stylesheets when the host has stylesheet inlining +enabled; it is not confirmation that each sheet was included in the replay. +`css.unreadable` counts access failures, including cross-origin restrictions, not +failed network loads. No stylesheet contents are read into the payload. `device` +is a viewport/coarse-pointer heuristic. Analytics autocapture lists configuration, +not a guarantee that every detector is active. + +No DOM text, field values, URLs, click targets, or accessibility trees are captured +by these two families. The privacy setting is reported as context, while rrweb +continues to enforce replay masking. The package adds one document visibility +listener, no DOM observers or polling. Context collection runs only on snapshots +and stops doing payload work when its cap is exhausted. + +## Identity and lifecycle + +The runtime stamps identity centrally. Session Replay supplies a tab ID using +session storage and a document ID in memory. A normal same-tab navigation reuses +the tab ID when the outgoing page releases ownership on `pagehide`; a copied, +still-owned storage record generates a fresh ID. Blocked storage or a missing +pagehide can result in a fresh tab ID on navigation. IDs are correlation hints, +not an authoritative browser tab registry. Child frames do not claim parent storage. + +Stop, shutdown, reinitialization, and loss of recording eligibility detach capture. +Identity bookkeeping is shared for the document lifetime; its pagehide/pageshow +listeners do not emit semantic lifecycle events. Existing rrweb delivery and upload +policies still apply to semantic custom events. + +## Extension boundary + +`createSemanticCapture(config, host, recorders?)` injects the host and accepts +recorders. The runtime owns sampling, caps, timestamps, identity, fault isolation, +subscriptions, and cleanup. This package does not import rrweb or an analytics SDK. +Dialog and notice recorders, DOM enrichment helpers, and forwarding rrweb mutations +are deferred to the next increment. Clicks, forms, keys, navigation, resource errors, +recording/lifecycle events, and analytics-event forwarding are also not shipped here. + +## Local validation + +From the repository root, run `pnpm install` and `pnpm build`, then: + +```sh +pnpm --filter @amplitude/amp-semantic test --runInBand +pnpm --filter @amplitude/session-replay-browser test --runInBand +pnpm exec vite --port 5175 +``` + +Open `/session-replay-browser/sr-semantic-test.html`. The fixture uses local +configuration and transport handlers; it does not upload replay data to Amplitude. +Switch Chrome tabs and return, then flush to inspect visibility timestamps. Stop +and resume to verify cleanup and new context snapshots. Query parameters +`enabled=false`, `sampleRate=0`, `disabled=context`, `disabled=visibility`, and +`remoteEnabled=false` exercise the capture gates. diff --git a/packages/amp-semantic/e2e/replay.spec.ts b/packages/amp-semantic/e2e/replay.spec.ts new file mode 100644 index 0000000000..2cb38106dc --- /dev/null +++ b/packages/amp-semantic/e2e/replay.spec.ts @@ -0,0 +1,94 @@ +import { test, expect, Page } from '@playwright/test'; +import Ajv from 'ajv'; +import * as schema from '../schema.json'; + +type WireEvent = { type: number; timestamp: number; data: { tag: string; payload: Record } }; +declare global { + interface Window { + semanticHarness: { + ready: boolean; + events: WireEvent[]; + errors: string[]; + flush: () => Promise; + stop: () => Promise; + start: () => Promise; + }; + } +} +const validate = new Ajv().compile(schema); +async function open(page: Page, query = '') { + await page.goto(`/session-replay-browser/sr-semantic-test.html${query}`); + await expect.poll(() => page.evaluate(() => window.semanticHarness?.ready)).toBe(true); +} +async function flush(page: Page) { + await page.evaluate(() => window.semanticHarness.flush()); + return page.evaluate(() => window.semanticHarness.events); +} +async function visibility(page: Page, state: 'hidden' | 'visible') { + // Deterministic notification input; native tab scheduling is checked manually in Chrome. + await page.evaluate((value) => { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => value }); + document.dispatchEvent(new Event('visibilitychange')); + }, state); +} + +test('real replay transport delivers only the two supported families with stable identity and timestamps', async ({ + page, +}) => { + const external: string[] = []; + page.on('request', (request) => { + if (/^https?:/.test(request.url()) && !new URL(request.url()).hostname.match(/^(localhost|127\.0\.0\.1)$/)) { + external.push(request.url()); + } + }); + await open(page); + await expect + .poll(async () => (await flush(page)).filter((e) => e.data.tag === 'amp-semantic/context').length) + .toBe(1); + await visibility(page, 'hidden'); + await page.waitForTimeout(100); + await visibility(page, 'visible'); + await expect + .poll(async () => (await flush(page)).filter((e) => e.data.tag === 'amp-semantic/visibility').length) + .toBe(2); + const events = await flush(page); + expect(events.map((e) => e.data.tag)).toEqual([ + 'amp-semantic/context', + 'amp-semantic/visibility', + 'amp-semantic/visibility', + ]); + expect(events[2].timestamp - events[1].timestamp).toBeGreaterThanOrEqual(90); + for (const event of events) { + expect(validate(event), JSON.stringify(validate.errors)).toBe(true); + expect(event.data.payload.tab_id).toBe(events[0].data.payload.tab_id); + expect(event.data.payload.document_id).toBe(events[0].data.payload.document_id); + } + expect(external).toEqual([]); + expect(await page.evaluate(() => window.semanticHarness.errors)).toEqual([]); +}); + +for (const query of ['?enabled=false', '?sampleRate=0', '?remoteEnabled=false', '?disabled=context,visibility']) { + test(`capture gates suppress semantic output: ${query}`, async ({ page }) => { + await open(page, query); + await visibility(page, 'hidden'); + await visibility(page, 'visible'); + await page.evaluate(() => window.semanticHarness.stop()); + expect(await flush(page)).toEqual([]); + expect(await page.evaluate(() => window.semanticHarness.errors)).toEqual([]); + }); +} + +test('stop removes listeners and resume emits fresh context without resetting document identity', async ({ page }) => { + await open(page); + await expect.poll(async () => (await flush(page)).length).toBe(1); + await page.evaluate(() => window.semanticHarness.stop()); + await visibility(page, 'hidden'); + expect((await flush(page)).length).toBe(1); + await page.evaluate(() => window.semanticHarness.start()); + await expect + .poll(async () => (await flush(page)).filter((e) => e.data.tag === 'amp-semantic/context').length) + .toBe(2); + const contexts = (await flush(page)).filter((e) => e.data.tag === 'amp-semantic/context'); + expect(contexts[1].data.payload.visibility_state).toBe('hidden'); + expect(contexts[1].data.payload.document_id).toBe(contexts[0].data.payload.document_id); +}); diff --git a/packages/amp-semantic/jest.config.js b/packages/amp-semantic/jest.config.js new file mode 100644 index 0000000000..02a9803f3c --- /dev/null +++ b/packages/amp-semantic/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + ...require('../../jest.config.js'), + displayName: '@amplitude/amp-semantic', + rootDir: '.', + testEnvironment: 'jsdom', +}; diff --git a/packages/amp-semantic/package.json b/packages/amp-semantic/package.json new file mode 100644 index 0000000000..6a69dd103c --- /dev/null +++ b/packages/amp-semantic/package.json @@ -0,0 +1,38 @@ +{ + "name": "@amplitude/amp-semantic", + "version": "0.1.0", + "description": "Composable browser context and visibility capture for Amplitude", + "license": "MIT", + "main": "lib/cjs/index.js", + "module": "lib/esm/index.js", + "types": "lib/esm/index.d.ts", + "sideEffects": false, + "files": [ + "lib", + "schema.json" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "pnpm build:es5 && pnpm build:esm", + "build:es5": "tsc -p tsconfig.es5.json", + "build:esm": "tsc -p tsconfig.esm.json", + "test": "jest", + "lint": "eslint '{src,test,e2e}/**/*.ts'", + "typecheck": "tsc -p tsconfig.json", + "test:playwright": "playwright test --config playwright.config.ts" + }, + "dependencies": { + "@amplitude/session-replay-dom-privacy": "workspace:*", + "tslib": "^2.4.1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/amplitude/Amplitude-TypeScript.git" + }, + "homepage": "https://github.com/amplitude/Amplitude-TypeScript", + "devDependencies": { + "ajv": "^8.17.1" + } +} diff --git a/packages/amp-semantic/playwright.config.ts b/packages/amp-semantic/playwright.config.ts new file mode 100644 index 0000000000..d2aad578a5 --- /dev/null +++ b/packages/amp-semantic/playwright.config.ts @@ -0,0 +1,21 @@ +import * as path from 'path'; +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + testMatch: '**/*.spec.ts', + forbidOnly: !!process.env.CI, + retries: 0, + workers: 2, + timeout: 30000, + reporter: 'list', + use: { baseURL: 'http://127.0.0.1:5185', trace: 'retain-on-failure' }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + webServer: { + command: 'pnpm exec vite --host 127.0.0.1 --port 5185 --strictPort', + cwd: path.resolve(__dirname, '../..'), + url: 'http://127.0.0.1:5185/session-replay-browser/sr-semantic-test.html', + reuseExistingServer: false, + timeout: 30000, + }, +}); diff --git a/packages/amp-semantic/schema.json b/packages/amp-semantic/schema.json new file mode 100644 index 0000000000..f5f6645f60 --- /dev/null +++ b/packages/amp-semantic/schema.json @@ -0,0 +1,193 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Amplitude semantic capture v1 events", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "const": 5 + }, + "timestamp": { + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "tag": { + "const": "amp-semantic/context" + }, + "payload": { + "type": "object", + "properties": { + "sdk": { + "type": "string" + }, + "mask": { + "enum": ["light", "medium", "conservative"] + }, + "css": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0 + }, + "inlined": { + "type": "integer", + "minimum": 0 + }, + "unreadable": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["total", "inlined", "unreadable"], + "additionalProperties": false + }, + "viewport": { + "type": "object", + "properties": { + "w": { + "type": "number" + }, + "h": { + "type": "number" + } + }, + "required": ["w", "h"], + "additionalProperties": false + }, + "device": { + "enum": ["desktop", "tablet", "mobile"] + }, + "language": { + "type": "string" + }, + "visibility_state": { + "enum": ["hidden", "visible"] + }, + "focused": { + "type": "boolean" + }, + "user_agent": { + "type": "string" + }, + "analytics_sdk": { + "type": "object", + "properties": { + "present": { + "type": "boolean" + }, + "autocapture": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["present", "autocapture"], + "additionalProperties": false + }, + "caps_hit": { + "type": "object", + "properties": { + "context": { + "type": "integer", + "minimum": 0 + }, + "visibility": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false + }, + "tab_id": { + "type": "string", + "minLength": 1 + }, + "document_id": { + "type": "string", + "minLength": 1 + }, + "v": { + "const": 1 + } + }, + "required": [ + "sdk", + "mask", + "css", + "viewport", + "device", + "language", + "analytics_sdk", + "v", + "tab_id", + "document_id" + ], + "additionalProperties": false + } + }, + "required": ["tag", "payload"], + "additionalProperties": false + } + }, + "required": ["type", "timestamp", "data"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "const": 5 + }, + "timestamp": { + "type": "number" + }, + "data": { + "type": "object", + "properties": { + "tag": { + "const": "amp-semantic/visibility" + }, + "payload": { + "type": "object", + "properties": { + "state": { + "enum": ["hidden", "visible"] + }, + "focused": { + "type": "boolean", + "description": "Legacy observation-time focus snapshot; no longer emitted by visibility producers." + }, + "trigger": { + "enum": ["visibilitychange", "focus", "blur"], + "description": "Legacy trigger; new visibility events emit only state plus version and identity." + }, + "tab_id": { + "type": "string", + "minLength": 1 + }, + "document_id": { + "type": "string", + "minLength": 1 + }, + "v": { + "const": 1 + } + }, + "required": ["state", "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/capture.ts b/packages/amp-semantic/src/capture.ts new file mode 100644 index 0000000000..7a1a4c82b8 --- /dev/null +++ b/packages/amp-semantic/src/capture.ts @@ -0,0 +1,201 @@ +import { getDocumentIdentity } from './identity'; +import { + Cleanup, + HostSignals, + RecorderContext, + SemanticCaptureConfig, + SemanticEvent, + SemanticFamily, + SemanticHost, + SemanticPayloads, + SemanticRecorder, +} from './types'; + +export const SEMANTIC_CAPS: Record = { context: 200, visibility: 200 }; + +export interface SemanticCapture { + isActive: () => boolean; + start: () => void; + stop: () => void; + updateConfig: (config: SemanticCaptureConfig) => void; + resetSession: () => void; + notify: (signal: K, value: HostSignals[K]) => void; +} + +/** Composable runtime. Sampling and caps belong to a session, listeners to a recording. */ +export function createCapture( + config: SemanticCaptureConfig, + host: SemanticHost, + recorders: readonly SemanticRecorder[], +): SemanticCapture { + let settings = copyConfig(config); + let active = false; + let requested = false; + let snapshotSeen = false; + let sample: number | undefined; + let cleanups: Cleanup[] = []; + let counts: Partial> = {}; + let dropped: Partial> = {}; + const signals = new Map void>>(); + const subscribers = new Set<(event: SemanticEvent) => void>(); + const now = host.now ?? Date.now; + const report = (error: unknown) => { + try { + host.onError?.(error); + } catch { + /* Diagnostics must not affect the host. */ + } + }; + const safely = (fn: () => void) => { + try { + fn(); + } catch (error) { + report(error); + } + }; + const enabled = (family: SemanticFamily) => active && settings.families?.[family] !== false; + const hasCapacity = (family: SemanticFamily) => enabled(family) && (counts[family] ?? 0) < SEMANTIC_CAPS[family]; + const canEmit = (family: SemanticFamily): boolean => { + if (!enabled(family)) return false; + if (hasCapacity(family)) return true; + dropped[family] = (dropped[family] ?? 0) + 1; + return false; + }; + const context: Omit = { + host, + now, + enabled, + canEmit, + hasCapacity, + dropped: () => ({ ...dropped }), + hasSnapshot: () => snapshotSeen, + emit: (family: K, payload: SemanticPayloads[K], timestamp = now()): boolean => { + if (!canEmit(family)) return false; + let identity; + try { + identity = host.getIdentity?.(); + } catch (error) { + report(error); + } + identity ??= getDocumentIdentity(host.document, host.window); + const event = { + name: family, + payload: { ...payload, v: 1, tab_id: identity.tab_id, document_id: identity.document_id }, + timestamp, + } as SemanticEvent; + counts[family] = (counts[family] ?? 0) + 1; + try { + host.emit(event); + } catch (error) { + report(error); + return false; + } + if (family === 'context') dropped = {}; + subscribers.forEach((subscriber) => safely(() => subscriber(event))); + return true; + }, + on: (signal, callback) => { + let listeners = signals.get(signal); + if (!listeners) { + listeners = new Set(); + signals.set(signal, listeners); + } + const listener = (value: never) => safely(() => callback(value)); + listeners.add(listener); + const registered = listeners; + return () => { + registered.delete(listener); + }; + }, + subscribe: (callback) => { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; + }, + }; + const detach = () => { + active = false; + const previous = cleanups; + cleanups = []; + previous.reverse().forEach((cleanup) => safely(cleanup)); + signals.clear(); + subscribers.clear(); + }; + const attach = () => { + if (active || !requested || !settings.enabled) return; + const rate = settings.sampleRate ?? 1; + if (!Number.isFinite(rate) || rate <= 0 || rate > 1) return; + if (sample === undefined) sample = (host.random ?? Math.random)(); + if (sample >= rate) return; + active = true; + for (const recorder of recorders) { + if (recorder.families.some(enabled)) { + const acquired: Cleanup[] = []; + const scoped: RecorderContext = { + ...context, + onCleanup: (cleanup) => { + acquired.push(cleanup); + }, + on: (signal, callback) => { + const cleanup = context.on(signal, callback); + acquired.push(cleanup); + return cleanup; + }, + subscribe: (callback) => { + const cleanup = context.subscribe(callback); + acquired.push(cleanup); + return cleanup; + }, + }; + const rollback = () => { + acquired + .splice(0) + .reverse() + .forEach((cleanup) => safely(cleanup)); + }; + try { + acquired.push(recorder.setup(scoped)); + cleanups.push(rollback); + } catch (error) { + rollback(); + report(error); + } + } + } + }; + return { + isActive: () => active, + start: () => { + requested = true; + attach(); + }, + stop: () => { + requested = false; + snapshotSeen = false; + detach(); + }, + updateConfig: (next) => { + detach(); + settings = copyConfig(next); + attach(); + }, + resetSession: () => { + detach(); + sample = undefined; + snapshotSeen = false; + counts = {}; + dropped = {}; + attach(); + }, + notify: (signal, value) => { + if (signal === 'snapshot') snapshotSeen = true; + if (!active) return; + signals.get(signal)?.forEach((callback) => callback(value as never)); + }, + }; +} + +function copyConfig(config: SemanticCaptureConfig): SemanticCaptureConfig { + return { ...config, families: config.families ? { ...config.families } : undefined }; +} diff --git a/packages/amp-semantic/src/identity.ts b/packages/amp-semantic/src/identity.ts new file mode 100644 index 0000000000..43429bcee5 --- /dev/null +++ b/packages/amp-semantic/src/identity.ts @@ -0,0 +1,25 @@ +import type { SemanticIdentity } from './types'; + +const identities = new WeakMap(); + +function uuid(scope: Window): string { + try { + return scope.crypto.randomUUID(); + } catch { + // Correlation IDs, not credentials. Also supports HTTP and older browsers. + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => { + const value = (Math.random() * 16) | 0; + return (character === 'x' ? value : (value & 0x3) | 0x8).toString(16); + }); + } +} + +/** No storage or listeners. Hosts can supply persistent tab identity through getIdentity. */ +export function getDocumentIdentity(document: Document, scope: Window): SemanticIdentity { + let identity = identities.get(document); + if (!identity) { + identity = Object.freeze({ tab_id: uuid(scope), document_id: uuid(scope) }); + identities.set(document, identity); + } + return identity; +} diff --git a/packages/amp-semantic/src/index.ts b/packages/amp-semantic/src/index.ts new file mode 100644 index 0000000000..cda72e1fe2 --- /dev/null +++ b/packages/amp-semantic/src/index.ts @@ -0,0 +1,19 @@ +import { createCapture } from './capture'; +import { contextRecorder } from './recorders/context'; +import { visibilityRecorder } from './recorders/visibility'; +import type { SemanticCaptureConfig, SemanticHost, SemanticRecorder } from './types'; + +export { contextRecorder, visibilityRecorder }; +export const defaultRecorders: readonly SemanticRecorder[] = [contextRecorder, visibilityRecorder]; +export * from './types'; +export type { SemanticCapture } from './capture'; +export { SEMANTIC_CAPS } from './capture'; +export { getDocumentIdentity } from './identity'; + +export function createSemanticCapture( + config: SemanticCaptureConfig, + host: SemanticHost, + recorders: readonly SemanticRecorder[] = defaultRecorders, +) { + return createCapture(config, host, recorders); +} diff --git a/packages/amp-semantic/src/recorders/context.ts b/packages/amp-semantic/src/recorders/context.ts new file mode 100644 index 0000000000..32c6657e68 --- /dev/null +++ b/packages/amp-semantic/src/recorders/context.ts @@ -0,0 +1,42 @@ +import type { RecorderContext, SemanticRecorder } from '../types'; + +function captureContext(context: RecorderContext): void { + if (!context.canEmit('context')) return; + const { host } = context; + const { document, window: scope } = host; + const css = { total: 0, inlined: 0, unreadable: 0 }; + for (const sheet of Array.from(document.styleSheets)) { + css.total++; + try { + // Reading cssRules can throw for cross-origin stylesheets. + void sheet.cssRules; + if (host.inlineStylesheet !== false) css.inlined++; + } catch { + css.unreadable++; + } + } + const coarse = typeof scope.matchMedia === 'function' && scope.matchMedia('(pointer:coarse)').matches; + const device = coarse && scope.innerWidth < 768 ? 'mobile' : coarse && scope.innerWidth < 1024 ? 'tablet' : 'desktop'; + const caps = context.dropped(); + context.emit('context', { + sdk: host.sdk, + mask: host.getPrivacy().defaultMaskLevel ?? 'medium', + css, + viewport: { w: scope.innerWidth, h: scope.innerHeight }, + device, + language: scope.navigator.language, + visibility_state: document.visibilityState === 'hidden' ? 'hidden' : 'visible', + focused: document.hasFocus(), + user_agent: scope.navigator.userAgent, + analytics_sdk: host.getAnalyticsContext?.() ?? { present: false, autocapture: [] }, + ...(Object.keys(caps).length ? { caps_hit: caps } : {}), + }); +} + +/** Context is refreshed only when the host accepts a full replay snapshot. */ +export const contextRecorder: SemanticRecorder = { + families: ['context'], + setup(context) { + return context.on('snapshot', () => captureContext(context)); + }, +}; diff --git a/packages/amp-semantic/src/recorders/visibility.ts b/packages/amp-semantic/src/recorders/visibility.ts new file mode 100644 index 0000000000..ebeb39d443 --- /dev/null +++ b/packages/amp-semantic/src/recorders/visibility.ts @@ -0,0 +1,19 @@ +import type { SemanticRecorder } from '../types'; + +/** Document visibility is independent of keyboard focus. */ +export const visibilityRecorder: SemanticRecorder = { + families: ['visibility'], + setup(context) { + const { document } = context.host; + const readState = () => (document.visibilityState === 'hidden' ? 'hidden' : 'visible'); + let previous = readState(); + const onVisibility = () => { + const state = readState(); + if (state === previous) return; + previous = state; + context.emit('visibility', { state }); + }; + document.addEventListener('visibilitychange', onVisibility, true); + return () => document.removeEventListener('visibilitychange', onVisibility, true); + }, +}; diff --git a/packages/amp-semantic/src/types.ts b/packages/amp-semantic/src/types.ts new file mode 100644 index 0000000000..dd4b8433bc --- /dev/null +++ b/packages/amp-semantic/src/types.ts @@ -0,0 +1,82 @@ +import type { PrivacyConfig } from '@amplitude/session-replay-dom-privacy'; + +export const SEMANTIC_VERSION = 1; +export interface AnalyticsContext { + present: boolean; + autocapture: string[]; +} +export interface SemanticIdentity { + readonly tab_id: string; + readonly document_id: string; +} +/** Payloads exclude version and identity, which are owned by the runtime. */ +export interface SemanticPayloads { + context: { + sdk: string; + mask: 'light' | 'medium' | 'conservative'; + css: { total: number; inlined: number; unreadable: number }; + viewport: { w: number; h: number }; + device: 'desktop' | 'tablet' | 'mobile'; + language: string; + visibility_state: 'hidden' | 'visible'; + focused: boolean; + user_agent: string; + analytics_sdk: AnalyticsContext; + caps_hit?: Partial>; + }; + visibility: { state: 'hidden' | 'visible' }; +} +export type SemanticFamily = keyof SemanticPayloads; +export type SemanticEvent = { + [K in SemanticFamily]: { name: K; payload: SemanticPayloads[K] & SemanticIdentity & { v: 1 }; timestamp: number }; +}[SemanticFamily]; +export interface SemanticCaptureConfig { + enabled: boolean; + /** Additional sampling within recorded replay sessions. Defaults to 1. */ + sampleRate?: number; + /** Registered families are enabled unless explicitly false. */ + families?: Partial>; +} +export interface HostSignals { + /** An accepted full snapshot, forwarded after the recorder is ready. */ + snapshot: undefined; +} +export type Cleanup = () => void; +/** The host owns replay transport and privacy configuration; no rrweb dependency. */ +export interface SemanticHost { + document: Document; + window: Window; + emit: (event: SemanticEvent) => void; + getPrivacy: () => PrivacyConfig; + sdk: string; + getAnalyticsContext?: () => AnalyticsContext; + /** Called lazily for accepted events. Defaults to document-scoped memory only. */ + getIdentity?: () => SemanticIdentity; + inlineStylesheet?: boolean; + now?: () => number; + random?: () => number; + onError?: (error: unknown) => void; +} +export interface RecorderContext { + /** Register resources immediately so failed setup can roll them back. */ + onCleanup: (cleanup: Cleanup) => void; + host: SemanticHost; + now: () => number; + enabled: (family: SemanticFamily) => boolean; + /** Check session capacity without counting a dropped event. */ + hasCapacity: (family: SemanticFamily) => boolean; + /** Check before expensive payload work. A rejected candidate increments the dropped count. */ + canEmit: (family: SemanticFamily) => boolean; + emit: (family: K, payload: SemanticPayloads[K], timestamp?: number) => boolean; + on: (signal: K, callback: (value: HostSignals[K]) => void) => Cleanup; + subscribe: (callback: (event: SemanticEvent) => void) => Cleanup; + /** Peek until a context event successfully emits; runtime then clears these counts. */ + dropped: () => Partial>; + /** Preserves page readiness when families are reconfigured mid-recording. */ + hasSnapshot: () => boolean; +} +/** A factory owns its listeners/timers and returns complete teardown. */ +export interface SemanticRecorder { + families: readonly SemanticFamily[]; + setup: (context: RecorderContext) => Cleanup; +} diff --git a/packages/amp-semantic/test/capture-edges.test.ts b/packages/amp-semantic/test/capture-edges.test.ts new file mode 100644 index 0000000000..3d434eb7a9 --- /dev/null +++ b/packages/amp-semantic/test/capture-edges.test.ts @@ -0,0 +1,107 @@ +import * as semantic from '../src'; +import { createCapture } from '../src/capture'; +import { RecorderContext, SemanticRecorder } from '../src/types'; +import { recorderContext } from './fixtures'; + +function runtime() { + const host = recorderContext().context.host; + let context!: RecorderContext; + const cleanup = jest.fn(); + const recorder: SemanticRecorder = { + families: ['context'], + setup: (value) => { + context = value; + return cleanup; + }, + }; + const capture = createCapture({ enabled: true }, host, [recorder]); + capture.start(); + return { host, capture, context, cleanup }; +} + +test('exposes the composable public recorders and default runtime', () => { + expect(semantic.defaultRecorders).toEqual([semantic.contextRecorder, semantic.visibilityRecorder]); + expect(semantic.SEMANTIC_CAPS.context).toBe(200); + const h = recorderContext().context.host; + expect(semantic.getDocumentIdentity(document, window).tab_id).toEqual(expect.any(String)); + const capture = semantic.createSemanticCapture({ enabled: true }, h); + capture.start(); + expect(capture.isActive()).toBe(true); + capture.stop(); +}); + +test.each([0, -1, 2, NaN, Infinity])( + 'rejects invalid or zero sample rate %s without attaching listeners', + (sampleRate) => { + const host = recorderContext().context.host; + const capture = semantic.createSemanticCapture({ enabled: true, sampleRate }, host); + capture.start(); + capture.notify('snapshot', undefined); + expect(capture.isActive()).toBe(false); + expect(host.emit).not.toHaveBeenCalled(); + capture.stop(); + }, +); + +test('ignores unknown host signals and avoids context payload reads after its cap', () => { + const host = recorderContext().context.host; + const capture = semantic.createSemanticCapture({ enabled: true }, host); + capture.start(); + // JavaScript hosts can send signals that this release does not implement. + capture.notify('future-signal' as 'snapshot', undefined); + expect(host.emit).not.toHaveBeenCalled(); + for (let i = 0; i < semantic.SEMANTIC_CAPS.context; i++) capture.notify('snapshot', undefined); + const read = jest.spyOn(document, 'styleSheets', 'get'); + capture.notify('snapshot', undefined); + expect(read).not.toHaveBeenCalled(); + expect(host.emit).toHaveBeenCalledTimes(semantic.SEMANTIC_CAPS.context); + capture.stop(); +}); + +test('tracks snapshot state, explicit subscriptions and fault isolation', () => { + const { host, capture, context } = runtime(); + expect(context.hasSnapshot()).toBe(false); + const error = new Error('extension error'); + const bad = context.on('snapshot', () => { + throw error; + }); + const listener = jest.fn(); + const remove = context.on('snapshot', listener); + const subscriber = jest.fn(); + const unsubscribe = context.subscribe(subscriber); + const badSubscriber = context.subscribe(() => { + throw error; + }); + expect(() => capture.notify('snapshot', undefined)).not.toThrow(); + expect(context.hasSnapshot()).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + context.emit('visibility', { state: 'hidden' }); + expect(subscriber).toHaveBeenCalledTimes(1); + host.onError = jest.fn(); + capture.notify('snapshot', undefined); + expect(host.onError).toHaveBeenCalledWith(error); + bad(); + remove(); + unsubscribe(); + badSubscriber(); + capture.notify('snapshot', undefined); + expect(listener).toHaveBeenCalledTimes(2); + capture.stop(); + expect(context.hasSnapshot()).toBe(false); + remove(); +}); + +test('isolates failing cleanup and clears cap diagnostics only after a successful context event', () => { + const { host, capture, context, cleanup } = runtime(); + cleanup.mockImplementation(() => { + throw new Error('cleanup'); + }); + for (let i = 0; i < 202; i++) context.emit('visibility', { state: 'visible' }); + expect(context.dropped()).toEqual({ visibility: 2 }); + const stop = semantic.contextRecorder.setup(context); + capture.notify('snapshot', undefined); + expect(context.dropped()).toEqual({}); + expect(host.emit).toHaveBeenCalledWith(expect.objectContaining({ name: 'context' })); + stop(); + expect(() => capture.stop()).not.toThrow(); +}); diff --git a/packages/amp-semantic/test/capture.test.ts b/packages/amp-semantic/test/capture.test.ts new file mode 100644 index 0000000000..c15d01c385 --- /dev/null +++ b/packages/amp-semantic/test/capture.test.ts @@ -0,0 +1,188 @@ +import { createCapture } from '../src/capture'; +import { RecorderContext, SemanticHost, SemanticRecorder } from '../src/types'; + +describe('capture runtime', () => { + let host: SemanticHost; + let context: RecorderContext; + let setup: jest.Mock; + let cleanup: jest.Mock; + let recorder: SemanticRecorder; + beforeEach(() => { + host = { + document, + window, + emit: jest.fn(), + getPrivacy: () => ({}), + sdk: 'test', + getIdentity: jest.fn(() => ({ tab_id: 'tab-test', document_id: 'document-test' })), + random: jest.fn(() => 0.25), + now: () => 123, + }; + cleanup = jest.fn(); + setup = jest.fn((value: RecorderContext) => { + context = value; + return cleanup; + }); + recorder = { families: ['context'], setup }; + }); + test('disabled attaches no recorders and emits nothing', () => { + const capture = createCapture({ enabled: false }, host, [recorder]); + capture.start(); + capture.notify('snapshot', undefined); + capture.stop(); + expect(setup).not.toHaveBeenCalled(); + expect(host.emit).not.toHaveBeenCalled(); + expect(host.random).not.toHaveBeenCalled(); + expect(host.getIdentity).not.toHaveBeenCalled(); + }); + test('sampling is stable across starts and reset for a new session', () => { + const capture = createCapture({ enabled: true, sampleRate: 0.1 }, host, [recorder]); + capture.start(); + capture.stop(); + capture.start(); + expect(setup).not.toHaveBeenCalled(); + expect(host.getIdentity).not.toHaveBeenCalled(); + expect(host.random).toHaveBeenCalledTimes(1); + capture.resetSession(); + expect(host.random).toHaveBeenCalledTimes(2); + }); + test('family toggles tear down listeners without resetting caps', () => { + const capture = createCapture({ enabled: true }, host, [recorder]); + capture.start(); + capture.start(); + expect(setup).toHaveBeenCalledTimes(1); + for (let i = 0; i < 201; i++) context.emit('visibility', { state: 'visible' }); + expect(host.emit).toHaveBeenCalledTimes(200); + expect(context.dropped()).toEqual({ visibility: 1 }); + capture.updateConfig({ enabled: true, families: { context: false } }); + expect(cleanup).toHaveBeenCalledTimes(1); + expect(setup).toHaveBeenCalledTimes(1); + capture.updateConfig({ enabled: true }); + expect(context.emit('visibility', { state: 'hidden' })).toBe(false); + capture.resetSession(); + expect(context.emit('visibility', { state: 'hidden' })).toBe(true); + }); + test('preflight avoids payload work after the cap and counts each dropped candidate once', () => { + const capture = createCapture({ enabled: true }, host, [recorder]); + capture.start(); + for (let i = 0; i < 200; i++) context.emit('visibility', { state: 'visible' }); + for (let i = 0; i < 10; i++) expect(context.hasCapacity('visibility')).toBe(false); + expect(context.dropped()).toEqual({}); + const payload = jest.fn(() => ({ state: 'hidden' as const })); + if (context.canEmit('visibility')) context.emit('visibility', payload()); + expect(payload).not.toHaveBeenCalled(); + expect(context.dropped()).toEqual({ visibility: 1 }); + capture.stop(); + expect(context.canEmit('visibility')).toBe(false); + expect(context.dropped()).toEqual({ visibility: 1 }); + capture.start(); + capture.resetSession(); + expect(context.canEmit('visibility')).toBe(true); + capture.stop(); + }); + + test('dispatches signals synchronously and tears down subscriptions', () => { + const capture = createCapture({ enabled: true }, host, [recorder]); + capture.start(); + const callback = jest.fn(); + context.on('snapshot', callback); + capture.notify('snapshot', undefined); + expect(callback).toHaveBeenCalledTimes(1); + capture.stop(); + capture.notify('snapshot', undefined); + expect(callback).toHaveBeenCalledTimes(1); + expect(context.emit('visibility', { state: 'visible' })).toBe(false); + }); + test('preserves timestamp and version and isolates host errors', () => { + host.onError = jest.fn(); + const capture = createCapture({ enabled: true }, host, [recorder]); + capture.start(); + context.emit('visibility', { state: 'hidden' }, 99); + expect(host.emit).toHaveBeenCalledWith({ + name: 'visibility', + payload: { + v: 1, + state: 'hidden', + tab_id: 'tab-test', + document_id: 'document-test', + }, + timestamp: 99, + }); + host.emit = () => { + throw new Error('host'); + }; + expect(context.emit('visibility', { state: 'visible' })).toBe(false); + expect(host.onError).toHaveBeenCalled(); + host.onError = () => { + throw new Error('diagnostic'); + }; + expect(() => context.emit('visibility', { state: 'visible' })).not.toThrow(); + }); + test('reserves caps before calling a reentrant host emitter', () => { + const capture = createCapture({ enabled: true }, host, [recorder]); + capture.start(); + for (let i = 0; i < 199; i++) context.emit('visibility', { state: 'visible' }); + host.emit = jest.fn(() => { + context.emit('visibility', { state: 'hidden' }); + }); + context.emit('visibility', { state: 'visible' }); + expect(host.emit).toHaveBeenCalledTimes(1); + expect(context.dropped()).toEqual({ visibility: 1 }); + }); + test('stamps all families centrally and retains document identity through stop and reset', () => { + host.getIdentity = undefined; + const capture = createCapture({ enabled: true }, host, [recorder]); + capture.start(); + context.emit('visibility', { state: 'hidden' }); + const first = jest.mocked(host.emit).mock.calls[0][0].payload; + capture.stop(); + capture.start(); + capture.resetSession(); + context.emit('visibility', { state: 'visible' }); + expect(jest.mocked(host.emit).mock.calls[1][0].payload).toMatchObject({ + tab_id: first.tab_id, + document_id: first.document_id, + }); + expect(first.tab_id).toEqual(expect.any(String)); + expect(first.document_id).toEqual(expect.any(String)); + capture.stop(); + }); + + test('a failed identity provider falls back without dropping events', () => { + host.getIdentity = () => { + throw new Error('storage blocked'); + }; + host.onError = jest.fn(); + const capture = createCapture({ enabled: true }, host, [recorder]); + capture.start(); + expect(context.emit('visibility', { state: 'visible' })).toBe(true); + expect(host.onError).toHaveBeenCalled(); + expect(jest.mocked(host.emit).mock.calls[0][0].payload).toMatchObject({ + tab_id: expect.any(String), + document_id: expect.any(String), + }); + capture.stop(); + }); + + test('rolls back registered resources when an extension fails during setup', () => { + host.onError = jest.fn(); + const release = jest.fn(); + const listener = jest.fn(); + const failing: SemanticRecorder = { + families: ['context'], + setup: (ctx) => { + ctx.onCleanup(release); + ctx.on('snapshot', listener); + throw new Error('setup failed'); + }, + }; + const capture = createCapture({ enabled: true }, host, [failing, recorder]); + capture.start(); + capture.notify('snapshot', undefined); + expect(release).toHaveBeenCalledTimes(1); + expect(listener).not.toHaveBeenCalled(); + expect(setup).toHaveBeenCalledTimes(1); + capture.stop(); + expect(release).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/amp-semantic/test/fixtures.ts b/packages/amp-semantic/test/fixtures.ts new file mode 100644 index 0000000000..d3e2594690 --- /dev/null +++ b/packages/amp-semantic/test/fixtures.ts @@ -0,0 +1,7 @@ +/* eslint-disable no-restricted-globals -- Browser-only jsdom host fixture. */ +import type { SemanticHost } from '../src/types'; + +export function recorderContext() { + const host: SemanticHost = { document, window, sdk: 'test', emit: jest.fn(), getPrivacy: () => ({}) }; + return { context: { host } }; +} diff --git a/packages/amp-semantic/test/recorders.test.ts b/packages/amp-semantic/test/recorders.test.ts new file mode 100644 index 0000000000..7d9ec7627b --- /dev/null +++ b/packages/amp-semantic/test/recorders.test.ts @@ -0,0 +1,180 @@ +import { contextRecorder } from '../src/recorders/context'; +import { visibilityRecorder } from '../src/recorders/visibility'; +import { HostSignals, RecorderContext, SemanticFamily } from '../src/types'; + +function setup(families: SemanticFamily[] = ['context', 'visibility']) { + const signals: Partial void>> = {}; + const context = { + host: { + document, + window, + sdk: 'test-sdk@1', + getPrivacy: () => ({ defaultMaskLevel: 'medium' }), + getAnalyticsContext: () => ({ present: true, autocapture: ['elementInteractions'] }), + }, + enabled: (family: SemanticFamily) => families.includes(family), + emit: jest.fn(() => true), + canEmit: () => true, + hasCapacity: () => true, + dropped: jest.fn(() => ({})), + on: jest.fn((name: keyof HostSignals, callback: (value: never) => void) => { + signals[name] = callback; + return () => delete signals[name]; + }), + } as unknown as RecorderContext; + const cleanups = [contextRecorder, visibilityRecorder] + .filter((recorder) => recorder.families.some((family) => families.includes(family))) + .map((recorder) => recorder.setup(context)); + const cleanup = () => cleanups.forEach((stop) => stop()); + return { context, cleanup, signals }; +} + +describe('context and visibility recorders', () => { + afterEach(() => { + jest.useRealTimers(); + document.head.innerHTML = ''; + jest.restoreAllMocks(); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 }); + Object.defineProperty(window, 'matchMedia', { configurable: true, value: undefined }); + }); + + test('emits one context per host snapshot', () => { + document.head.innerHTML = ''; + const { context, cleanup, signals } = setup(); + expect(context.emit).not.toHaveBeenCalled(); + signals.snapshot?.(undefined as never); + expect(context.emit).toHaveBeenLastCalledWith( + 'context', + expect.objectContaining({ + sdk: 'test-sdk@1', + mask: 'medium', + device: 'desktop', + css: { total: 1, inlined: 1, unreadable: 0 }, + analytics_sdk: { present: true, autocapture: ['elementInteractions'] }, + }), + ); + signals.snapshot?.(undefined as never); + expect(context.emit).toHaveBeenCalledTimes(2); + cleanup(); + expect(signals).toEqual({}); + }); + + test('captures initial hidden state and refreshes context without the visibility family', () => { + const visibility = jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + const focused = jest.spyOn(document, 'hasFocus').mockReturnValue(false); + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('SemanticContextTest/1.0'); + const { context, cleanup, signals } = setup(['context']); + signals.snapshot?.(undefined as never); + expect(context.emit).toHaveBeenLastCalledWith( + 'context', + expect.objectContaining({ visibility_state: 'hidden', focused: false, user_agent: 'SemanticContextTest/1.0' }), + ); + visibility.mockReturnValue('visible'); + focused.mockReturnValue(true); + signals.snapshot?.(undefined as never); + expect(context.emit).toHaveBeenLastCalledWith( + 'context', + expect.objectContaining({ visibility_state: 'visible', focused: true, user_agent: 'SemanticContextTest/1.0' }), + ); + expect(context.emit).toHaveBeenCalledTimes(2); + cleanup(); + }); + + test('counts unreadable stylesheets and includes dropped counts', () => { + const { context, cleanup, signals } = setup(['context']); + const sheet = { + get cssRules(): never { + throw new Error('cross origin'); + }, + }; + jest.spyOn(document, 'styleSheets', 'get').mockReturnValue([sheet] as unknown as StyleSheetList); + jest.mocked(context.dropped).mockReturnValue({ visibility: 3 }); + signals.snapshot?.(undefined as never); + expect(context.emit).toHaveBeenCalledWith( + 'context', + expect.objectContaining({ + css: { total: 1, inlined: 0, unreadable: 1 }, + caps_hit: { visibility: 3 }, + }), + ); + cleanup(); + }); + + test('does not count readable styles as inlined when stylesheet inlining is disabled', () => { + document.head.innerHTML = ''; + const { context, cleanup, signals } = setup(['context']); + context.host.inlineStylesheet = false; + context.host.getAnalyticsContext = undefined; + context.host.getPrivacy = () => ({}); + signals.snapshot?.(undefined as never); + expect(context.emit).toHaveBeenCalledWith( + 'context', + expect.objectContaining({ + css: { total: 1, inlined: 0, unreadable: 0 }, + analytics_sdk: { present: false, autocapture: [] }, + }), + ); + cleanup(); + }); + + test.each([ + [600, 'mobile'], + [900, 'tablet'], + [1200, 'desktop'], + ])('uses coarse pointer and width %s for %s', (width, device) => { + const { context, cleanup, signals } = setup(['context']); + Object.defineProperty(window, 'matchMedia', { configurable: true, value: jest.fn(() => ({ matches: true })) }); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }); + signals.snapshot?.(undefined as never); + expect(context.emit).toHaveBeenCalledWith('context', expect.objectContaining({ device })); + cleanup(); + }); + + test('ignores focus-only changes and duplicate visibility notifications', () => { + const visibility = jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + const focused = jest.spyOn(document, 'hasFocus').mockReturnValue(true); + const { context, cleanup } = setup(['visibility']); + focused.mockReturnValue(false); + window.dispatchEvent(new Event('blur')); + document.body.dispatchEvent(new Event('blur', { bubbles: true })); + document.dispatchEvent(new Event('visibilitychange')); + focused.mockReturnValue(true); + window.dispatchEvent(new Event('focus')); + expect(context.emit).not.toHaveBeenCalled(); + + visibility.mockReturnValue('hidden'); + focused.mockReturnValue(false); + document.dispatchEvent(new Event('visibilitychange')); + document.dispatchEvent(new Event('visibilitychange')); + expect(context.emit).toHaveBeenCalledTimes(1); + expect(context.emit).toHaveBeenLastCalledWith('visibility', { + state: 'hidden', + }); + + // Emit visibility without implying that the page has already gained focus. + visibility.mockReturnValue('visible'); + document.dispatchEvent(new Event('visibilitychange')); + expect(context.emit).toHaveBeenCalledTimes(2); + expect(context.emit).toHaveBeenLastCalledWith('visibility', { + state: 'visible', + }); + focused.mockReturnValue(true); + window.dispatchEvent(new Event('focus')); + document.dispatchEvent(new Event('visibilitychange')); + expect(context.emit).toHaveBeenCalledTimes(2); + + cleanup(); + visibility.mockReturnValue('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + expect(context.emit).toHaveBeenCalledTimes(2); + }); + + test('attaches no browser listeners when browser families are disabled', () => { + const documentListener = jest.spyOn(document, 'addEventListener'); + const windowListener = jest.spyOn(window, 'addEventListener'); + const { cleanup } = setup(['context']); + expect(documentListener).not.toHaveBeenCalled(); + expect(windowListener).not.toHaveBeenCalled(); + cleanup(); + }); +}); diff --git a/packages/amp-semantic/test/schema.test.ts b/packages/amp-semantic/test/schema.test.ts new file mode 100644 index 0000000000..8b934653a9 --- /dev/null +++ b/packages/amp-semantic/test/schema.test.ts @@ -0,0 +1,38 @@ +import Ajv from 'ajv'; +import * as schema from '../schema.json'; +import { createSemanticCapture, SemanticEvent } from '../src'; + +const validate = new Ajv().compile(schema); +const wire = (event: SemanticEvent) => ({ + type: 5, + timestamp: event.timestamp, + data: { tag: `amp-semantic/${event.name}`, payload: event.payload }, +}); + +test('validates actual context and visibility payloads and rejects unshipped families', () => { + const visibility = jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + const events: SemanticEvent[] = []; + const capture = createSemanticCapture( + { enabled: true }, + { + document, + window, + sdk: 'test', + getPrivacy: () => ({}), + emit: (event) => events.push(event), + }, + ); + capture.start(); + capture.notify('snapshot', undefined); + visibility.mockReturnValue('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + capture.stop(); + expect(events.map((event) => event.name)).toEqual(['context', 'visibility']); + for (const event of events) expect(validate(wire(event))).toBe(true); + const invalid = wire(events[1]); + invalid.data.tag = 'amp-semantic/click'; + expect(validate(invalid)).toBe(false); + invalid.data.tag = 'amp-semantic/visibility'; + invalid.data.payload = { ...invalid.data.payload, document_id: '' }; + expect(validate(invalid)).toBe(false); +}); diff --git a/packages/amp-semantic/tsconfig.es5.json b/packages/amp-semantic/tsconfig.es5.json new file mode 100644 index 0000000000..ed5f1c8064 --- /dev/null +++ b/packages/amp-semantic/tsconfig.es5.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "module": "commonjs", + "noEmit": false, + "rootDir": "src", + "outDir": "lib/cjs" + } +} diff --git a/packages/amp-semantic/tsconfig.esm.json b/packages/amp-semantic/tsconfig.esm.json new file mode 100644 index 0000000000..34f1a57932 --- /dev/null +++ b/packages/amp-semantic/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "module": "es2020", + "noEmit": false, + "rootDir": "src", + "outDir": "lib/esm" + } +} diff --git a/packages/amp-semantic/tsconfig.json b/packages/amp-semantic/tsconfig.json new file mode 100644 index 0000000000..d775133af7 --- /dev/null +++ b/packages/amp-semantic/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*", "test/**/*", "e2e/**/*", "playwright.config.ts"], + "compilerOptions": { + "noEmit": true, + "rootDir": "." + } +} diff --git a/packages/plugin-session-replay-browser/src/semantic-context.ts b/packages/plugin-session-replay-browser/src/semantic-context.ts new file mode 100644 index 0000000000..3f098f026f --- /dev/null +++ b/packages/plugin-session-replay-browser/src/semantic-context.ts @@ -0,0 +1,27 @@ +import type { BrowserConfig } from '@amplitude/analytics-core'; + +/** Mirrors analytics-browser's boolean defaults and frustration-plugin's detector defaults. */ +export function semanticAutocaptureFamilies(autocapture: BrowserConfig['autocapture']): string[] { + if (autocapture === true) { + return [ + 'attribution', + 'fileDownloads', + 'formInteractions', + 'pageViews', + 'sessions', + 'elementInteractions', + 'webVitals', + 'frustrationInteractions', + ]; + } + if (!autocapture || typeof autocapture !== 'object') return []; + return Object.keys(autocapture).filter((key) => { + const value = autocapture[key as keyof typeof autocapture]; + if (key !== 'frustrationInteractions') return Boolean(value); + const frustration = autocapture.frustrationInteractions; + if (frustration === true) return true; + if (!frustration || typeof frustration !== 'object') return false; + // Dead clicks default on. Error clicks are currently opt-in in frustration-plugin. + return (frustration.deadClicks !== false && frustration.deadClicks !== null) || Boolean(frustration.errorClicks); + }); +} diff --git a/packages/plugin-session-replay-browser/src/session-replay.ts b/packages/plugin-session-replay-browser/src/session-replay.ts index 2cac71a9ac..9e920930c9 100644 --- a/packages/plugin-session-replay-browser/src/session-replay.ts +++ b/packages/plugin-session-replay-browser/src/session-replay.ts @@ -21,6 +21,7 @@ import { SessionReplayOptions as SessionReplayBrowserOptions, } from '@amplitude/session-replay-browser'; import { parseUserProperties } from './helpers'; +import { semanticAutocaptureFamilies } from './semantic-context'; import { SessionReplayOptions } from './typings/session-replay'; import { VERSION } from './version'; @@ -92,6 +93,11 @@ export class SessionReplayPlugin implements EnrichmentPlugin { + test('boolean autocapture uses the current analytics-browser defaults', () => { + expect(semanticAutocaptureFamilies(true)).toEqual([ + 'attribution', + 'fileDownloads', + 'formInteractions', + 'pageViews', + 'sessions', + 'elementInteractions', + 'webVitals', + 'frustrationInteractions', + ]); + expect(semanticAutocaptureFamilies(false)).toEqual([]); + expect(semanticAutocaptureFamilies(undefined)).toEqual([]); + }); + + test.each([ + [true, true], + [false, false], + [{}, true], + [{ deadClicks: true }, true], + [{ deadClicks: false }, false], + [{ deadClicks: false, errorClicks: true }, true], + [{ deadClicks: false, errorClicks: {} }, true], + [{ deadClicks: false, errorClicks: false, rageClicks: true }, false], + ])('reports dead/error detector availability for %p', (frustrationInteractions, enabled) => { + const families = semanticAutocaptureFamilies({ elementInteractions: true, frustrationInteractions }); + expect(families.includes('frustrationInteractions')).toBe(enabled); + expect(families).toContain('elementInteractions'); + }); +}); diff --git a/packages/session-replay-browser/package.json b/packages/session-replay-browser/package.json index ad0743e849..4c39ff85a3 100644 --- a/packages/session-replay-browser/package.json +++ b/packages/session-replay-browser/package.json @@ -40,6 +40,7 @@ "url": "https://github.com/amplitude/Amplitude-TypeScript/issues" }, "dependencies": { + "@amplitude/amp-semantic": "workspace:*", "@amplitude/analytics-core": "workspace:*", "@amplitude/analytics-types": "workspace:*", "@amplitude/rrweb-plugin-console-record": "2.1.5", diff --git a/packages/session-replay-browser/src/config/joined-config.ts b/packages/session-replay-browser/src/config/joined-config.ts index 2c3f9e5cc9..3dba68c54f 100644 --- a/packages/session-replay-browser/src/config/joined-config.ts +++ b/packages/session-replay-browser/src/config/joined-config.ts @@ -77,6 +77,7 @@ export class SessionReplayJoinedConfigGenerator { const samplingConfig = namespaceConfig.sr_sampling_config; const privacyConfig = namespaceConfig.sr_privacy_config; const targetingConfig = namespaceConfig.sr_targeting_config; + const semanticConfig = namespaceConfig.sr_semantic_config; const ugcFilterRules = config.interactionConfig?.ugcFilterRules; // This is intentionally forced to only be set through the remote config. @@ -88,8 +89,11 @@ export class SessionReplayJoinedConfigGenerator { // This is intentionally forced to only be set through the remote config. config.loggingConfig = namespaceConfig.sr_logging_config; - if (samplingConfig || privacyConfig || targetingConfig) { + if (samplingConfig || privacyConfig || targetingConfig || semanticConfig) { sessionReplayRemoteConfig = {}; + if (semanticConfig) { + sessionReplayRemoteConfig.sr_semantic_config = semanticConfig; + } if (samplingConfig) { sessionReplayRemoteConfig.sr_sampling_config = samplingConfig; } @@ -127,7 +131,16 @@ export class SessionReplayJoinedConfigGenerator { sr_sampling_config: samplingConfig, sr_privacy_config: remotePrivacyConfig, sr_targeting_config: targetingConfig, + sr_semantic_config: semanticConfig, } = sessionReplayRemoteConfig; + if (semanticConfig) { + config.semanticCapture = { + enabled: false, + ...config.semanticCapture, + ...semanticConfig, + families: { ...config.semanticCapture?.families, ...semanticConfig.families }, + }; + } if (samplingConfig && Object.keys(samplingConfig).length > 0) { if (Object.prototype.hasOwnProperty.call(samplingConfig, 'capture_enabled')) { config.captureEnabled = samplingConfig.capture_enabled; diff --git a/packages/session-replay-browser/src/config/local-config.ts b/packages/session-replay-browser/src/config/local-config.ts index 9142fb5b61..f0c3cab2a1 100644 --- a/packages/session-replay-browser/src/config/local-config.ts +++ b/packages/session-replay-browser/src/config/local-config.ts @@ -1,3 +1,4 @@ +import type { AnalyticsContext, SemanticCaptureConfig } from '@amplitude/amp-semantic'; import { Config, ILogger, Logger, FetchTransport, LogLevel } from '@amplitude/analytics-core'; import { DEFAULT_FLUSH_MAX_INTERVAL_MS, @@ -36,6 +37,8 @@ export class SessionReplayLocalConfig extends Config implements ISessionReplayLo apiKey: string; sampleRate: number; privacyConfig?: PrivacyConfig; + semanticCapture?: SemanticCaptureConfig; + semanticAnalyticsContext?: AnalyticsContext; interactionConfig?: InteractionConfig; debugMode?: boolean; configServerUrl?: string; @@ -78,6 +81,8 @@ export class SessionReplayLocalConfig extends Config implements ISessionReplayLo this.apiKey = apiKey; this.sampleRate = options.sampleRate || DEFAULT_SAMPLE_RATE; + this.semanticCapture = options.semanticCapture; + this.semanticAnalyticsContext = options.semanticAnalyticsContext; this.serverZone = options.serverZone || DEFAULT_SERVER_ZONE; this.configServerUrl = options.configServerUrl; this.trackServerUrl = options.trackServerUrl; diff --git a/packages/session-replay-browser/src/config/types.ts b/packages/session-replay-browser/src/config/types.ts index 2e30be91c4..c97de69529 100644 --- a/packages/session-replay-browser/src/config/types.ts +++ b/packages/session-replay-browser/src/config/types.ts @@ -1,3 +1,4 @@ +import type { AnalyticsContext, SemanticCaptureConfig } from '@amplitude/amp-semantic'; import type { PrivacyConfig } from '@amplitude/session-replay-dom-privacy'; export type { PrivacyConfig, MaskLevel } from '@amplitude/session-replay-dom-privacy'; import { IConfig, LogLevel, ILogger } from '@amplitude/analytics-core'; @@ -44,6 +45,7 @@ export type SessionReplayRemoteConfig = { sr_interaction_config?: InteractionConfig; sr_logging_config?: LoggingConfig; sr_targeting_config?: TargetingConfig; + sr_semantic_config?: Partial; }; export interface SessionReplayRemoteConfigAPIResponse { @@ -175,6 +177,10 @@ export interface SessionReplayLocalConfig extends IConfig { */ sampleRate: number; privacyConfig?: PrivacyConfig; + /** Optional semantic events in the replay stream. Disabled by default. */ + semanticCapture?: SemanticCaptureConfig; + /** @internal Supplied by the analytics plugin host. */ + semanticAnalyticsContext?: AnalyticsContext; /** * Adds additional debug event property to help debug instrumentation issues * (such as mismatching apps). Only recommended for debugging initial setup, diff --git a/packages/session-replay-browser/src/semantic-identity.ts b/packages/session-replay-browser/src/semantic-identity.ts new file mode 100644 index 0000000000..6de78e2bdf --- /dev/null +++ b/packages/session-replay-browser/src/semantic-identity.ts @@ -0,0 +1,58 @@ +import { getDocumentIdentity, SemanticIdentity } from '@amplitude/amp-semantic'; + +export const SEMANTIC_TAB_STORAGE_KEY = 'AMP_semantic_tab_v1'; +const identities = new WeakMap(); +type StoredTab = { tab_id: string; owner: string | null }; +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** Called only when semantic capture accepts an event. Shared by SDK instances in this document. */ +export function getSemanticIdentity(scope: Window): SemanticIdentity { + const document = scope.document; + const existing = identities.get(document); + if (existing) return existing; + let identity = getDocumentIdentity(document, scope); + // Child frames share their parent's sessionStorage. Do not claim its tab record. + if (scope.top === scope) { + try { + const storage = scope.sessionStorage; + const read = (): StoredTab | undefined => { + try { + const value = JSON.parse(storage.getItem(SEMANTIC_TAB_STORAGE_KEY) ?? 'null') as StoredTab | null; + if (value && uuidPattern.test(value.tab_id) && (value.owner === null || typeof value.owner === 'string')) { + return value; + } + } catch { + // Corrupt or blocked storage is equivalent to no saved identity. + } + return undefined; + }; + const stored = read(); + // A copied record still owned by a live document belongs to the opener/duplicated tab. + if (stored?.owner === null) identity = Object.freeze({ ...identity, tab_id: stored.tab_id }); + const write = (owner: string | null) => { + try { + storage.setItem(SEMANTIC_TAB_STORAGE_KEY, JSON.stringify({ tab_id: identity.tab_id, owner })); + } catch { + // Keep the current in-memory identity stable if storage becomes unavailable. + } + }; + storage.setItem( + SEMANTIC_TAB_STORAGE_KEY, + JSON.stringify({ tab_id: identity.tab_id, owner: identity.document_id }), + ); + scope.addEventListener('pagehide', () => { + // Release on actual navigation, not beforeunload (which can be cancelled). + // This also allows same-tab navigation while the current document enters bfcache. + if (read()?.owner === identity.document_id) write(null); + }); + scope.addEventListener('pageshow', (event) => { + if (event.persisted) write(identity.document_id); + }); + } catch { + // No storage access: identity lasts for this document only. + identity = getDocumentIdentity(document, scope); + } + } + identities.set(document, identity); + return identity; +} diff --git a/packages/session-replay-browser/src/semantic.ts b/packages/session-replay-browser/src/semantic.ts new file mode 100644 index 0000000000..8ed9941f9b --- /dev/null +++ b/packages/session-replay-browser/src/semantic.ts @@ -0,0 +1,53 @@ +import { createSemanticCapture, SEMANTIC_CAPS, SemanticCaptureConfig, SemanticHost } from '@amplitude/amp-semantic'; +import { EventType, eventWithTime } from '@amplitude/rrweb-types'; + +/** Starts capture only after rrweb can accept custom events. */ +export class ReplaySemanticAdapter { + private readonly capture: ReturnType; + private sessionId?: string | number; + private ready = false; + private pendingSnapshots = 0; + private config: SemanticCaptureConfig = { enabled: false }; + + constructor(host: SemanticHost) { + this.capture = createSemanticCapture(this.config, host); + } + + prepare(config: SemanticCaptureConfig, sessionId: string | number): void { + this.stop(); + this.config = config; + this.capture.updateConfig(config); + if (this.sessionId !== sessionId) this.capture.resetSession(); + this.sessionId = sessionId; + } + + recordingReady(): void { + if (!this.config.enabled || this.ready) return; + this.ready = true; + this.capture.start(); + const pending = this.pendingSnapshots; + this.pendingSnapshots = 0; + for (let i = 0; i < pending; i++) this.snapshot(); + } + + /** Called only for replay events accepted for delivery; custom events cannot recurse. */ + event(event: eventWithTime): void { + if (event.type === EventType.FullSnapshot) this.snapshot(); + } + + private snapshot(): void { + if (!this.config.enabled) return; + if (!this.ready) { + // No DOM retention; bound startup work by the context event budget. + this.pendingSnapshots = Math.min(this.pendingSnapshots + 1, SEMANTIC_CAPS.context); + return; + } + if (this.capture.isActive()) this.capture.notify('snapshot', undefined); + } + + stop(): void { + this.ready = false; + this.pendingSnapshots = 0; + this.capture.stop(); + } +} diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index 5221cd6bff..98ec445c00 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -1,3 +1,5 @@ +import { ReplaySemanticAdapter } from './semantic'; +import { getSemanticIdentity } from './semantic-identity'; import { createPrivacyRecorderOptions, getBlockSelectors, @@ -110,6 +112,7 @@ export class SessionReplay implements AmplitudeSessionReplay { // Cache the dynamically imported record function private recordFunction: RecordFunction | null = null; + private semanticAdapter?: ReplaySemanticAdapter; private recordEventsInFlight = false; /** * When false, capture is paused by a customer `stop()` (or `shutdown()`) call. @@ -243,6 +246,8 @@ export class SessionReplay implements AmplitudeSessionReplay { // Re-init should always tear down any previous URL-change subscription, even when the // next config has no targeting config and we don't subscribe again. this.urlChangeCleanup?.(); + this.semanticAdapter?.stop(); + this.semanticAdapter = undefined; // A new init always allows capture again. `stop()` only pauses the current instance. this.recordingEnabled = true; this.startChildRecordingOnSetup = false; @@ -466,6 +471,9 @@ export class SessionReplay implements AmplitudeSessionReplay { } const isSessionChange = previousSessionId !== sessionId; + if (isSessionChange && this.config?.semanticCapture?.enabled && this.semanticAdapter) { + this.stopRecordingEvents(); + } // Drop any beacon-buffered events from the previous session BEFORE installing the // new identifiers / start time. Otherwise the page-leave beacon path could attribute @@ -930,6 +938,7 @@ 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(); return; } this.stopRecordingEvents(); @@ -1023,6 +1032,7 @@ export class SessionReplay implements AmplitudeSessionReplay { return; } + this.prepareSemanticCapture(config, sessionId); this.recordCancelCallback = recordFunction({ ...this.buildRRWebRecordOptions( config, @@ -1051,6 +1061,7 @@ export class SessionReplay implements AmplitudeSessionReplay { // Buffer the event so it can be flushed once the compressor is initialized. this.pendingEmitEvents.push({ event, sessionId }); } + this.semanticAdapter?.event(event); }, 'Error while capturing replay: ', ), @@ -1058,6 +1069,8 @@ export class SessionReplay implements AmplitudeSessionReplay { recordCrossOriginIframes: crossOriginIframesEnabled, }); + if (this.recordCancelCallback) this.semanticAdapter?.recordingReady(); + if (crossOriginIframesEnabled && !childMode && coordinateChildren) { if (!this.crossOriginIframeCoordinator) { this.crossOriginIframeCoordinator = new CrossOriginIframeCoordinator(); @@ -1070,10 +1083,39 @@ export class SessionReplay implements AmplitudeSessionReplay { void this.addCustomRRWebEvent(CustomRRwebEvent.METADATA, this.metadata); } } catch (error) { + this.semanticAdapter?.stop(); this.loggerProvider.warn('Failed to initialize session replay:', error); } } + private prepareSemanticCapture(config: SessionReplayJoinedConfig, sessionId: string | number): void { + const scope = getGlobalScope() as Window | undefined; + if (!scope?.document) return; + if (!config.semanticCapture?.enabled) { + this.semanticAdapter?.stop(); + this.semanticAdapter?.prepare({ enabled: false }, sessionId); + return; + } + if (!this.semanticAdapter) { + this.semanticAdapter = new ReplaySemanticAdapter({ + document: scope.document, + window: scope, + sdk: `@amplitude/session-replay-browser@${VERSION}`, + getPrivacy: () => this.config?.privacyConfig ?? {}, + getIdentity: () => getSemanticIdentity(scope), + getAnalyticsContext: () => this.config?.semanticAnalyticsContext ?? { present: false, autocapture: [] }, + inlineStylesheet: config.shouldInlineStylesheet, + emit: (event) => { + if (this.recordCancelCallback && this.recordFunction) { + this.recordFunction.addCustomEvent(`amp-semantic/${event.name}`, event.payload); + } + }, + onError: (error) => this.loggerProvider.debug('Semantic capture error:', error), + }); + } + this.semanticAdapter.prepare(config.semanticCapture, sessionId); + } + private buildRRWebRecordOptions( config: SessionReplayJoinedConfig, hooks: { mouseInteraction?: any; scroll?: scrollCallback }, @@ -1197,6 +1239,7 @@ export class SessionReplay implements AmplitudeSessionReplay { stopRecordingEvents = () => { try { + this.semanticAdapter?.stop(); this.loggerProvider.log('Session Replay capture stopping.'); this.recordCancelCallback && this.recordCancelCallback(); this.recordCancelCallback = null; diff --git a/packages/session-replay-browser/test/config/joined-config.test.ts b/packages/session-replay-browser/test/config/joined-config.test.ts index f7d9cc8ce9..459c6eae29 100644 --- a/packages/session-replay-browser/test/config/joined-config.test.ts +++ b/packages/session-replay-browser/test/config/joined-config.test.ts @@ -139,6 +139,31 @@ describe('SessionReplayJoinedConfigGenerator', () => { }); }); + describe('semantic capture configuration', () => { + test('remote family overrides preserve other local families and can kill capture', async () => { + mockRemoteConfig = { + sr_semantic_config: { enabled: false, families: { context: false } }, + }; + const generator = await createSessionReplayJoinedConfigGenerator('static_key', { + ...mockOptions, + semanticCapture: { enabled: true, sampleRate: 0.25, families: { context: true, visibility: false } }, + }); + const { joinedConfig, remoteConfig } = await generator.generateJoinedConfig(); + expect(joinedConfig.semanticCapture).toEqual({ + enabled: false, + sampleRate: 0.25, + families: { context: false, visibility: false }, + }); + expect(remoteConfig?.sr_semantic_config?.enabled).toBe(false); + }); + + test('a partial remote semantic config does not enable capture by default', async () => { + mockRemoteConfig = { sr_semantic_config: { families: { context: true } } }; + const { joinedConfig } = await joinedConfigGenerator.generateJoinedConfig(); + expect(joinedConfig.semanticCapture).toEqual({ enabled: false, families: { context: true } }); + }); + }); + describe('with successful sampling config fetch', () => { test('should use sample_rate and capture_enabled from API', async () => { mockRemoteConfig = { diff --git a/packages/session-replay-browser/test/semantic-identity.test.ts b/packages/session-replay-browser/test/semantic-identity.test.ts new file mode 100644 index 0000000000..fd78225760 --- /dev/null +++ b/packages/session-replay-browser/test/semantic-identity.test.ts @@ -0,0 +1,118 @@ +import { getSemanticIdentity, SEMANTIC_TAB_STORAGE_KEY } from '../src/semantic-identity'; + +function storage(initial?: string) { + const values = new Map(); + if (initial !== undefined) values.set(SEMANTIC_TAB_STORAGE_KEY, initial); + return { + getItem: jest.fn((key: string) => values.get(key) ?? null), + setItem: jest.fn((key: string, value: string) => values.set(key, value)), + } as unknown as Storage; +} + +function page(store: Storage) { + const scope = Object.assign(new EventTarget(), { + document: document.implementation.createHTMLDocument('test'), + sessionStorage: store, + crypto: window.crypto, + }) as unknown as Window; + Object.defineProperty(scope, 'top', { value: scope, configurable: true }); + return scope; +} + +describe('semantic replay identity', () => { + test('shares identity in one document and preserves the tab across navigation and bfcache', () => { + const store = storage(); + const first = page(store); + const identity = getSemanticIdentity(first); + expect(getSemanticIdentity(first)).toBe(identity); + // A cancelled beforeunload must not release the live owner. + first.dispatchEvent(new Event('beforeunload')); + expect(JSON.parse(store.getItem(SEMANTIC_TAB_STORAGE_KEY) ?? '{}').owner).toBe(identity.document_id); + first.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })); + const next = page(store); + const nextIdentity = getSemanticIdentity(next); + expect(nextIdentity.tab_id).toBe(identity.tab_id); + expect(nextIdentity.document_id).not.toBe(identity.document_id); + next.dispatchEvent(new PageTransitionEvent('pagehide')); + first.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + expect(getSemanticIdentity(first)).toBe(identity); + expect(JSON.parse(store.getItem(SEMANTIC_TAB_STORAGE_KEY) ?? '{}').owner).toBe(identity.document_id); + }); + + test('a duplicate with copied sessionStorage gets a different tab without changing the opener', () => { + const store = storage(); + const first = getSemanticIdentity(page(store)); + const duplicateStore = storage(store.getItem(SEMANTIC_TAB_STORAGE_KEY) ?? undefined); + const duplicate = getSemanticIdentity(page(duplicateStore)); + expect(duplicate.tab_id).not.toBe(first.tab_id); + expect(duplicate.document_id).not.toBe(first.document_id); + expect(JSON.parse(store.getItem(SEMANTIC_TAB_STORAGE_KEY) ?? '{}').tab_id).toBe(first.tab_id); + }); + + test('independent tabs and corrupt storage produce fresh IDs', () => { + const first = getSemanticIdentity(page(storage())); + const second = getSemanticIdentity(page(storage('not json'))); + const invalid = getSemanticIdentity(page(storage('{"tab_id":"bad","owner":null}'))); + expect(new Set([first.tab_id, second.tab_id, invalid.tab_id]).size).toBe(3); + expect(first.document_id).toMatch(/^[a-f0-9-]{36}$/); + }); + + test('blocked storage falls back to stable in-memory identity', () => { + const scope = page(storage()); + Object.defineProperty(scope, 'sessionStorage', { + get: () => { + throw new Error('blocked'); + }, + }); + const first = getSemanticIdentity(scope); + expect(getSemanticIdentity(scope)).toBe(first); + expect(first.tab_id).toEqual(expect.any(String)); + expect(first.document_id).toEqual(expect.any(String)); + }); + + test('a failed initial claim does not adopt a stored tab ID', () => { + const storedId = '2dbe15a7-3c89-4d6c-a807-03d6288e2141'; + const store = storage(JSON.stringify({ tab_id: storedId, owner: null })); + jest.spyOn(store, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + const scope = page(store); + const identity = getSemanticIdentity(scope); + expect(identity.tab_id).not.toBe(storedId); + expect(getSemanticIdentity(scope)).toBe(identity); + }); + + test('storage failure on pagehide never changes an already emitted identity', () => { + const store = storage(); + const scope = page(store); + const identity = getSemanticIdentity(scope); + jest.spyOn(store, 'setItem').mockImplementation(() => { + throw new Error('quota'); + }); + expect(() => scope.dispatchEvent(new PageTransitionEvent('pagehide'))).not.toThrow(); + expect(getSemanticIdentity(scope)).toBe(identity); + }); + + test('child frames do not overwrite the parent tab storage record', () => { + const store = storage(); + const parent = page(store); + const parentIdentity = getSemanticIdentity(parent); + const child = page(store); + Object.defineProperty(child, 'top', { value: parent }); + const before = store.getItem(SEMANTIC_TAB_STORAGE_KEY); + const childIdentity = getSemanticIdentity(child); + expect(store.getItem(SEMANTIC_TAB_STORAGE_KEY)).toBe(before); + expect(childIdentity.document_id).not.toBe(parentIdentity.document_id); + }); + test('does not reclaim storage erased before pagehide', () => { + const store = storage(); + const scope = page(store); + const identity = getSemanticIdentity(scope); + jest.spyOn(store, 'getItem').mockReturnValue(null); + const writes = jest.spyOn(store, 'setItem'); + writes.mockClear(); + scope.dispatchEvent(new Event('pagehide')); + expect(writes).not.toHaveBeenCalled(); + expect(getSemanticIdentity(scope)).toBe(identity); + }); +}); diff --git a/packages/session-replay-browser/test/semantic-integration.test.ts b/packages/session-replay-browser/test/semantic-integration.test.ts new file mode 100644 index 0000000000..be70f80753 --- /dev/null +++ b/packages/session-replay-browser/test/semantic-integration.test.ts @@ -0,0 +1,173 @@ +import { createSemanticCapture } from '@amplitude/amp-semantic'; +import { getGlobalScope } from '@amplitude/analytics-core'; +import { EventType } from '@amplitude/rrweb-types'; +import { SessionReplay } from '../src/session-replay'; +import { SessionReplayJoinedConfig } from '../src/config/types'; + +jest.mock('@amplitude/amp-semantic', () => ({ + ...jest.requireActual('@amplitude/amp-semantic'), + createSemanticCapture: jest.fn(), +})); +jest.mock('@amplitude/analytics-core', () => ({ + ...jest.requireActual('@amplitude/analytics-core'), + getGlobalScope: jest.fn(), +})); +jest.mock('../src/worker', () => ({ compressionScript: '' }), { virtual: true }); + +describe('Session Replay context and visibility integration', () => { + const capture = { + isActive: jest.fn(() => true), + start: jest.fn(), + stop: jest.fn(), + updateConfig: jest.fn(), + resetSession: jest.fn(), + notify: jest.fn(), + }; + beforeEach(() => { + jest.clearAllMocks(); + capture.isActive.mockReturnValue(true); + jest.mocked(getGlobalScope).mockReturnValue(window as unknown as typeof globalThis); + jest.mocked(createSemanticCapture).mockReturnValue(capture); + }); + + function prepare(enabled: boolean) { + const sdk = new SessionReplay(); + sdk.config = { + semanticCapture: { enabled }, + privacyConfig: { defaultMaskLevel: 'conservative' }, + } as SessionReplayJoinedConfig; + const record = Object.assign(jest.fn(), { + addCustomEvent: jest.fn(), + takeFullSnapshot: jest.fn(), + mirror: { getNode: jest.fn() }, + }); + const internal = sdk as any; + internal.recordFunction = record; + internal.prepareSemanticCapture(sdk.config, 1); + return { sdk, record, internal }; + } + + test('disabled capture does not construct a runtime or change session-switch behavior', async () => { + const { sdk } = prepare(false); + expect(createSemanticCapture).not.toHaveBeenCalled(); + sdk.identifiers = { sessionId: 1, deviceId: 'device' } as typeof sdk.identifiers; + sdk.recordEvents = jest.fn().mockResolvedValue(undefined); + const stop = jest.spyOn(sdk, 'stopRecordingEvents'); + await sdk.asyncSetSessionId(2); + expect(stop).not.toHaveBeenCalled(); + }); + + test('emits through rrweb only while recording and reads current privacy and analytics settings', () => { + const { sdk, record, internal } = prepare(true); + const host = jest.mocked(createSemanticCapture).mock.calls[0][1]; + const event = { + name: 'visibility' as const, + timestamp: 123, + payload: { v: 1 as const, state: 'hidden' as const, tab_id: 't', document_id: 'd' }, + }; + host.emit(event); + expect(record.addCustomEvent).not.toHaveBeenCalled(); + sdk.recordCancelCallback = jest.fn(); + host.emit(event); + expect(record.addCustomEvent).toHaveBeenCalledWith('amp-semantic/visibility', event.payload); + expect(host.getPrivacy()).toEqual({ defaultMaskLevel: 'conservative' }); + expect(host.getIdentity!()).toEqual({ tab_id: expect.any(String), document_id: expect.any(String) }); + expect(host.getAnalyticsContext!()).toEqual({ present: false, autocapture: [] }); + internal.config.semanticAnalyticsContext = { present: true, autocapture: ['pageViews'] }; + expect(host.getAnalyticsContext!()).toEqual({ present: true, autocapture: ['pageViews'] }); + const debug = jest.spyOn(internal.loggerProvider, 'debug'); + host.onError!(new Error('test')); + expect(debug).toHaveBeenCalledWith('Semantic capture error:', expect.any(Error)); + internal.config = undefined; + internal.recordFunction = undefined; + expect(host.getPrivacy()).toEqual({}); + expect(host.getAnalyticsContext!()).toEqual({ present: false, autocapture: [] }); + host.emit(event); + expect(record.addCustomEvent).toHaveBeenCalledTimes(1); + }); + + test('turning capture off updates the retained runtime and clears its listeners', () => { + const { sdk, internal } = prepare(true); + internal.prepareSemanticCapture({ ...sdk.config, semanticCapture: { enabled: false } }, 1); + expect(capture.stop).toHaveBeenCalled(); + expect(capture.updateConfig).toHaveBeenLastCalledWith({ enabled: false }); + }); + + test('skips setup without a browser document', () => { + jest.mocked(getGlobalScope).mockReturnValue(undefined); + prepare(true); + jest.mocked(getGlobalScope).mockReturnValue({} as typeof globalThis); + prepare(true); + expect(createSemanticCapture).not.toHaveBeenCalled(); + }); + + test('stops the old semantic stream before changing sessions', async () => { + const { sdk } = prepare(true); + sdk.identifiers = { sessionId: 1, deviceId: 'device' } as typeof sdk.identifiers; + sdk.recordEvents = jest.fn().mockResolvedValue(undefined); + const stop = jest.spyOn(sdk, 'stopRecordingEvents'); + await sdk.asyncSetSessionId(2); + expect(stop).toHaveBeenCalledTimes(1); + expect(capture.stop).toHaveBeenCalled(); + }); + + test('stops active semantic capture when replay becomes ineligible', async () => { + const { sdk, internal } = prepare(true); + sdk.recordCancelCallback = jest.fn(); + jest.spyOn(internal, 'getShouldRecord').mockReturnValue(false); + const stop = jest.spyOn(sdk, 'stopRecordingEvents'); + await internal._recordEvents(); + expect(stop).toHaveBeenCalledTimes(1); + stop.mockClear(); + await internal._recordEvents(); + 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(); + sdk.recordCancelCallback = () => { + observed(capture.stop.mock.calls.length); + }; + capture.stop.mockClear(); + sdk[method](); + expect(observed).toHaveBeenCalledWith(1); + }); + + test('connects accepted snapshots after recorder startup and never adds a click hook', 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((options) => { + options.emit({ type: EventType.FullSnapshot, timestamp: 1, data: {} }); + expect(capture.notify).not.toHaveBeenCalled(); + return jest.fn(); + }); + await internal._recordEvents(); + const options = record.mock.calls[0][0]; + expect(options.hooks.mouseInteraction).toBeUndefined(); + 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/visibility', payload: {} } }); + expect(enqueued).toHaveBeenCalledTimes(1); + expect(capture.notify).toHaveBeenCalledTimes(1); + jest.spyOn(internal, 'shouldOptOut').mockReturnValue(true); + options.emit({ type: EventType.FullSnapshot, timestamp: 3, data: {} }); + expect(enqueued).toHaveBeenCalledTimes(1); + expect(capture.notify).toHaveBeenCalledTimes(1); + jest.spyOn(internal, 'shouldOptOut').mockReturnValue(false); + record.mockImplementationOnce(() => { + throw new Error('rrweb failed'); + }); + const stop = jest.spyOn(internal.semanticAdapter, 'stop'); + await internal._recordEvents(); + expect(stop).toHaveBeenCalled(); + sdk.stopRecordingEvents(); + }); +}); diff --git a/packages/session-replay-browser/test/semantic.test.ts b/packages/session-replay-browser/test/semantic.test.ts new file mode 100644 index 0000000000..367b28db9c --- /dev/null +++ b/packages/session-replay-browser/test/semantic.test.ts @@ -0,0 +1,79 @@ +import { createSemanticCapture, SEMANTIC_CAPS, SemanticHost } from '@amplitude/amp-semantic'; +import { eventWithTime, EventType } from '@amplitude/rrweb-types'; +import { ReplaySemanticAdapter } from '../src/semantic'; + +jest.mock('@amplitude/amp-semantic', () => ({ + ...jest.requireActual('@amplitude/amp-semantic'), + createSemanticCapture: jest.fn(), +})); + +describe('ReplaySemanticAdapter', () => { + const capture = { + isActive: jest.fn(() => true), + start: jest.fn(), + stop: jest.fn(), + updateConfig: jest.fn(), + resetSession: jest.fn(), + notify: jest.fn(), + }; + const host: SemanticHost = { document, window, sdk: 'test', emit: jest.fn(), getPrivacy: () => ({}) }; + const snapshot = { type: EventType.FullSnapshot, timestamp: 1 } as eventWithTime; + beforeEach(() => { + jest.clearAllMocks(); + capture.isActive.mockReturnValue(true); + jest.mocked(createSemanticCapture).mockReturnValue(capture); + }); + + test('waits for rrweb readiness, then forwards each snapshot once without custom-event recursion', () => { + const adapter = new ReplaySemanticAdapter(host); + adapter.prepare({ enabled: true }, 1); + adapter.event(snapshot); + expect(capture.notify).not.toHaveBeenCalled(); + adapter.recordingReady(); + adapter.recordingReady(); + expect(capture.start).toHaveBeenCalledTimes(1); + 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); + }); + + test('clears queued snapshots on stop and prepare; preserves session sampling until the session changes', () => { + const adapter = new ReplaySemanticAdapter(host); + adapter.prepare({ enabled: true }, 1); + adapter.event(snapshot); + adapter.stop(); + adapter.recordingReady(); + expect(capture.notify).not.toHaveBeenCalled(); + adapter.prepare({ enabled: true }, 1); + adapter.event(snapshot); + adapter.prepare({ enabled: true }, 2); + adapter.recordingReady(); + expect(capture.notify).not.toHaveBeenCalled(); + expect(capture.resetSession).toHaveBeenCalledTimes(2); + expect(capture.stop).toHaveBeenCalled(); + }); + + test('does not deliver snapshots when disabled or sampled out', () => { + const adapter = new ReplaySemanticAdapter(host); + adapter.prepare({ enabled: false }, 1); + adapter.event(snapshot); + adapter.recordingReady(); + expect(capture.start).not.toHaveBeenCalled(); + capture.isActive.mockReturnValue(false); + adapter.prepare({ enabled: true, sampleRate: 0.01 }, 1); + adapter.event(snapshot); + adapter.recordingReady(); + adapter.event(snapshot); + expect(capture.notify).not.toHaveBeenCalled(); + }); + + test('bounds deferred startup work without retaining DOM snapshots', () => { + const adapter = new ReplaySemanticAdapter(host); + adapter.prepare({ enabled: true }, 1); + for (let i = 0; i < 1000; i++) adapter.event(snapshot); + adapter.recordingReady(); + expect(capture.notify).toHaveBeenCalledTimes(SEMANTIC_CAPS.context); + }); +}); diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index 5c74ab5b42..dc496b3246 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -356,6 +356,14 @@ describe('SessionReplay', () => { }); describe('init', () => { + test('reinitialization stops and discards the previous semantic adapter', async () => { + const stop = jest.fn(); + (sessionReplay as any).semanticAdapter = { stop }; + await sessionReplay.init(apiKey, mockOptions).promise; + expect(stop).toHaveBeenCalledTimes(1); + expect((sessionReplay as any).semanticAdapter).toBeUndefined(); + }); + test('should pass current page to evaluateTargetingAndCapture during init', async () => { const evaluateTargetingAndCaptureSpy = jest.spyOn(sessionReplay, 'evaluateTargetingAndCapture'); await sessionReplay.init(apiKey, mockOptions).promise; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b8f3b949e..3e142aa51d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,6 +304,19 @@ importers: specifier: ^4.9.4 version: 4.9.5 + packages/amp-semantic: + dependencies: + '@amplitude/session-replay-dom-privacy': + specifier: workspace:* + version: link:../session-replay-dom-privacy + tslib: + specifier: ^2.4.1 + version: 2.8.1 + devDependencies: + ajv: + specifier: ^8.17.1 + version: 8.17.1 + packages/analytics-browser: dependencies: '@amplitude/analytics-core': @@ -1078,6 +1091,9 @@ importers: packages/session-replay-browser: dependencies: + '@amplitude/amp-semantic': + specifier: workspace:* + version: link:../amp-semantic '@amplitude/analytics-core': specifier: workspace:* version: link:../analytics-core diff --git a/test-server/session-replay-browser/sr-semantic-test.html b/test-server/session-replay-browser/sr-semantic-test.html new file mode 100644 index 0000000000..72343704d2 --- /dev/null +++ b/test-server/session-replay-browser/sr-semantic-test.html @@ -0,0 +1,49 @@ + + + + + + Semantic context and visibility + + + +

Semantic context and visibility

+

+ Context: environment facts at each replay full snapshot: user agent, viewport, masking policy, + visibility, focus and analytics configuration. +

+

+ Visibility: hidden/visible transitions. Switch to another Chrome tab for a few seconds and + return. Keyboard focus changes alone do not emit this event. +

+
+

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 new file mode 100644 index 0000000000..2d89542283 --- /dev/null +++ b/test-server/session-replay-browser/sr-semantic-test.js @@ -0,0 +1,104 @@ +import * as sessionReplay from '@amplitude/session-replay-browser'; + +const params = new URLSearchParams(location.search); +const events = []; +const errors = []; +let ready = false; +const output = document.querySelector('#events'); +const status = document.querySelector('#status'); +const report = (error) => { + errors.push(String(error)); + status.textContent = String(error); +}; + +async function receiveBatch({ body, headers }) { + let raw = body; + if (typeof body !== 'string') { + const gzip = Object.entries(headers).some( + ([key, value]) => key.toLowerCase() === 'content-encoding' && value === 'gzip', + ); + const stream = new Blob([body]).stream(); + raw = await new Response(gzip ? stream.pipeThrough(new DecompressionStream('gzip')) : stream).text(); + } + for (const entry of JSON.parse(raw).events) { + const event = typeof entry === 'string' ? JSON.parse(entry) : entry; + if (event.type === 5 && event.data?.tag?.startsWith('amp-semantic/')) events.push(event); + } + // Bound the inspector's retained data independently of the SDK. + if (events.length > 1000) events.splice(0, events.length - 1000); + output.textContent = JSON.stringify(events, null, 2); + return new Response('{"code":200}', { status: 200 }); +} + +async function flush() { + if (!ready) return; + // Move the current replay sequence into the public flush queue. + window.dispatchEvent(new Event('blur')); + await sessionReplay.flush(false); +} +async function stop() { + sessionReplay.stop(); + await flush(); + status.textContent = 'Recording stopped'; +} +async function start() { + await sessionReplay.start().promise; + status.textContent = 'Recording; switch tabs and flush to inspect events'; +} +document.querySelector('#flush').onclick = () => void flush().catch(report); +document.querySelector('#stop').onclick = () => void stop().catch(report); +document.querySelector('#start').onclick = () => void start().catch(report); +window.semanticHarness = { + get ready() { + return ready; + }, + events, + errors, + flush, + stop, + start, +}; + +async function boot() { + const rate = Number(params.get('sampleRate') ?? 1); + const families = Object.fromEntries( + (params.get('disabled') ?? '') + .split(',') + .filter((name) => ['context', 'visibility'].includes(name)) + .map((name) => [name, false]), + ); + await sessionReplay.init('semantic-local-test-key', { + deviceId: 'semantic-local-device', + sessionId: Date.now(), + sampleRate: 1, + storeType: 'memory', + useWebWorker: false, + logLevel: 0, + semanticCapture: { enabled: params.get('enabled') !== 'false', sampleRate: rate, families }, + privacyConfig: { defaultMaskLevel: 'medium' }, + performanceConfig: { enabled: false }, + eagerFullSnapshotSend: true, + captureFullSnapshotOnFocus: false, + flushIntervalConfig: { minIntervalMs: 0, maxIntervalMs: 1000 }, + trackServerUrl: `${location.origin}/semantic-local-only/track`, + configServerUrl: `${location.origin}/semantic-local-only/config`, + handleSendEvents: receiveBatch, + handleFetchConfig: async () => + new Response( + JSON.stringify({ + configs: { + sessionReplay: { + sr_sampling_config: { capture_enabled: true, sample_rate: 1 }, + ...(params.has('remoteEnabled') + ? { sr_semantic_config: { enabled: params.get('remoteEnabled') === 'true' } } + : {}), + }, + }, + }), + { status: 200 }, + ), + }).promise; + ready = true; + status.textContent = 'Ready; switch tabs and flush to inspect events'; +} +void boot().catch(report);