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 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; + }, +}; diff --git a/packages/amp-semantic/src/types.ts b/packages/amp-semantic/src/types.ts index b23fa8492..4b02db298 100644 --- a/packages/amp-semantic/src/types.ts +++ b/packages/amp-semantic/src/types.ts @@ -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[]; diff --git a/packages/amp-semantic/test/resource-error.test.ts b/packages/amp-semantic/test/resource-error.test.ts new file mode 100644 index 000000000..1dad5834b --- /dev/null +++ b/packages/amp-semantic/test/resource-error.test.ts @@ -0,0 +1,243 @@ +import { createSemanticCapture, resourceErrorRecorder, SEMANTIC_CAPS } from '../src'; +import { createHelpers } from '../src/helpers'; +import type { + RecorderContext, + SemanticCapture, + SemanticCaptureConfig, + SemanticEvent, + SemanticHost, + SemanticRecorder, +} from '../src'; + +let capture: SemanticCapture; +let events: SemanticEvent[]; +let privacy: ReturnType; +function start(recorder: SemanticRecorder, config: SemanticCaptureConfig = { enabled: true }) { + events = []; + privacy = { defaultMaskLevel: 'light' }; + capture = createSemanticCapture( + config, + { + document, + window, + sdk: 'test', + emit: (event) => events.push(event), + getPrivacy: () => privacy, + getUrl: () => 'https://example.com/page', + getNodeId: (node) => Number((node as Element).getAttribute('data-node')) || 7, + }, + [recorder], + ); + capture.start(); +} +function fail(element: Element) { + element.dispatchEvent(new Event('error')); +} +function img(src = '/products/8812/image.png?secret=PRIVATE#route') { + const element = document.createElement('img'); + element.src = src; + document.body.append(element); + return element; +} + +beforeEach(() => { + jest.useFakeTimers(); + document.body.innerHTML = ''; + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); +}); +afterEach(() => { + capture?.stop(); + jest.restoreAllMocks(); + jest.useRealTimers(); + document.body.innerHTML = ''; +}); + +describe('resource errors', () => { + test('handles empty and malformed URLs, changed paths, and invalid targets', () => { + start(resourceErrorRecorder); + document.body.innerHTML = + ''; + 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 = '
'; + start(resourceErrorRecorder); + const region = document.querySelector('#region')!; + fail(region.querySelector('img')!); + region.className = 'amp-mask'; + const blocked = img('/blocked.png'); + fail(blocked); + blocked.className = 'amp-block'; + const removed = img('/removed.png'); + fail(removed); + removed.remove(); + jest.advanceTimersByTime(500); + expect(events).toHaveLength(1); + expect(events[0].payload).toMatchObject({ container: { role: 'region', name: null } }); + expect(JSON.stringify(events)).not.toContain('PRIVATE'); + }); + test('captures stylesheet and script load failures but ignores runtime errors and preloads', () => { + start(resourceErrorRecorder); + document.body.innerHTML = + '
'; + for (const element of Array.from(document.body.children)) fail(element); + window.dispatchEvent(new ErrorEvent('error', { message: 'PRIVATE' })); + jest.advanceTimersByTime(500); + expect(events.map((event) => event.payload)).toEqual([ + expect.objectContaining({ rt: 'css', count: 1, path: '/theme.css' }), + expect.objectContaining({ rt: 'script', count: 1, path: '/app.js' }), + ]); + }); + test('omits non-http paths but preserves long sanitized paths', () => { + start(resourceErrorRecorder); + fail(img('data:image/png;base64,PRIVATE')); + fail(img('blob:https://example.com/PRIVATE')); + const path = '/assets/' + 'folder/'.repeat(40) + 'image.png'; + fail(img(path + '?private=hidden#secret')); + jest.advanceTimersByTime(500); + expect(events).toHaveLength(2); + expect(events[0].payload).toMatchObject({ rt: 'img', count: 2 }); + expect(events[0].payload).not.toHaveProperty('path'); + expect(events[1].payload).toMatchObject({ rt: 'img', count: 1, path }); + }); + test('reports failed font count without guessing URL/container and removes its listener', () => { + const fonts = new EventTarget(); + const original = Object.getOwnPropertyDescriptor(document, 'fonts'); + Object.defineProperty(document, 'fonts', { configurable: true, value: fonts }); + try { + start(resourceErrorRecorder); + const event = new Event('loadingerror'); + Object.defineProperty(event, 'fontfaces', { value: [{ family: 'PRIVATE' }, { family: 'PRIVATE' }] }); + fonts.dispatchEvent(event); + jest.advanceTimersByTime(500); + expect(events[0].payload).toEqual(expect.objectContaining({ rt: 'font', count: 2 })); + expect(events[0].payload).not.toHaveProperty('path'); + expect(events[0].payload).not.toHaveProperty('container'); + expect(JSON.stringify(events)).not.toContain('PRIVATE'); + capture.stop(); + fonts.dispatchEvent(event); + jest.advanceTimersByTime(500); + expect(events).toHaveLength(1); + } finally { + if (original) Object.defineProperty(document, 'fonts', original); + else Reflect.deleteProperty(document, 'fonts'); + } + }); + test('flushes on pagehide and hidden; discards pending work on disable/stop', () => { + start(resourceErrorRecorder); + fail(img()); + window.dispatchEvent(new Event('pagehide')); + expect(events).toHaveLength(1); + fail(img()); + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + expect(events).toHaveLength(2); + fail(img()); + expect(events).toHaveLength(3); + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + fail(img()); + capture.updateConfig({ enabled: true, families: { 'resource-error': false } }); + jest.advanceTimersByTime(500); + fail(img()); + expect(events).toHaveLength(3); + capture.updateConfig({ enabled: true }); + fail(img()); + capture.stop(); + jest.advanceTimersByTime(500); + fail(img()); + expect(events).toHaveLength(3); + }); + test('bounds pending groups, counts, and emitted events', () => { + start(resourceErrorRecorder); + for (let i = 0; i < 101; i++) fail(img(`/asset-${i}.png`)); + expect(events).toHaveLength(100); + jest.advanceTimersByTime(500); + expect(events).toHaveLength(101); + for (let i = 0; i < 105; i++) { + fail(img('/next.png')); + window.dispatchEvent(new Event('pagehide')); + } + expect(events).toHaveLength(SEMANTIC_CAPS['resource-error']); + }); +}); diff --git a/packages/amp-semantic/test/schema.test.ts b/packages/amp-semantic/test/schema.test.ts index c9e405805..e3eae68cf 100644 --- a/packages/amp-semantic/test/schema.test.ts +++ b/packages/amp-semantic/test/schema.test.ts @@ -50,6 +50,7 @@ test('default capture exposes context and skips payload reads after its cap', () semantic.recordingRecorder, semantic.interactionsRecorder, semantic.keysRecorder, + semantic.resourceErrorRecorder, ]); const host = { document, window, sdk: 'test', getPrivacy: () => ({}), emit: jest.fn() }; const capture = semantic.createSemanticCapture({ enabled: true }, host); diff --git a/test-server/session-replay-browser/sr-semantic-test.html b/test-server/session-replay-browser/sr-semantic-test.html index 71eeb5077..898a1c680 100644 --- a/test-server/session-replay-browser/sr-semantic-test.html +++ b/test-server/session-replay-browser/sr-semantic-test.html @@ -56,6 +56,10 @@

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

diff --git a/test-server/session-replay-browser/sr-semantic-test.js b/test-server/session-replay-browser/sr-semantic-test.js index a9a9dfcf9..835920252 100644 --- a/test-server/session-replay-browser/sr-semantic-test.js +++ b/test-server/session-replay-browser/sr-semantic-test.js @@ -66,7 +66,7 @@ async function boot() { const families = Object.fromEntries( (params.get('disabled') ?? '') .split(',') - .filter((name) => ['context', 'visibility', 'lifecycle', 'navigation', 'recording', 'click', 'field', 'submit', 'keys'].includes(name)) + .filter((name) => ['context', 'visibility', 'lifecycle', 'navigation', 'recording', 'click', 'field', 'submit', 'keys', 'resource-error'].includes(name)) .map((name) => [name, false]), ); await sessionReplay.init('semantic-local-test-key', {