Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/amp-semantic/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,5 @@ The initial state is present in context. The visibility budget is 200 events per
`amp-semantic/click` describes the actionable target with its rrweb node ID, accessible name, role, states, container, stable key or selector, templated URL, and heading. When rrweb supplies native click events, the recorder shares the existing heatmap hook; auxiliary middle clicks use a small document listener. `amp-semantic/field` emits on change or focusout with value presence, checked state, validity, and a value only when Session Replay privacy settings allow it. Sensitive password and hidden fields are skipped. `amp-semantic/submit` captures form validity, submitter, and up to ten invalid field descriptions without calling `checkValidity()` or reading field text. These families have independent switches and budgets of 2,000, 1,000, and 300 events per session.

`amp-semantic/keys` records navigation keys (Escape, Enter, Tab, arrows, Home/End, Page Up/Down, F1–F12) and Ctrl/Meta shortcuts for K, P, and F. It never records ordinary typing, composition, repeated keys, or target text from editable content. Targets use rrweb IDs and privacy-aware accessible names. Disable this family with `families: { keys: false }`; its budget is 1,000 events per session.

`amp-semantic/resource-error` records failed image, stylesheet, script, and font loads. It groups matching failures for 500 ms, then emits a resource type (`rt`), count, and only a sanitized path and privacy-safe container when available. It does not observe network responses or CSS parsing errors. Pending failures flush on pagehide or when the document becomes hidden. Disable this family with `families: { 'resource-error': false }`; its budget is 200 events per session.
19 changes: 18 additions & 1 deletion packages/amp-semantic/e2e/replay.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ for (const query of [
'?enabled=false',
'?sampleRate=0',
'?remoteEnabled=false',
'?disabled=context,visibility,lifecycle,navigation,recording,click,field,submit,keys',
'?disabled=context,visibility,lifecycle,navigation,recording,click,field,submit,keys,resource-error',
'?createCapture=false&remoteEnabled=true',
]) {
test(`capture gates suppress semantic output: ${query}`, async ({ page }) => {
Expand Down Expand Up @@ -201,3 +201,20 @@ test('allowlisted keys include a privacy-safe target in replay', async ({ page }
expect(key.data.payload).toMatchObject({ key: 'Escape', target: { role: 'button', name: 'Save changes' } });
expect(validate(key), JSON.stringify(validate.errors)).toBe(true);
});

test('failed image loads produce a compact resource event', async ({ page }) => {
await page.route('**/missing-asset.png', (route) => route.abort());
await open(page);
await page.evaluate(() => {
const image = document.createElement('img');
image.src = '/missing-asset.png?private=hidden';
document.body.append(image);
});
await expect
.poll(async () => (await flush(page)).filter((event) => event.data.tag === 'amp-semantic/resource-error').length)
.toBe(1);
const failure = (await flush(page)).find((event) => event.data.tag === 'amp-semantic/resource-error')!;
expect(failure.data.payload).toMatchObject({ rt: 'img', path: '/missing-asset.png', count: 1 });
expect(JSON.stringify(failure)).not.toContain('private=hidden');
expect(validate(failure), JSON.stringify(validate.errors)).toBe(true);
});
95 changes: 95 additions & 0 deletions packages/amp-semantic/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,101 @@
"data"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"const": 5
},
"timestamp": {
"type": "number"
},
"data": {
"type": "object",
"properties": {
"tag": {
"const": "amp-semantic/resource-error"
},
"payload": {
"type": "object",
"properties": {
"rt": {
"enum": [
"img",
"css",
"font",
"script"
]
},
"count": {
"type": "integer",
"minimum": 1,
"maximum": 65535
},
"container": {
"type": "object",
"properties": {
"id": {
"type": [
"integer",
"null"
]
},
"role": {
"type": "string"
},
"name": {
"type": [
"string",
"null"
]
}
},
"required": [
"id",
"role",
"name"
],
"additionalProperties": false
},
"path": {
"type": "string",
"pattern": "^/[^?#]*$"
},
"v": {
"const": 1
},
"tab_id": {
"type": "string",
"minLength": 1
},
"document_id": {
"type": "string",
"minLength": 1
}
},
"required": [
"v",
"rt",
"count"
],
"additionalProperties": false
}
},
"required": [
"tag",
"payload"
],
"additionalProperties": false
}
},
"required": [
"type",
"timestamp",
"data"
],
"additionalProperties": false
}
]
}
4 changes: 4 additions & 0 deletions packages/amp-semantic/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { navigationRecorder } from './recorders/navigation';
import { recordingRecorder } from './recorders/recording';
import { interactionsRecorder } from './recorders/interactions';
import { keysRecorder } from './recorders/keys';
import { resourceErrorRecorder } from './recorders/resource-error';
import type { SemanticCaptureConfig, SemanticHost, SemanticRecorder } from './types';

export { createCapture, DEFAULT_SEMANTIC_CAP } from './capture';
Expand All @@ -17,6 +18,7 @@ export {
recordingRecorder,
interactionsRecorder,
keysRecorder,
resourceErrorRecorder,
};
export const defaultRecorders: readonly SemanticRecorder[] = [
contextRecorder,
Expand All @@ -26,6 +28,7 @@ export const defaultRecorders: readonly SemanticRecorder[] = [
recordingRecorder,
interactionsRecorder,
keysRecorder,
resourceErrorRecorder,
];
export const SEMANTIC_CAPS = {
context: DEFAULT_SEMANTIC_CAP,
Expand All @@ -37,6 +40,7 @@ export const SEMANTIC_CAPS = {
field: 1000,
submit: 300,
keys: 1000,
'resource-error': 200,
};
export * from './types';
export type { SemanticCapture } from './capture';
Expand Down
119 changes: 119 additions & 0 deletions packages/amp-semantic/src/recorders/resource-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { getHelpers } from '../helpers';
import type { SemanticPayloads, SemanticRecorder } from '../types';

type Payload = SemanticPayloads['resource-error'];
type Pending = { element: Element; rt: Payload['rt']; path?: string; count: number };
const WINDOW_MS = 500;
const MAX_PENDING = 100;
const MAX_COUNT = 65535;

/** Observe failures only: no requests, DOM scans, or network API patches. */
export const resourceErrorRecorder: SemanticRecorder = {
families: ['resource-error'],
setup(context) {
const { document, window } = context.host;
const helpers = context.helpers ?? getHelpers(context.host);
let active = true;
let timer: number | undefined;
let pending: Pending[] = [];
let fontsFailed = 0;
const fonts = document.fonts;
const clearTimer = () => {
if (timer !== undefined) window.clearTimeout(timer);
timer = undefined;
};
const flush = () => {
clearTimer();
const batch = pending;
const fontCount = fontsFailed;
pending = [];
fontsFailed = 0;
if (!active || !context.enabled('resource-error')) return;
const groups = new Map<string, Payload>();
for (const item of batch) {
// Re-read privacy and container names at emission; detached nodes lose masking ancestry.
if (!item.element.isConnected || helpers.blocked(item.element)) continue;
const container = helpers.container(item.element);
const payload: Payload = {
rt: item.rt,
count: item.count,
...(item.path ? { path: item.path } : {}),
...(container ? { container } : {}),
};
const key = JSON.stringify([payload.rt, payload.path, payload.container]);
const previous = groups.get(key);
if (previous) previous.count = Math.min(MAX_COUNT, previous.count + payload.count);
else groups.set(key, payload);
}
if (fontCount) context.emit('resource-error', { rt: 'font', count: fontCount });
for (const payload of groups.values()) context.emit('resource-error', payload);
};
const schedule = () => {
if (document.visibilityState === 'hidden') flush();
else if (timer === undefined) timer = window.setTimeout(flush, WINDOW_MS);
};
const resourcePath = (value: string): string | undefined => {
if (!value) return undefined;
try {
const url = new URL(value, document.baseURI);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined;
// Resource fragments are not SPA routes. Never include queries or origins.
return helpers.url(url.origin + url.pathname);
} catch {
return undefined;
}
};
const onError = (event: Event) => {
if (!active || !context.enabled('resource-error')) return;
const element = event.target as Element | null;
if (!element || element.nodeType !== 1 || helpers.blocked(element)) return;
let rt: Payload['rt'];
let url: string;
if (element.tagName === 'IMG') {
rt = 'img';
url = (element as HTMLImageElement).currentSrc || element.getAttribute('src') || '';
} else if (element.tagName === 'SCRIPT' && element.hasAttribute('src')) {
rt = 'script';
url = element.getAttribute('src') || '';
} else if (element.tagName === 'LINK' && (element as HTMLLinkElement).relList.contains('stylesheet')) {
rt = 'css';
url = element.getAttribute('href') || '';
} else return;
const path = resourcePath(url);
const existing = pending.find((item) => item.element === element && item.rt === rt && item.path === path);
if (existing) existing.count = Math.min(MAX_COUNT, existing.count + 1);
else {
if (pending.length >= MAX_PENDING) flush();
pending.push({ element, rt, path, count: 1 });
}
schedule();
};
const onFontError = (event: Event) => {
if (!active || !context.enabled('resource-error')) return;
const faces = (event as Event & { fontfaces?: readonly FontFace[] }).fontfaces;
if (!faces?.length) return;
// FontFaceSet provides failed faces, not their source URLs or affected DOM containers.
fontsFailed = Math.min(MAX_COUNT, fontsFailed + faces.length);
schedule();
};
const onVisibility = () => {
if (document.visibilityState === 'hidden') flush();
};
const cleanup = () => {
active = false;
clearTimer();
pending = [];
fontsFailed = 0;
document.removeEventListener('error', onError, true);
document.removeEventListener('visibilitychange', onVisibility, true);
window.removeEventListener('pagehide', flush, true);
fonts?.removeEventListener('loadingerror', onFontError);
};
context.onCleanup(cleanup);
document.addEventListener('error', onError, true);
document.addEventListener('visibilitychange', onVisibility, true);
window.addEventListener('pagehide', flush, true);
fonts?.addEventListener('loadingerror', onFontError);
return cleanup;
},
};
6 changes: 6 additions & 0 deletions packages/amp-semantic/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export interface SemanticPayloads {
navigation: { url: string };
recording: RecordingSignal;
keys: { key: string; target: NamedTarget & { states: string[] } };
'resource-error': {
rt: 'img' | 'css' | 'font' | 'script';
count: number;
container?: TargetDescription;
path?: string;
};
click: NamedTarget & {
container: TargetDescription | null;
states: string[];
Expand Down
Loading
Loading