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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions packages/amp-semantic/README.md
Original file line number Diff line number Diff line change
@@ -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.
94 changes: 94 additions & 0 deletions packages/amp-semantic/e2e/replay.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> } };
declare global {
interface Window {
semanticHarness: {
ready: boolean;
events: WireEvent[];
errors: string[];
flush: () => Promise<void>;
stop: () => Promise<void>;
start: () => Promise<void>;
};
}
}
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);
});
6 changes: 6 additions & 0 deletions packages/amp-semantic/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
...require('../../jest.config.js'),
displayName: '@amplitude/amp-semantic',
rootDir: '.',
testEnvironment: 'jsdom',
};
38 changes: 38 additions & 0 deletions packages/amp-semantic/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
21 changes: 21 additions & 0 deletions packages/amp-semantic/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
Loading
Loading