diff --git a/packages/amp-semantic/README.md b/packages/amp-semantic/README.md
index bc237a47a..a32e4b024 100644
--- a/packages/amp-semantic/README.md
+++ b/packages/amp-semantic/README.md
@@ -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.
diff --git a/packages/amp-semantic/e2e/replay.spec.ts b/packages/amp-semantic/e2e/replay.spec.ts
index 0f7232b22..bc977e5ef 100644
--- a/packages/amp-semantic/e2e/replay.spec.ts
+++ b/packages/amp-semantic/e2e/replay.spec.ts
@@ -76,7 +76,7 @@ for (const query of [
'?enabled=false',
'?sampleRate=0',
'?remoteEnabled=false',
- '?disabled=context,visibility,lifecycle,navigation,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 }) => {
@@ -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);
+});
diff --git a/packages/amp-semantic/schema.json b/packages/amp-semantic/schema.json
index 890055753..0f34cb291 100644
--- a/packages/amp-semantic/schema.json
+++ b/packages/amp-semantic/schema.json
@@ -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
}
]
}
diff --git a/packages/amp-semantic/src/index.ts b/packages/amp-semantic/src/index.ts
index d4b326dfe..99f8363b6 100644
--- a/packages/amp-semantic/src/index.ts
+++ b/packages/amp-semantic/src/index.ts
@@ -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';
@@ -17,6 +18,7 @@ export {
recordingRecorder,
interactionsRecorder,
keysRecorder,
+ resourceErrorRecorder,
};
export const defaultRecorders: readonly SemanticRecorder[] = [
contextRecorder,
@@ -26,6 +28,7 @@ export const defaultRecorders: readonly SemanticRecorder[] = [
recordingRecorder,
interactionsRecorder,
keysRecorder,
+ resourceErrorRecorder,
];
export const SEMANTIC_CAPS = {
context: DEFAULT_SEMANTIC_CAP,
@@ -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';
diff --git a/packages/amp-semantic/src/recorders/resource-error.ts b/packages/amp-semantic/src/recorders/resource-error.ts
new file mode 100644
index 000000000..40a9d9e05
--- /dev/null
+++ b/packages/amp-semantic/src/recorders/resource-error.ts
@@ -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';
+ for (const element of Array.from(document.body.children)) fail(element);
+ document.dispatchEvent(new Event('error'));
+ const image = document.body.firstElementChild as HTMLImageElement;
+ fail(image);
+ image.setAttribute('src', '/next.png');
+ fail(image);
+ jest.advanceTimersByTime(500);
+ expect(events.map((event) => event.payload)).toEqual([
+ expect.objectContaining({ rt: 'img', count: 3 }),
+ expect.objectContaining({ rt: 'script', count: 1 }),
+ expect.objectContaining({ rt: 'css', count: 1 }),
+ expect.objectContaining({ rt: 'img', path: '/next.png', count: 1 }),
+ ]);
+ });
+
+ test('rechecks enablement and ignores late resource and font callbacks', () => {
+ const fonts = new EventTarget();
+ const original = Object.getOwnPropertyDescriptor(document, 'fonts');
+ Object.defineProperty(document, 'fonts', { configurable: true, value: fonts });
+ try {
+ const onDocument = jest.spyOn(document, 'addEventListener');
+ const onWindow = jest.spyOn(window, 'addEventListener');
+ const onFonts = jest.spyOn(fonts, 'addEventListener');
+ let enabled = true;
+ const emit = jest.fn();
+ const host: SemanticHost = {
+ document,
+ window,
+ sdk: 'test',
+ getPrivacy: () => ({ defaultMaskLevel: 'light' }),
+ emit: jest.fn(),
+ };
+ const context = {
+ host,
+ helpers: createHelpers(host),
+ onCleanup: jest.fn(),
+ now: Date.now,
+ enabled: () => enabled,
+ canEmit: () => true,
+ emit,
+ on: jest.fn(),
+ dropped: () => ({}),
+ clearDropped: jest.fn(),
+ } as unknown as RecorderContext;
+ const cleanup = resourceErrorRecorder.setup(context);
+ const onError = onDocument.mock.calls.find(([type]) => type === 'error')![1] as EventListener;
+ const onPagehide = onWindow.mock.calls.find(([type]) => type === 'pagehide')![1] as EventListener;
+ const onFontError = onFonts.mock.calls.find(([type]) => type === 'loadingerror')![1] as EventListener;
+ onError(new Event('error'));
+ onFontError(new Event('loadingerror'));
+ onFontError(Object.assign(new Event('loadingerror'), { fontfaces: [] }));
+ const first = img('/first.png');
+ fail(first);
+ enabled = false;
+ fail(img('/disabled.png'));
+ onFontError(Object.assign(new Event('loadingerror'), { fontfaces: [{}] }));
+ jest.advanceTimersByTime(500);
+ expect(emit).not.toHaveBeenCalled();
+ cleanup();
+ onError(new Event('error'));
+ onPagehide(new Event('pagehide'));
+ onFontError(Object.assign(new Event('loadingerror'), { fontfaces: [{}] }));
+ expect(emit).not.toHaveBeenCalled();
+ } finally {
+ if (original) Object.defineProperty(document, 'fonts', original);
+ else Reflect.deleteProperty(document, 'fonts');
+ }
+ });
+ test('aggregates matching failures and templates paths without origins, queries, or fragments', () => {
+ start(resourceErrorRecorder);
+ fail(img());
+ fail(img());
+ jest.advanceTimersByTime(499);
+ expect(events).toHaveLength(0);
+ jest.advanceTimersByTime(1);
+ expect(events).toHaveLength(1);
+ expect(events[0].payload).toMatchObject({ rt: 'img', count: 2, path: '/products/:id/image.png' });
+ expect(events[0].payload).not.toHaveProperty('container');
+ expect(JSON.stringify(events)).not.toContain('PRIVATE');
+ fail(img());
+ jest.advanceTimersByTime(500);
+ expect(events[1].payload).toMatchObject({ count: 1 });
+ });
+ test('separates containers and rechecks masking, blocking, and detachment before emission', () => {
+ document.body.innerHTML = '

Semantic capture playground
Keys: Escape, Enter, Tab, arrows, Home/End, Page Up/Down, function keys, and Ctrl/Meta K, P, or F.
Ordinary typing and editable text are excluded. Focus a control and press Escape to inspect its target.
+ Resource errors: failed image, stylesheet, script, and font loads, grouped briefly by type and + sanitized path. Runtime JavaScript errors and invalid CSS rules do not trigger this event. +