From 46153597235cae4c0dd668a7e7c7baa8cae171f1 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Tue, 25 Aug 2026 12:25:26 +0100 Subject: [PATCH 1/8] feat(replay): capture JSON-LD as custom events --- .changeset/replay-json-ld-events.md | 6 + .../extensions/replay/json-ld.test.ts | 159 +++++++++ .../replay/lazy-sessionrecording.test.ts | 60 +++- .../src/extensions/replay/external/json-ld.ts | 328 ++++++++++++++++++ .../external/lazy-loaded-session-recorder.ts | 28 +- .../config-snapshot.spec.ts.snap | 5 + packages/types/src/posthog-config.ts | 7 + 7 files changed, 591 insertions(+), 2 deletions(-) create mode 100644 .changeset/replay-json-ld-events.md create mode 100644 packages/browser/src/__tests__/extensions/replay/json-ld.test.ts create mode 100644 packages/browser/src/extensions/replay/external/json-ld.ts diff --git a/.changeset/replay-json-ld-events.md b/.changeset/replay-json-ld-events.md new file mode 100644 index 0000000000..5235764f07 --- /dev/null +++ b/.changeset/replay-json-ld-events.md @@ -0,0 +1,6 @@ +--- +'posthog-js': minor +'@posthog/types': patch +--- + +Add opt-in Schema.org JSON-LD capture to session replay through `session_recording.captureJsonLd`. The recorder emits sanitized JSON-LD as custom replay events and excludes all script elements from replay snapshots. diff --git a/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts new file mode 100644 index 0000000000..376e725512 --- /dev/null +++ b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts @@ -0,0 +1,159 @@ +import { sanitizeJsonLd, startJsonLdCapture } from '../../../extensions/replay/external/json-ld' + +function jsonLdScript(value: unknown): HTMLScriptElement { + const script = document.createElement('script') + script.type = 'application/ld+json' + script.textContent = JSON.stringify(value) + return script +} + +async function deliverMutations(): Promise { + await Promise.resolve() +} + +describe('JSON-LD replay capture', () => { + afterEach(() => { + document.body.replaceChildren() + }) + + it('keeps only path-allowed properties and @id values', () => { + const sanitized = sanitizeJsonLd( + JSON.stringify({ + '@context': 'http://schema.org/', + '@type': 'Product', + '@id': 'https://example.com/products/123', + name: 'Camera', + email: 'private@example.com', + manufacturer: { + '@type': 'Organization', + '@id': 'https://example.com/organizations/acme', + name: 'Acme', + email: 'private@example.com', + }, + offers: { + '@type': 'Offer', + price: 100, + seller: { + '@type': 'Person', + name: 'Private name', + }, + }, + }) + ) + + expect(JSON.parse(sanitized!)).toEqual({ + '@context': 'https://schema.org', + '@type': 'Product', + '@id': 'https://example.com/products/123', + name: 'Camera', + manufacturer: { + '@type': 'Organization', + '@id': 'https://example.com/organizations/acme', + name: 'Acme', + }, + offers: { + '@type': 'Offer', + price: 100, + }, + }) + }) + + it('keeps scalar leaf values and drops non-scalar leaf values', () => { + const sanitized = sanitizeJsonLd( + JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: ['Camera', 2, true, null], + sku: { value: 'private' }, + color: ['black', { value: 'private' }], + category: false, + }) + ) + + expect(JSON.parse(sanitized!)).toEqual({ + '@context': 'https://schema.org', + '@type': 'Product', + name: ['Camera', 2, true, null], + category: false, + }) + }) + + it.each([ + 'not json', + JSON.stringify({ '@context': 'https://example.com', '@type': 'Product' }), + JSON.stringify({ '@context': 'https://schema.org', '@type': 'Event' }), + JSON.stringify([ + { '@context': 'https://schema.org', '@type': 'Product' }, + { '@context': 'https://schema.org', '@type': 'Event' }, + ]), + ])('drops an invalid JSON-LD document', (value) => { + expect(sanitizeJsonLd(value)).toBeNull() + }) + + it('emits initial, added, and changed JSON-LD without duplicates', async () => { + const emit = jest.fn() + const initial = jsonLdScript({ '@context': 'https://schema.org', '@type': 'Product', name: 'One' }) + document.body.appendChild(initial) + + const stop = startJsonLdCapture(document, MutationObserver, { + blockClass: 'ph-no-capture', + maskTextClass: 'ph-mask', + emit, + }) + + expect(emit).toHaveBeenCalledWith({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'One', + }) + + const added = jsonLdScript({ '@context': 'https://schema.org', '@type': 'Product', name: 'Two' }) + document.body.appendChild(added) + await deliverMutations() + + added.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Three', + }) + await deliverMutations() + + expect(emit.mock.calls).toEqual([ + [{ '@context': 'https://schema.org', '@type': 'Product', name: 'One' }], + [{ '@context': 'https://schema.org', '@type': 'Product', name: 'Two' }], + [{ '@context': 'https://schema.org', '@type': 'Product', name: 'Three' }], + ]) + + stop() + document.body.appendChild(jsonLdScript({ '@context': 'https://schema.org', '@type': 'Product', name: 'Four' })) + await deliverMutations() + expect(emit).toHaveBeenCalledTimes(3) + }) + + it('drops JSON-LD inside text masks and blocked elements', async () => { + const emit = jest.fn() + document.body.innerHTML = '
' + document.body.children[0].appendChild( + jsonLdScript({ '@context': 'https://schema.org', '@type': 'Person', '@id': 'masked' }) + ) + document.body.children[1].appendChild( + jsonLdScript({ '@context': 'https://schema.org', '@type': 'Person', '@id': 'blocked' }) + ) + + const stop = startJsonLdCapture(document, MutationObserver, { + blockClass: 'ph-no-capture', + blockSelector: '.private', + maskTextClass: 'ph-mask', + emit, + }) + + expect(emit).not.toHaveBeenCalled() + + const transient = jsonLdScript({ '@context': 'https://schema.org', '@type': 'Person', '@id': 'transient' }) + document.body.children[0].appendChild(transient) + transient.remove() + await deliverMutations() + expect(emit).not.toHaveBeenCalled() + stop() + }) +}) diff --git a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts index fd0fc32c10..27fd8d5687 100644 --- a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts @@ -3452,7 +3452,7 @@ describe('Lazy SessionRecording', () => { maskInputFn: undefined, maskAllElementAttributes: false, maskAttributeFn: undefined, - slimDOMOptions: {}, + slimDOMOptions: { script: true }, collectFonts: false, plugins: [], inlineStylesheet: true, @@ -3461,6 +3461,64 @@ describe('Lazy SessionRecording', () => { }) }) + it('always removes scripts when user slim DOM options override defaults', () => { + posthog.config.session_recording.slimDOMOptions = { script: false, comment: true } + + sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) + + expect(assignableWindow.__PosthogExtensions__.rrweb.record).toHaveBeenCalledWith( + expect.objectContaining({ + slimDOMOptions: { script: true, comment: true }, + }) + ) + }) + + it('emits sanitized JSON-LD when capture is enabled', () => { + const script = document.createElement('script') + script.type = 'application/ld+json' + script.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + '@id': 'https://example.com/products/123', + name: 'Camera', + email: 'private@example.com', + }) + document.body.appendChild(script) + posthog.config.session_recording.captureJsonLd = true + + try { + sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) + + expect(_addCustomEvent).toHaveBeenCalledWith('$json_ld', { + '@context': 'https://schema.org', + '@type': 'Product', + '@id': 'https://example.com/products/123', + name: 'Camera', + }) + } finally { + script.remove() + } + }) + + it('does not emit JSON-LD by default', () => { + const script = document.createElement('script') + script.type = 'application/ld+json' + script.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Camera', + }) + document.body.appendChild(script) + + try { + sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) + + expect(_addCustomEvent.mock.calls.some(([tag]) => tag === '$json_ld')).toBe(false) + } finally { + script.remove() + } + }) + it('contains and logs recorder-owned callback failures once without swallowing host failures', () => { sessionRecording.onRemoteConfig( makeFlagsResponse({ diff --git a/packages/browser/src/extensions/replay/external/json-ld.ts b/packages/browser/src/extensions/replay/external/json-ld.ts new file mode 100644 index 0000000000..df09c1fdc2 --- /dev/null +++ b/packages/browser/src/extensions/replay/external/json-ld.ts @@ -0,0 +1,328 @@ +import { isArray, isNull, isUndefined } from '@posthog/core' + +type JsonLdScalar = string | number | boolean | null +type JsonLdPropertyRule = true | readonly string[] +type JsonLdEntityRules = Record + +const MAX_JSON_LD_INPUT_LENGTH = 100_000 +const MAX_JSON_LD_OUTPUT_LENGTH = 20_000 +const SCHEMA_CONTEXT = 'https://schema.org' + +const ENTITY_RULES: Record = { + Action: { + actionStatus: true, + }, + AggregateOffer: { + lowPrice: true, + highPrice: true, + priceCurrency: true, + offerCount: true, + availability: true, + offers: ['Offer'], + }, + AggregateRating: { + ratingValue: true, + ratingCount: true, + reviewCount: true, + bestRating: true, + worstRating: true, + }, + Brand: { + name: true, + }, + CreativeWork: { + genre: true, + inLanguage: true, + encodingFormat: true, + dateCreated: true, + dateModified: true, + datePublished: true, + expires: true, + isAccessibleForFree: true, + isFamilyFriendly: true, + contentRating: true, + learningResourceType: true, + educationalLevel: true, + educationalUse: true, + interactivityType: true, + aggregateRating: ['AggregateRating'], + publisher: ['Organization'], + }, + Offer: { + price: true, + priceCurrency: true, + priceValidUntil: true, + availability: true, + itemCondition: true, + seller: ['Organization'], + }, + Organization: { + name: true, + legalName: true, + foundingDate: true, + dissolutionDate: true, + nonprofitStatus: true, + aggregateRating: ['AggregateRating'], + brand: ['Brand'], + }, + Person: {}, + Place: { + publicAccess: true, + smokingAllowed: true, + maximumAttendeeCapacity: true, + isAccessibleForFree: true, + aggregateRating: ['AggregateRating'], + }, + Product: { + name: true, + sku: true, + mpn: true, + gtin: true, + gtin8: true, + gtin12: true, + gtin13: true, + gtin14: true, + productID: true, + productGroupID: true, + asin: true, + model: true, + category: true, + color: true, + material: true, + pattern: true, + size: true, + productionDate: true, + releaseDate: true, + brand: ['Brand', 'Organization'], + manufacturer: ['Organization'], + offers: ['Offer', 'AggregateOffer'], + aggregateRating: ['AggregateRating'], + }, +} + +export const JSON_LD_EVENT_TAG = '$json_ld' + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && !isNull(value) && !isArray(value) +} + +function isScalar(value: unknown): value is JsonLdScalar { + const type = typeof value + return isNull(value) || type === 'string' || type === 'number' || type === 'boolean' +} + +function sanitizeScalar(value: unknown): JsonLdScalar | JsonLdScalar[] | undefined { + return isScalar(value) || (isArray(value) && value.every(isScalar)) ? value : undefined +} + +function sanitizeEntity(value: unknown, allowedTypes?: readonly string[]): Record | null { + if (!isObject(value) || typeof value['@type'] !== 'string') { + return null + } + + const type = value['@type'] + const rules = ENTITY_RULES[type] + if (isUndefined(rules) || (allowedTypes && !allowedTypes.includes(type))) { + return null + } + + const result: Record = { '@type': type } + const id = sanitizeScalar(value['@id']) + if (!isUndefined(id)) { + result['@id'] = id + } + + for (const property of Object.keys(rules)) { + const propertyValue = value[property] + const rule = rules[property] + if (rule === true) { + const scalar = sanitizeScalar(propertyValue) + if (!isUndefined(scalar)) { + result[property] = scalar + } + } else if (isArray(propertyValue)) { + const items = propertyValue + .map((item) => sanitizeEntity(item, rule)) + .filter((item): item is Record => !isNull(item)) + if (items.length) { + result[property] = items + } + } else { + const nestedEntity = sanitizeEntity(propertyValue, rule) + if (nestedEntity) { + result[property] = nestedEntity + } + } + } + + return result +} + +function sanitizeRoot(value: unknown): Record | null { + if ( + !isObject(value) || + typeof value['@context'] !== 'string' || + value['@context'].replace(/^http:/, 'https:').replace(/\/$/, '') !== SCHEMA_CONTEXT + ) { + return null + } + + const entity = sanitizeEntity(value) + return entity ? { '@context': SCHEMA_CONTEXT, ...entity } : null +} + +export function sanitizeJsonLd(text: string): string | null { + if (!text || text.length > MAX_JSON_LD_INPUT_LENGTH) { + return null + } + + try { + const value: unknown = JSON.parse(text) + const sanitized = isArray(value) ? value.map(sanitizeRoot) : sanitizeRoot(value) + if ( + isNull(sanitized) || + (isArray(sanitized) && (!sanitized.length || sanitized.some((item) => isNull(item)))) + ) { + return null + } + + const output = JSON.stringify(sanitized).replace(/ { + rule.lastIndex = 0 + return rule.test(className) + }) +} + +function selectorMatches(element: Element, selector?: string | null): boolean { + try { + return !!selector && element.matches(selector) + } catch { + return false + } +} + +function isWithinPrivacyBoundary(element: Element, options: JsonLdPrivacyOptions): boolean { + for (let current: Element | null = element; current; current = current.parentElement) { + if ( + classMatches(current, options.blockClass) || + classMatches(current, options.maskTextClass) || + selectorMatches(current, options.blockSelector) || + selectorMatches(current, options.maskTextSelector) + ) { + return true + } + } + return false +} + +function getJsonLdScripts(node: Node): HTMLScriptElement[] { + if (isJsonLdScript(node)) { + return [node] + } + if (node.nodeType !== node.ELEMENT_NODE) { + return [] + } + return Array.from((node as Element).querySelectorAll('script')).filter(isJsonLdScript) +} + +export function startJsonLdCapture( + doc: Document, + MutationObserverClass: typeof MutationObserver, + options: JsonLdPrivacyOptions & { emit: (jsonLd: unknown) => void } +): () => void { + const lastJsonByScript = new WeakMap() + + const captureScript = (script: HTMLScriptElement): void => { + try { + if (!script.isConnected || isWithinPrivacyBoundary(script, options)) { + return + } + const json = sanitizeJsonLd(script.textContent || '') + if (!json) { + lastJsonByScript.delete(script) + return + } + if (lastJsonByScript.get(script) !== json) { + lastJsonByScript.set(script, json) + options.emit(JSON.parse(json)) + } + } catch { + return + } + } + + try { + const observer = new MutationObserverClass((mutations) => { + try { + const scripts = new Set() + const addScripts = (node: Node): void => { + for (const script of getJsonLdScripts(node)) { + scripts.add(script) + } + } + + for (const mutation of mutations) { + if (mutation.type === 'childList') { + if (isJsonLdScript(mutation.target)) { + scripts.add(mutation.target) + } + mutation.addedNodes.forEach(addScripts) + } else if (mutation.type === 'characterData' && mutation.target.parentNode) { + addScripts(mutation.target.parentNode) + } else if (mutation.type === 'attributes') { + addScripts(mutation.target) + } + } + + scripts.forEach(captureScript) + } catch { + return + } + }) + + observer.observe(doc, { + attributes: true, + attributeFilter: ['type'], + characterData: true, + childList: true, + subtree: true, + }) + doc.querySelectorAll('script').forEach((script) => { + if (isJsonLdScript(script)) { + captureScript(script) + } + }) + + return () => observer.disconnect() + } catch { + return () => {} + } +} diff --git a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts index 09b8e34919..766ac080ea 100644 --- a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts +++ b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts @@ -90,6 +90,7 @@ import { decodeSamplingDecision, } from './recording-strategies' import { MASKED, PERSONAL_DATA_CAMPAIGN_PARAMS } from '@posthog/browser-common/utils/event-utils' +import { JSON_LD_EVENT_TAG, startJsonLdCapture } from './json-ld' const BASE_ENDPOINT = '/s/' const DEFAULT_CANVAS_QUALITY = 0.4 @@ -492,6 +493,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt */ private _forceAllowLocalhostNetworkCapture = false private _stopRrweb: listenerHandler | undefined = undefined + private _stopJsonLdCapture: (() => void) | undefined private _lastActivityTimestamp: number = Date.now() private _isActivatingTrigger: boolean = false /** @@ -1427,6 +1429,8 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt // Clear any queued rrweb events to prevent memory leaks from closures this._queuedRRWebEvents = [] + this._stopJsonLdCapture?.() + this._stopJsonLdCapture = undefined this._stopRrweb?.() this._stopRrweb = undefined } @@ -2529,7 +2533,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt maskInputFn: undefined, maskAllElementAttributes: false, maskAttributeFn: undefined, - slimDOMOptions: {}, + slimDOMOptions: { script: true }, collectFonts: false, inlineStylesheet: true, // inlining every CSSRule of every sheet is the dominant cost of a full @@ -2573,6 +2577,13 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } } + if (sessionRecordingOptions.slimDOMOptions !== true && sessionRecordingOptions.slimDOMOptions !== 'all') { + sessionRecordingOptions.slimDOMOptions = { + ...sessionRecordingOptions.slimDOMOptions, + script: true, + } + } + if (this._canvasRecording && this._canvasRecording.enabled) { sessionRecordingOptions.recordCanvas = true // canvas fps is owned by the canvas recording config; merge so that @@ -2689,6 +2700,21 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt this._rrwebError = false + if ( + userSessionRecordingOptions?.captureJsonLd && + document && + window?.MutationObserver && + !this._stopJsonLdCapture + ) { + this._stopJsonLdCapture = startJsonLdCapture(document, window.MutationObserver, { + blockClass: sessionRecordingOptions.blockClass, + blockSelector: sessionRecordingOptions.blockSelector, + maskTextClass: sessionRecordingOptions.maskTextClass, + maskTextSelector: sessionRecordingOptions.maskTextSelector, + emit: (jsonLd) => this._tryAddCustomEvent(JSON_LD_EVENT_TAG, jsonLd), + }) + } + // We reset the last activity timestamp, resetting the idle timer this._lastActivityTimestamp = Date.now() // stay unknown if we're not sure if we're idle or not diff --git a/packages/types/src/__tests__/__snapshots__/config-snapshot.spec.ts.snap b/packages/types/src/__tests__/__snapshots__/config-snapshot.spec.ts.snap index 942fc4c851..56a0c326d3 100644 --- a/packages/types/src/__tests__/__snapshots__/config-snapshot.spec.ts.snap +++ b/packages/types/src/__tests__/__snapshots__/config-snapshot.spec.ts.snap @@ -500,6 +500,11 @@ exports[`config snapshot for PostHogConfig 1`] = ` "Partial", "\\"all\\"" ], + "captureJsonLd": [ + "undefined", + "false", + "true" + ], "collectFonts": [ "undefined", "false", diff --git a/packages/types/src/posthog-config.ts b/packages/types/src/posthog-config.ts index 05e089b102..b20852ef78 100644 --- a/packages/types/src/posthog-config.ts +++ b/packages/types/src/posthog-config.ts @@ -686,6 +686,13 @@ export interface SessionRecordingOptions { */ slimDOMOptions?: true | Partial | 'all' + /** + * Captures sanitized Schema.org JSON-LD as session replay custom events. + * JSON-LD inside a text mask or blocked element is never captured. + * @default false + */ + captureJsonLd?: boolean + /** * Derived from `rrweb.record` options * @see https://github.com/rrweb-io/rrweb/blob/master/guide.md From e12a8ee86404d793f1e96f2b455e396fbc2be0f5 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Tue, 25 Aug 2026 13:24:25 +0100 Subject: [PATCH 2/8] fix(replay): harden JSON-LD capture boundaries --- .changeset/replay-json-ld-events.md | 2 +- .../session-recording-masking.spec.ts | 122 +++++++- .../extensions/replay/json-ld.test.ts | 262 +++++++++++++++++- .../replay/lazy-sessionrecording.test.ts | 252 ++++++++++++++++- .../src/extensions/replay/external/json-ld.ts | 121 +++++--- .../external/lazy-loaded-session-recorder.ts | 76 ++++- .../replay/external/triggerMatching.ts | 12 + packages/types/src/posthog-config.ts | 5 + 8 files changed, 790 insertions(+), 62 deletions(-) diff --git a/.changeset/replay-json-ld-events.md b/.changeset/replay-json-ld-events.md index 5235764f07..8ee130cd13 100644 --- a/.changeset/replay-json-ld-events.md +++ b/.changeset/replay-json-ld-events.md @@ -3,4 +3,4 @@ '@posthog/types': patch --- -Add opt-in Schema.org JSON-LD capture to session replay through `session_recording.captureJsonLd`. The recorder emits sanitized JSON-LD as custom replay events and excludes all script elements from replay snapshots. +Add opt-in Schema.org JSON-LD capture to session replay through `session_recording.captureJsonLd`. When enabled, the recorder emits sanitized JSON-LD as custom replay events and excludes all script elements from replay snapshots. diff --git a/packages/browser/playwright/mocked/session-recording/session-recording-masking.spec.ts b/packages/browser/playwright/mocked/session-recording/session-recording-masking.spec.ts index a6f4701abf..384b227dcf 100644 --- a/packages/browser/playwright/mocked/session-recording/session-recording-masking.spec.ts +++ b/packages/browser/playwright/mocked/session-recording/session-recording-masking.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '../utils/posthog-playwright-test-base' -import { start } from '../utils/setup' +import { start, waitForSessionRecordingToStart } from '../utils/setup' import { Page } from '@playwright/test' import { CaptureResult } from '@/types' @@ -8,11 +8,12 @@ import { CaptureResult } from '@/types' const remoteMaskingTextSelector = '*' -const startOptions = (masking: Record) => ({ +const startOptions = (masking: Record, captureJsonLd = false) => ({ options: { session_recording: { // not the default but makes for easier test assertions compress_events: false, + captureJsonLd, }, }, flagsResponseOverrides: { @@ -55,6 +56,123 @@ function assertTheConfigIsAsExpected(snapshotEvents: CaptureResult[], expectedMa } test.describe('Session recording - masking', () => { + test('emits only sanitized JSON-LD in recording bytes', async ({ page, context }) => { + await page.addInitScript(() => { + const appendJsonLd = (value: Record, className = '') => { + const script = document.createElement('script') + script.type = 'application/ld+json' + script.className = className + script.setAttribute('data-private', 'PRIVATE_ATTRIBUTE') + script.textContent = JSON.stringify(value) + script.append(document.createComment('PRIVATE_COMMENT')) + document.head.append(script) + } + const appendInitialJsonLd = () => { + appendJsonLd({ + '@context': 'https://schema.org', + '@type': 'Product', + '@id': 'ALLOWED_PRODUCT_ID', + name: 'ALLOWED_INITIAL_PRODUCT', + email: 'PRIVATE_UNAPPROVED_EMAIL', + description: 'PRIVATE_DESCRIPTION', + url: 'https://example.com/?token=PRIVATE_URL_TOKEN', + brand: { + '@type': 'Person', + name: 'PRIVATE_NESTED_PERSON', + }, + manufacturer: { + '@type': 'Organization', + name: 'ALLOWED_MANUFACTURER', + legalName: 'ALLOWED_MANUFACTURER_LEGAL_NAME', + email: 'PRIVATE_MANUFACTURER_EMAIL', + }, + }) + appendJsonLd( + { + '@context': 'https://schema.org', + '@type': 'Product', + name: 'PRIVATE_MASKED_PRODUCT', + }, + 'json-ld-mask' + ) + } + if (document.head) { + appendInitialJsonLd() + } else { + document.onreadystatechange = () => { + if (document.head) { + document.onreadystatechange = null + appendInitialJsonLd() + } + } + } + }) + await start( + startOptions( + { + maskAllInputs: true, + maskTextSelector: '.json-ld-mask', + }, + true + ), + page, + context + ) + await waitForSessionRecordingToStart(page) + + await page.evaluate(() => { + const script = document.createElement('script') + script.type = 'application/ld+json' + script.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'ALLOWED_DYNAMIC_PRODUCT', + email: 'PRIVATE_DYNAMIC_EMAIL', + }) + document.head.append(script) + + const maskedScript = document.createElement('script') + maskedScript.type = 'application/ld+json' + maskedScript.className = 'json-ld-mask' + maskedScript.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'PRIVATE_DYNAMIC_MASKED_PRODUCT', + }) + document.head.append(maskedScript) + }) + await page.locator('[data-cy-input]').type('flush recording') + + const getEventBytes = async () => + JSON.stringify( + (await page.capturedEvents()) + .filter((event) => event.event === '$snapshot') + .flatMap((event) => event.properties['$snapshot_data']) + ) + await expect.poll(getEventBytes).toContain('ALLOWED_DYNAMIC_PRODUCT') + const eventBytes = await getEventBytes() + expect(eventBytes).toContain('ALLOWED_PRODUCT_ID') + expect(eventBytes).toContain('ALLOWED_INITIAL_PRODUCT') + expect(eventBytes).toContain('ALLOWED_DYNAMIC_PRODUCT') + expect(eventBytes).toContain('ALLOWED_MANUFACTURER') + expect(eventBytes).toContain('ALLOWED_MANUFACTURER_LEGAL_NAME') + expect(eventBytes).not.toContain('"tagName":"script"') + for (const privateMarker of [ + 'PRIVATE_ATTRIBUTE', + 'PRIVATE_COMMENT', + 'PRIVATE_UNAPPROVED_EMAIL', + 'PRIVATE_DESCRIPTION', + 'PRIVATE_URL_TOKEN', + 'PRIVATE_NESTED_PERSON', + 'PRIVATE_MANUFACTURER_EMAIL', + 'PRIVATE_MASKED_PRODUCT', + 'PRIVATE_DYNAMIC_EMAIL', + 'PRIVATE_DYNAMIC_MASKED_PRODUCT', + ]) { + expect(eventBytes).not.toContain(privateMarker) + } + }) + test('masks text', async ({ page, context }) => { await start( startOptions({ diff --git a/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts index 376e725512..778881a7b5 100644 --- a/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts @@ -9,6 +9,7 @@ function jsonLdScript(value: unknown): HTMLScriptElement { async function deliverMutations(): Promise { await Promise.resolve() + await Promise.resolve() } describe('JSON-LD replay capture', () => { @@ -41,7 +42,7 @@ describe('JSON-LD replay capture', () => { }) ) - expect(JSON.parse(sanitized!)).toEqual({ + expect(sanitized?.[0]).toEqual({ '@context': 'https://schema.org', '@type': 'Product', '@id': 'https://example.com/products/123', @@ -70,7 +71,7 @@ describe('JSON-LD replay capture', () => { }) ) - expect(JSON.parse(sanitized!)).toEqual({ + expect(sanitized?.[0]).toEqual({ '@context': 'https://schema.org', '@type': 'Product', name: ['Camera', 2, true, null], @@ -78,10 +79,40 @@ describe('JSON-LD replay capture', () => { }) }) + it('sanitizes root and nested entity arrays', () => { + expect( + sanitizeJsonLd( + JSON.stringify([ + { + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Camera', + offers: [ + { '@type': 'Offer', price: 100, email: 'private@example.com' }, + { '@type': 'Person', '@id': 'private-person' }, + ], + }, + { '@context': 'https://schema.org', '@type': 'Organization', name: 'Acme' }, + ]) + )?.[0] + ).toEqual([ + { + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Camera', + offers: [{ '@type': 'Offer', price: 100 }], + }, + { '@context': 'https://schema.org', '@type': 'Organization', name: 'Acme' }, + ]) + }) + it.each([ 'not json', JSON.stringify({ '@context': 'https://example.com', '@type': 'Product' }), JSON.stringify({ '@context': 'https://schema.org', '@type': 'Event' }), + JSON.stringify({ '@context': 'https://schema.org', '@type': 'constructor', '@id': 'private@example.com' }), + JSON.stringify({ '@context': 'https://schema.org', '@type': 'toString', '@id': 'private@example.com' }), + JSON.stringify({ '@context': 'https://schema.org', '@type': '__proto__', '@id': 'private@example.com' }), JSON.stringify([ { '@context': 'https://schema.org', '@type': 'Product' }, { '@context': 'https://schema.org', '@type': 'Event' }, @@ -90,16 +121,72 @@ describe('JSON-LD replay capture', () => { expect(sanitizeJsonLd(value)).toBeNull() }) + it('drops Person properties other than @id', () => { + expect( + sanitizeJsonLd( + JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Person', + '@id': 'person-id', + name: 'Private name', + email: 'private@example.com', + }) + )?.[0] + ).toEqual({ + '@context': 'https://schema.org', + '@type': 'Person', + '@id': 'person-id', + }) + }) + + it('ignores inherited JSON-LD properties', () => { + const properties = ['@context', '@type', '@id', 'name'] + const values = ['https://schema.org', 'Product', 'private-id', 'private-name'] + const descriptors = properties.map((property) => Object.getOwnPropertyDescriptor(Object.prototype, property)) + let inheritedContext: ReturnType + let inheritedType: ReturnType + let inheritedLeaves: ReturnType + + try { + properties.forEach((property, index) => { + Object.defineProperty(Object.prototype, property, { + configurable: true, + value: values[index], + }) + }) + inheritedContext = sanitizeJsonLd(JSON.stringify({ '@type': 'Product' })) + inheritedType = sanitizeJsonLd(JSON.stringify({ '@context': 'https://schema.org' })) + inheritedLeaves = sanitizeJsonLd(JSON.stringify({ '@context': 'https://schema.org', '@type': 'Product' })) + } finally { + properties.forEach((property, index) => { + const descriptor = descriptors[index] + if (descriptor) { + Object.defineProperty(Object.prototype, property, descriptor) + } else { + Reflect.deleteProperty(Object.prototype, property) + } + }) + } + + expect(inheritedContext).toBeNull() + expect(inheritedType).toBeNull() + expect(inheritedLeaves?.[0]).toEqual({ + '@context': 'https://schema.org', + '@type': 'Product', + }) + }) + it('emits initial, added, and changed JSON-LD without duplicates', async () => { - const emit = jest.fn() + const emit = jest.fn(() => true) const initial = jsonLdScript({ '@context': 'https://schema.org', '@type': 'Product', name: 'One' }) document.body.appendChild(initial) - const stop = startJsonLdCapture(document, MutationObserver, { + const capture = startJsonLdCapture(document, MutationObserver, { blockClass: 'ph-no-capture', maskTextClass: 'ph-mask', emit, }) + capture.scan() expect(emit).toHaveBeenCalledWith({ '@context': 'https://schema.org', @@ -111,6 +198,14 @@ describe('JSON-LD replay capture', () => { document.body.appendChild(added) await deliverMutations() + added.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Two', + email: 'private@example.com', + }) + await deliverMutations() + added.textContent = JSON.stringify({ '@context': 'https://schema.org', '@type': 'Product', @@ -124,14 +219,110 @@ describe('JSON-LD replay capture', () => { [{ '@context': 'https://schema.org', '@type': 'Product', name: 'Three' }], ]) - stop() + capture.stop() document.body.appendChild(jsonLdScript({ '@context': 'https://schema.org', '@type': 'Product', name: 'Four' })) await deliverMutations() expect(emit).toHaveBeenCalledTimes(3) }) + it('does not scan subtrees for ordinary text changes', async () => { + const text = document.createTextNode('before') + document.body.append(text) + const querySelectorAll = jest.spyOn(Element.prototype, 'querySelectorAll') + const capture = startJsonLdCapture(document, MutationObserver, { emit: jest.fn(() => true) }) + querySelectorAll.mockClear() + + text.data = 'after' + await deliverMutations() + + expect(querySelectorAll).not.toHaveBeenCalled() + querySelectorAll.mockRestore() + capture.stop() + }) + + it('limits the total JSON-LD emitted by one recorder', async () => { + for (let index = 0; index < 6; index++) { + document.body.appendChild( + jsonLdScript({ + '@context': 'https://schema.org', + '@type': 'Product', + name: `${index}${'x'.repeat(19_000)}`, + }) + ) + } + const emit = jest.fn(() => true) + + const capture = startJsonLdCapture(document, MutationObserver, { emit }) + capture.scan() + const querySelectorAll = jest.spyOn(Element.prototype, 'querySelectorAll') + const container = document.createElement('div') + container.appendChild(jsonLdScript({ '@context': 'https://schema.org', '@type': 'Product' })) + document.body.append(container) + await deliverMutations() + + expect(emit).toHaveBeenCalledTimes(5) + expect(querySelectorAll).not.toHaveBeenCalled() + querySelectorAll.mockRestore() + capture.stop() + }) + + it('rescans after capture becomes enabled', () => { + let enabled = false + const emit = jest.fn(() => true) + document.body.appendChild( + jsonLdScript({ '@context': 'https://schema.org', '@type': 'Product', name: 'Camera' }) + ) + const capture = startJsonLdCapture(document, MutationObserver, { + emit, + isEnabled: () => enabled, + }) + + expect(emit).not.toHaveBeenCalled() + enabled = true + capture.scan() + expect(emit).toHaveBeenCalledWith({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Camera', + }) + capture.stop() + }) + + it('drops scripts moved before capture into a masked shadow root or another document', async () => { + const emit = jest.fn(() => true) + const capture = startJsonLdCapture(document, MutationObserver, { + maskTextClass: 'ph-mask', + emit, + }) + const shadowHost = document.createElement('div') + shadowHost.className = 'ph-mask' + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }) + document.body.append(shadowHost) + const shadowScript = jsonLdScript({ + '@context': 'https://schema.org', + '@type': 'Person', + '@id': 'shadow-private', + }) + document.body.append(shadowScript) + shadowRoot.append(shadowScript) + + const iframe = document.createElement('iframe') + document.body.append(iframe) + const frameScript = jsonLdScript({ + '@context': 'https://schema.org', + '@type': 'Person', + '@id': 'frame-private', + }) + document.body.append(frameScript) + iframe.contentDocument!.body.append(frameScript) + await deliverMutations() + + expect(emit).not.toHaveBeenCalled() + capture.stop() + }) + it('drops JSON-LD inside text masks and blocked elements', async () => { - const emit = jest.fn() + const emit = jest.fn(() => true) document.body.innerHTML = '
' document.body.children[0].appendChild( jsonLdScript({ '@context': 'https://schema.org', '@type': 'Person', '@id': 'masked' }) @@ -140,13 +331,23 @@ describe('JSON-LD replay capture', () => { jsonLdScript({ '@context': 'https://schema.org', '@type': 'Person', '@id': 'blocked' }) ) - const stop = startJsonLdCapture(document, MutationObserver, { + const capture = startJsonLdCapture(document, MutationObserver, { blockClass: 'ph-no-capture', blockSelector: '.private', maskTextClass: 'ph-mask', emit, }) + capture.scan() + + expect(emit).not.toHaveBeenCalled() + document.body.children[0].appendChild( + jsonLdScript({ '@context': 'https://schema.org', '@type': 'Person', '@id': 'dynamic-masked' }) + ) + document.body.children[1].appendChild( + jsonLdScript({ '@context': 'https://schema.org', '@type': 'Person', '@id': 'dynamic-blocked' }) + ) + await deliverMutations() expect(emit).not.toHaveBeenCalled() const transient = jsonLdScript({ '@context': 'https://schema.org', '@type': 'Person', '@id': 'transient' }) @@ -154,6 +355,51 @@ describe('JSON-LD replay capture', () => { transient.remove() await deliverMutations() expect(emit).not.toHaveBeenCalled() - stop() + capture.stop() + }) + + it('ignores non-JSON-LD scripts until their type changes', async () => { + const emit = jest.fn(() => true) + const value = JSON.stringify({ '@context': 'https://schema.org', '@type': 'Product', name: 'Camera' }) + const scripts = ['', 'text/javascript', 'application/json'].map((type) => { + const script = document.createElement('script') + script.type = type + if (type === 'application/json') { + script.textContent = value + } + document.body.appendChild(script) + script.textContent = value + return script + }) + const capture = startJsonLdCapture(document, MutationObserver, { emit }) + + capture.scan() + expect(emit).not.toHaveBeenCalled() + + scripts[2].type = 'application/ld+json' + await deliverMutations() + expect(emit).toHaveBeenCalledWith({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Camera', + }) + capture.stop() + }) + + it('does not deduplicate an event that the recorder rejects', () => { + let acceptsEvents = false + const emit = jest.fn(() => acceptsEvents) + document.body.appendChild( + jsonLdScript({ '@context': 'https://schema.org', '@type': 'Product', name: 'Camera' }) + ) + const capture = startJsonLdCapture(document, MutationObserver, { emit }) + + capture.scan() + acceptsEvents = true + capture.scan() + capture.scan() + + expect(emit).toHaveBeenCalledTimes(2) + capture.stop() }) }) diff --git a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts index 27fd8d5687..c637e718bb 100644 --- a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts @@ -3452,7 +3452,7 @@ describe('Lazy SessionRecording', () => { maskInputFn: undefined, maskAllElementAttributes: false, maskAttributeFn: undefined, - slimDOMOptions: { script: true }, + slimDOMOptions: {}, collectFonts: false, plugins: [], inlineStylesheet: true, @@ -3461,8 +3461,9 @@ describe('Lazy SessionRecording', () => { }) }) - it('always removes scripts when user slim DOM options override defaults', () => { + it('removes scripts when JSON-LD capture is enabled', () => { posthog.config.session_recording.slimDOMOptions = { script: false, comment: true } + posthog.config.session_recording.captureJsonLd = true sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) @@ -3473,7 +3474,7 @@ describe('Lazy SessionRecording', () => { ) }) - it('emits sanitized JSON-LD when capture is enabled', () => { + it('emits sanitized JSON-LD only while capture is enabled', async () => { const script = document.createElement('script') script.type = 'application/ld+json' script.textContent = JSON.stringify({ @@ -3489,13 +3490,154 @@ describe('Lazy SessionRecording', () => { try { sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) + expect(_addCustomEvent).not.toHaveBeenCalledWith('$json_ld', expect.anything()) + _emit(createMetaSnapshot()) + await Promise.resolve() + expect(_addCustomEvent).toHaveBeenCalledWith('$json_ld', { '@context': 'https://schema.org', '@type': 'Product', '@id': 'https://example.com/products/123', name: 'Camera', }) + + posthog.config.session_recording.captureJsonLd = false + document.body.appendChild( + Object.assign(document.createElement('script'), { + type: 'application/ld+json', + textContent: JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'After disable', + }), + }) + ) + await Promise.resolve() + expect(_addCustomEvent).not.toHaveBeenCalledWith( + '$json_ld', + expect.objectContaining({ name: 'After disable' }) + ) + } finally { + document.querySelectorAll('script[type="application/ld+json"]').forEach((element) => element.remove()) + } + }) + + it('emits the latest JSON-LD after returning from idle', async () => { + const script = document.createElement('script') + script.type = 'application/ld+json' + script.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Before idle', + }) + document.body.appendChild(script) + posthog.config.session_recording.captureJsonLd = true + + try { + sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) + _emit(createMetaSnapshot()) + await Promise.resolve() + _addCustomEvent.mockClear() + + const lazyRecorder = sessionRecording['_lazyLoadedSessionRecording'] + lazyRecorder['_isIdle'] = true + script.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'After idle', + }) + await Promise.resolve() + expect(_addCustomEvent).not.toHaveBeenCalledWith( + '$json_ld', + expect.objectContaining({ name: 'After idle' }) + ) + + _emit(createIncrementalSnapshot({ timestamp: Date.now() + 1 })) + await Promise.resolve() + expect(_addCustomEvent).toHaveBeenCalledWith('$json_ld', { + '@context': 'https://schema.org', + '@type': 'Product', + name: 'After idle', + }) + } finally { + script.remove() + } + }) + + it('does not queue JSON-LD before rrweb is ready', async () => { + const script = document.createElement('script') + script.type = 'application/ld+json' + script.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Before disable', + }) + document.body.appendChild(script) + posthog.config.session_recording.captureJsonLd = true + + try { + sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) + expect(_addCustomEvent).not.toHaveBeenCalledWith('$json_ld', expect.anything()) + + posthog.config.session_recording.captureJsonLd = false + _emit(createMetaSnapshot()) + await Promise.resolve() + + expect(_addCustomEvent).not.toHaveBeenCalledWith('$json_ld', expect.anything()) + expect(sessionRecording['_lazyLoadedSessionRecording']['_queuedRRWebEvents']).toEqual([]) + } finally { + script.remove() + } + }) + + it('restores JSON-LD after a pending-trigger snapshot truncates the buffer', async () => { + const script = document.createElement('script') + script.type = 'application/ld+json' + script.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Camera', + }) + document.body.appendChild(script) + posthog.config.session_recording.captureJsonLd = true + + try { + sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) + _addCustomEvent.mockImplementation((tag: string, payload: unknown) => { + _emit(createCustomSnapshot({}, payload as Record, tag)) + }) + _emit(createMetaSnapshot({ data: { href: 'https://test.com/first' } })) + await Promise.resolve() + + const lazyRecorder = sessionRecording['_lazyLoadedSessionRecording'] + const pendingTrigger = jest + .spyOn(lazyRecorder['_strategy']!, 'hasPendingTriggers') + .mockReturnValue(true) + try { + _emit(createMetaSnapshot({ data: { href: 'https://test.com/second' } })) + _emit(createFullSnapshot()) + await Promise.resolve() + + const bufferedEvents = lazyRecorder['_buffer'].data + const fullSnapshotIndex = bufferedEvents.findIndex((event: eventWithTime) => event.type === 2) + const jsonLdIndexes = bufferedEvents + .map((event: eventWithTime, index: number) => (event.data?.tag === '$json_ld' ? index : -1)) + .filter((index: number) => index >= 0) + expect(bufferedEvents[0]).toEqual(createMetaSnapshot({ data: { href: 'https://test.com/second' } })) + expect(jsonLdIndexes).toHaveLength(1) + expect(jsonLdIndexes[0]).toBeGreaterThan(fullSnapshotIndex) + expect(bufferedEvents[jsonLdIndexes[0]]).toEqual( + createCustomSnapshot( + {}, + { '@context': 'https://schema.org', '@type': 'Product', name: 'Camera' }, + '$json_ld' + ) + ) + } finally { + pendingTrigger.mockRestore() + } } finally { + _addCustomEvent.mockReset() script.remove() } }) @@ -3519,6 +3661,30 @@ describe('Lazy SessionRecording', () => { } }) + it('does not enable JSON-LD capture for a truthy non-boolean value', () => { + const script = document.createElement('script') + script.type = 'application/ld+json' + script.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Camera', + }) + document.body.appendChild(script) + posthog.config.session_recording.slimDOMOptions = { script: false } + posthog.config.session_recording.captureJsonLd = 'false' as unknown as boolean + + try { + sessionRecording.onRemoteConfig(makeFlagsResponse({ sessionRecording: { endpoint: '/s/' } })) + + expect(_addCustomEvent.mock.calls.some(([tag]) => tag === '$json_ld')).toBe(false) + expect(assignableWindow.__PosthogExtensions__.rrweb.record).toHaveBeenCalledWith( + expect.objectContaining({ slimDOMOptions: { script: false } }) + ) + } finally { + script.remove() + } + }) + it('contains and logs recorder-owned callback failures once without swallowing host failures', () => { sessionRecording.onRemoteConfig( makeFlagsResponse({ @@ -4324,7 +4490,54 @@ describe('Lazy SessionRecording', () => { }) describe('URL blocking', () => { + it('does not capture JSON-LD read on the initial blocked URL after navigation', async () => { + const script = document.createElement('script') + script.type = 'application/ld+json' + script.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Blocked page product', + }) + document.body.appendChild(script) + posthog.config.session_recording.captureJsonLd = true + fakeNavigateTo('https://test.com/blocked') + + try { + sessionRecording.onRemoteConfig( + makeFlagsResponse({ + sessionRecording: { + endpoint: '/s/', + urlBlocklist: [{ matching: 'regex', url: '/blocked' }], + }, + }) + ) + + fakeNavigateTo('https://test.com/allowed') + _emit(createMetaSnapshot()) + await Promise.resolve() + expect(_addCustomEvent).not.toHaveBeenCalledWith( + '$json_ld', + expect.objectContaining({ name: 'Blocked page product' }) + ) + + script.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Allowed page product', + }) + await Promise.resolve() + expect(_addCustomEvent).toHaveBeenCalledWith('$json_ld', { + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Allowed page product', + }) + } finally { + script.remove() + } + }) + it('does not flush buffer and includes pause event when hitting blocked URL', async () => { + posthog.config.session_recording.captureJsonLd = true sessionRecording.onRemoteConfig( makeFlagsResponse({ sessionRecording: { @@ -4338,7 +4551,6 @@ describe('Lazy SessionRecording', () => { }, }) ) - // Emit some events before hitting blocked URL _emit(createIncrementalSnapshot({ data: { source: 1 } })) _emit(createIncrementalSnapshot({ data: { source: 2 } })) @@ -4352,6 +4564,20 @@ describe('Lazy SessionRecording', () => { _emit(createIncrementalSnapshot({ data: { source: 3 } })) _emit(createIncrementalSnapshot({ data: { source: 4 } })) + const blockedJsonLd = document.createElement('script') + blockedJsonLd.type = 'application/ld+json' + blockedJsonLd.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Blocked page product', + }) + document.body.appendChild(blockedJsonLd) + await Promise.resolve() + expect(_addCustomEvent).not.toHaveBeenCalledWith( + '$json_ld', + expect.objectContaining({ name: 'Blocked page product' }) + ) + expect(sessionRecording['_lazyLoadedSessionRecording']['_buffer'].data).toEqual([ { data: { @@ -4372,6 +4598,24 @@ describe('Lazy SessionRecording', () => { // Verify recording resumes with resume event _emit(createIncrementalSnapshot({ data: { source: 5 } })) + await Promise.resolve() + expect(_addCustomEvent).not.toHaveBeenCalledWith( + '$json_ld', + expect.objectContaining({ name: 'Blocked page product' }) + ) + + blockedJsonLd.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Allowed page product', + }) + await Promise.resolve() + expect(_addCustomEvent).toHaveBeenCalledWith('$json_ld', { + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Allowed page product', + }) + blockedJsonLd.remove() expect(sessionRecording['_lazyLoadedSessionRecording']['_buffer'].data).toStrictEqual([ { diff --git a/packages/browser/src/extensions/replay/external/json-ld.ts b/packages/browser/src/extensions/replay/external/json-ld.ts index df09c1fdc2..eea9dfd05d 100644 --- a/packages/browser/src/extensions/replay/external/json-ld.ts +++ b/packages/browser/src/extensions/replay/external/json-ld.ts @@ -1,10 +1,10 @@ -import { isArray, isNull, isUndefined } from '@posthog/core' +import { hasOwnProperty, isArray, isNull, isUndefined } from '@posthog/core' type JsonLdScalar = string | number | boolean | null type JsonLdPropertyRule = true | readonly string[] type JsonLdEntityRules = Record -const MAX_JSON_LD_INPUT_LENGTH = 100_000 +const MAX_JSON_LD_LENGTH = 100_000 const MAX_JSON_LD_OUTPUT_LENGTH = 20_000 const SCHEMA_CONTEXT = 'https://schema.org' @@ -106,6 +106,10 @@ function isObject(value: unknown): value is Record { return typeof value === 'object' && !isNull(value) && !isArray(value) } +function getOwnProperty(value: Record, property: string): unknown { + return hasOwnProperty.call(value, property) ? value[property] : undefined +} + function isScalar(value: unknown): value is JsonLdScalar { const type = typeof value return isNull(value) || type === 'string' || type === 'number' || type === 'boolean' @@ -116,24 +120,27 @@ function sanitizeScalar(value: unknown): JsonLdScalar | JsonLdScalar[] | undefin } function sanitizeEntity(value: unknown, allowedTypes?: readonly string[]): Record | null { - if (!isObject(value) || typeof value['@type'] !== 'string') { + if (!isObject(value)) { + return null + } + const type = getOwnProperty(value, '@type') + if (typeof type !== 'string') { return null } - const type = value['@type'] - const rules = ENTITY_RULES[type] - if (isUndefined(rules) || (allowedTypes && !allowedTypes.includes(type))) { + if (!hasOwnProperty.call(ENTITY_RULES, type) || (allowedTypes && !allowedTypes.includes(type))) { return null } + const rules = ENTITY_RULES[type] const result: Record = { '@type': type } - const id = sanitizeScalar(value['@id']) + const id = sanitizeScalar(getOwnProperty(value, '@id')) if (!isUndefined(id)) { result['@id'] = id } for (const property of Object.keys(rules)) { - const propertyValue = value[property] + const propertyValue = getOwnProperty(value, property) const rule = rules[property] if (rule === true) { const scalar = sanitizeScalar(propertyValue) @@ -159,11 +166,11 @@ function sanitizeEntity(value: unknown, allowedTypes?: readonly string[]): Recor } function sanitizeRoot(value: unknown): Record | null { - if ( - !isObject(value) || - typeof value['@context'] !== 'string' || - value['@context'].replace(/^http:/, 'https:').replace(/\/$/, '') !== SCHEMA_CONTEXT - ) { + if (!isObject(value)) { + return null + } + const context = getOwnProperty(value, '@context') + if (typeof context !== 'string' || context.replace(/^http:/, 'https:').replace(/\/$/, '') !== SCHEMA_CONTEXT) { return null } @@ -171,8 +178,8 @@ function sanitizeRoot(value: unknown): Record | null { return entity ? { '@context': SCHEMA_CONTEXT, ...entity } : null } -export function sanitizeJsonLd(text: string): string | null { - if (!text || text.length > MAX_JSON_LD_INPUT_LENGTH) { +export function sanitizeJsonLd(text: string): [unknown, string] | null { + if (!text || text.length > MAX_JSON_LD_LENGTH) { return null } @@ -186,8 +193,8 @@ export function sanitizeJsonLd(text: string): string | null { return null } - const output = JSON.stringify(sanitized).replace(/ void } -): () => void { + options: JsonLdPrivacyOptions & { + emit: (jsonLd: unknown) => boolean + isEnabled?: () => boolean + shouldSuppress?: () => boolean + } +): { scan: (force?: boolean) => void; stop: () => void } { const lastJsonByScript = new WeakMap() + let remainingLength = MAX_JSON_LD_LENGTH - const captureScript = (script: HTMLScriptElement): void => { + const captureScript = (script: HTMLScriptElement, force = false): void => { try { - if (!script.isConnected || isWithinPrivacyBoundary(script, options)) { + const shouldSuppress = options.shouldSuppress?.() === true + if ( + !remainingLength || + (options.isEnabled?.() === false && !shouldSuppress) || + !script.isConnected || + script.ownerDocument !== doc || + isWithinPrivacyBoundary(script, options) + ) { return } - const json = sanitizeJsonLd(script.textContent || '') - if (!json) { + const sanitized = sanitizeJsonLd(script.textContent || '') + if (!sanitized) { lastJsonByScript.delete(script) return } - if (lastJsonByScript.get(script) !== json) { + const [jsonLd, json] = sanitized + if (shouldSuppress) { lastJsonByScript.set(script, json) - options.emit(JSON.parse(json)) + return + } + if (force || lastJsonByScript.get(script) !== json) { + if (json.length > remainingLength) { + remainingLength = 0 + return + } + if (options.emit(jsonLd)) { + lastJsonByScript.set(script, json) + remainingLength -= json.length + } } } catch { return @@ -282,6 +318,9 @@ export function startJsonLdCapture( try { const observer = new MutationObserverClass((mutations) => { try { + if (!remainingLength || (options.isEnabled?.() === false && options.shouldSuppress?.() !== true)) { + return + } const scripts = new Set() const addScripts = (node: Node): void => { for (const script of getJsonLdScripts(node)) { @@ -295,14 +334,17 @@ export function startJsonLdCapture( scripts.add(mutation.target) } mutation.addedNodes.forEach(addScripts) - } else if (mutation.type === 'characterData' && mutation.target.parentNode) { - addScripts(mutation.target.parentNode) - } else if (mutation.type === 'attributes') { - addScripts(mutation.target) + } else if (mutation.type === 'characterData') { + const parent = mutation.target.parentNode + if (parent && isJsonLdScript(parent)) { + scripts.add(parent) + } + } else if (mutation.type === 'attributes' && isJsonLdScript(mutation.target)) { + scripts.add(mutation.target) } } - scripts.forEach(captureScript) + scripts.forEach((script) => captureScript(script)) } catch { return } @@ -315,14 +357,19 @@ export function startJsonLdCapture( childList: true, subtree: true, }) - doc.querySelectorAll('script').forEach((script) => { - if (isJsonLdScript(script)) { - captureScript(script) + const scan = (force = false): void => { + if (!remainingLength || (options.isEnabled?.() === false && options.shouldSuppress?.() !== true)) { + return } - }) + doc.querySelectorAll('script').forEach((script) => { + if (isJsonLdScript(script)) { + captureScript(script, force) + } + }) + } - return () => observer.disconnect() + return { scan, stop: () => observer.disconnect() } } catch { - return () => {} + return { scan: () => {}, stop: () => {} } } } diff --git a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts index 766ac080ea..4776b2a62d 100644 --- a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts +++ b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts @@ -493,7 +493,8 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt */ private _forceAllowLocalhostNetworkCapture = false private _stopRrweb: listenerHandler | undefined = undefined - private _stopJsonLdCapture: (() => void) | undefined + private _jsonLdCapture: ReturnType | undefined + private _jsonLdCaptureReady = false private _lastActivityTimestamp: number = Date.now() private _isActivatingTrigger: boolean = false /** @@ -897,6 +898,36 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt return this._tryRRWebMethod(newQueuedEvent(() => getRRWebRecord()!.addCustomEvent(tag, payload))) } + private _canCaptureJsonLd(): boolean { + return ( + this._jsonLdCaptureReady && + this._instance.config.session_recording?.captureJsonLd === true && + !this._urlTriggerMatching.urlBlocked && + this._isIdle !== true + ) + } + + private _tryAddJsonLdEvent(jsonLd: unknown): boolean { + if (!this._canCaptureJsonLd()) { + return false + } + try { + const rrwebRecord = getRRWebRecord() + if (!rrwebRecord) { + return false + } + rrwebRecord.addCustomEvent(JSON_LD_EVENT_TAG, jsonLd) + return this._canCaptureJsonLd() + } catch { + return false + } + } + + private _scheduleJsonLdScan(force = false): void { + // eslint-disable-next-line compat/compat + Promise.resolve().then(() => this._jsonLdCapture?.scan(force)) + } + private _pageViewFallBack() { try { if (this._instance.config.capture_pageview || !window) { @@ -999,6 +1030,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt // so we might not get the below custom event, but events will report the paused status. // which will allow debugging of sessions that start on blocked pages this._urlTriggerMatching.urlBlocked = true + this._jsonLdCapture?.scan() // Clear the snapshot timer since we don't want new snapshots while paused clearInterval(this._fullSnapshotTimer) @@ -1016,6 +1048,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt this._urlTriggerMatching.urlBlocked = false this._tryTakeFullSnapshot() + this._scheduleJsonLdScan() this._scheduleFullSnapshot() this._tryAddCustomEvent('recording resumed', { reason: 'left blocked url' }) @@ -1429,8 +1462,9 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt // Clear any queued rrweb events to prevent memory leaks from closures this._queuedRRWebEvents = [] - this._stopJsonLdCapture?.() - this._stopJsonLdCapture = undefined + this._jsonLdCapture?.stop() + this._jsonLdCapture = undefined + this._jsonLdCaptureReady = false this._stopRrweb?.() this._stopRrweb = undefined } @@ -1785,7 +1819,9 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } // Clear the buffer if waiting for a trigger and only keep data from after the current full snapshot - if (rawEvent.type === EventType.FullSnapshot && this._strategy?.hasPendingTriggers(this.sessionId)) { + const jsonLdRemovedFromPendingBuffer = + rawEvent.type === EventType.FullSnapshot && this._strategy?.hasPendingTriggers(this.sessionId) + if (jsonLdRemovedFromPendingBuffer) { this._clearBufferBeforeMostRecentMeta() } @@ -1873,6 +1909,14 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt event.timestamp = sessionIdlePayload.lastActivityTimestamp + sessionIdlePayload.threshold } + const jsonLdCaptureWasReady = this._jsonLdCaptureReady + this._jsonLdCaptureReady = true + if (jsonLdRemovedFromPendingBuffer) { + this._scheduleJsonLdScan(true) + } else if (!jsonLdCaptureWasReady) { + this._scheduleJsonLdScan() + } + const compressionEnabled = this._instance.config.session_recording.compress_events ?? true if ( @@ -2454,6 +2498,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt if (this._eventsDroppedWhileIdle > 0 && bufferCanShip) { this._tryTakeFullSnapshot() } + this._scheduleJsonLdScan() this._scheduleFullSnapshot() } } @@ -2533,7 +2578,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt maskInputFn: undefined, maskAllElementAttributes: false, maskAttributeFn: undefined, - slimDOMOptions: { script: true }, + slimDOMOptions: {}, collectFonts: false, inlineStylesheet: true, // inlining every CSSRule of every sheet is the dominant cost of a full @@ -2577,7 +2622,11 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } } - if (sessionRecordingOptions.slimDOMOptions !== true && sessionRecordingOptions.slimDOMOptions !== 'all') { + if ( + userSessionRecordingOptions?.captureJsonLd === true && + sessionRecordingOptions.slimDOMOptions !== true && + sessionRecordingOptions.slimDOMOptions !== 'all' + ) { sessionRecordingOptions.slimDOMOptions = { ...sessionRecordingOptions.slimDOMOptions, script: true, @@ -2701,18 +2750,25 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt this._rrwebError = false if ( - userSessionRecordingOptions?.captureJsonLd && + userSessionRecordingOptions?.captureJsonLd === true && document && window?.MutationObserver && - !this._stopJsonLdCapture + !this._jsonLdCapture ) { - this._stopJsonLdCapture = startJsonLdCapture(document, window.MutationObserver, { + this._jsonLdCapture = startJsonLdCapture(document, window.MutationObserver, { blockClass: sessionRecordingOptions.blockClass, blockSelector: sessionRecordingOptions.blockSelector, maskTextClass: sessionRecordingOptions.maskTextClass, maskTextSelector: sessionRecordingOptions.maskTextSelector, - emit: (jsonLd) => this._tryAddCustomEvent(JSON_LD_EVENT_TAG, jsonLd), + isEnabled: () => this._canCaptureJsonLd(), + shouldSuppress: () => + this._instance.config.session_recording?.captureJsonLd !== true || + this._urlTriggerMatching.isCurrentUrlBlocked(), + emit: (jsonLd) => this._tryAddJsonLdEvent(jsonLd), }) + if (this._urlTriggerMatching.isCurrentUrlBlocked()) { + this._jsonLdCapture.scan() + } } // We reset the last activity timestamp, resetting the idle timer diff --git a/packages/browser/src/extensions/replay/external/triggerMatching.ts b/packages/browser/src/extensions/replay/external/triggerMatching.ts index 4bb3301619..6904758ae9 100644 --- a/packages/browser/src/extensions/replay/external/triggerMatching.ts +++ b/packages/browser/src/extensions/replay/external/triggerMatching.ts @@ -317,6 +317,18 @@ export class URLTriggerMatching implements TriggerStatusMatching { return this._urlTriggerStatus(sessionId) } + isCurrentUrlBlocked(): boolean { + const url = getTargetingUrl(this._instance) + if (!url) { + return false + } + try { + return sessionRecordingUrlTriggerMatches(url, this._urlBlocklist, this._compiledBlocklistRegexes) + } catch { + return true + } + } + /** * Check URL blocklist and pause/resume recording accordingly * This is separate from trigger checking and is used by both V1 and V2 diff --git a/packages/types/src/posthog-config.ts b/packages/types/src/posthog-config.ts index b20852ef78..a538352d82 100644 --- a/packages/types/src/posthog-config.ts +++ b/packages/types/src/posthog-config.ts @@ -689,6 +689,11 @@ export interface SessionRecordingOptions { /** * Captures sanitized Schema.org JSON-LD as session replay custom events. * JSON-LD inside a text mask or blocked element is never captured. + * The recorder keeps `@id` values without changes. + * The event tag is `$json_ld`. The payload is a JSON-LD object or array. + * The recorder removes all script nodes from snapshots when this option is enabled. + * Supported types are Action, AggregateOffer, AggregateRating, Brand, CreativeWork, Offer, Organization, Person, Place, and Product. + * The JSON-LD observer starts only when this option is true at recording start. * @default false */ captureJsonLd?: boolean From 19ba9103b6f1ffe9e5485d92e2fe3bb442fd0db1 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Tue, 25 Aug 2026 13:55:21 +0100 Subject: [PATCH 3/8] perf(replay): reduce JSON-LD recorder size --- .../extensions/replay/json-ld.test.ts | 2 +- .../src/extensions/replay/external/json-ld.ts | 68 ++++++++----------- .../external/lazy-loaded-session-recorder.ts | 7 +- 3 files changed, 32 insertions(+), 45 deletions(-) diff --git a/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts index 778881a7b5..d704df1c25 100644 --- a/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts @@ -274,7 +274,7 @@ describe('JSON-LD replay capture', () => { ) const capture = startJsonLdCapture(document, MutationObserver, { emit, - isEnabled: () => enabled, + getCaptureState: () => enabled, }) expect(emit).not.toHaveBeenCalled() diff --git a/packages/browser/src/extensions/replay/external/json-ld.ts b/packages/browser/src/extensions/replay/external/json-ld.ts index eea9dfd05d..9874b741b6 100644 --- a/packages/browser/src/extensions/replay/external/json-ld.ts +++ b/packages/browser/src/extensions/replay/external/json-ld.ts @@ -103,7 +103,7 @@ const ENTITY_RULES: Record = { export const JSON_LD_EVENT_TAG = '$json_ld' function isObject(value: unknown): value is Record { - return typeof value === 'object' && !isNull(value) && !isArray(value) + return typeof value === 'object' && !isNull(value) } function getOwnProperty(value: Record, property: string): unknown { @@ -148,9 +148,7 @@ function sanitizeEntity(value: unknown, allowedTypes?: readonly string[]): Recor result[property] = scalar } } else if (isArray(propertyValue)) { - const items = propertyValue - .map((item) => sanitizeEntity(item, rule)) - .filter((item): item is Record => !isNull(item)) + const items = propertyValue.map((item) => sanitizeEntity(item, rule)).filter(isObject) if (items.length) { result[property] = items } @@ -170,7 +168,7 @@ function sanitizeRoot(value: unknown): Record | null { return null } const context = getOwnProperty(value, '@context') - if (typeof context !== 'string' || context.replace(/^http:/, 'https:').replace(/\/$/, '') !== SCHEMA_CONTEXT) { + if (typeof context !== 'string' || !/^https?:\/\/schema\.org\/?$/.test(context)) { return null } @@ -186,10 +184,7 @@ export function sanitizeJsonLd(text: string): [unknown, string] | null { try { const value: unknown = JSON.parse(text) const sanitized = isArray(value) ? value.map(sanitizeRoot) : sanitizeRoot(value) - if ( - isNull(sanitized) || - (isArray(sanitized) && (!sanitized.length || sanitized.some((item) => isNull(item)))) - ) { + if (isNull(sanitized) || (isArray(sanitized) && (!sanitized.length || sanitized.some(isNull)))) { return null } @@ -202,8 +197,7 @@ export function sanitizeJsonLd(text: string): [unknown, string] | null { function isJsonLdScript(node: Node): node is HTMLScriptElement { return ( - node.nodeType === node.ELEMENT_NODE && - (node as Element).tagName === 'SCRIPT' && + node.nodeName === 'SCRIPT' && (node as Element).getAttribute('type')?.trim().toLowerCase() === 'application/ld+json' ) } @@ -215,20 +209,18 @@ type JsonLdPrivacyOptions = { maskTextSelector?: string | null } -function classMatches(element: Element, rule?: string | RegExp): boolean { - if (!rule) { - return false - } - if (typeof rule === 'string') { - return element.classList.contains(rule) +function matchesPrivacyRule(element: Element, classRule?: string | RegExp, selector?: string | null): boolean { + if ( + typeof classRule === 'string' + ? element.classList.contains(classRule) + : classRule && + Array.from(element.classList).some((className) => { + classRule.lastIndex = 0 + return classRule.test(className) + }) + ) { + return true } - return Array.from(element.classList).some((className) => { - rule.lastIndex = 0 - return rule.test(className) - }) -} - -function selectorMatches(element: Element, selector?: string | null): boolean { try { return !!selector && element.matches(selector) } catch { @@ -239,10 +231,8 @@ function selectorMatches(element: Element, selector?: string | null): boolean { function isWithinPrivacyBoundary(element: Element, options: JsonLdPrivacyOptions): boolean { for (let current: Element | null = element; current; ) { if ( - classMatches(current, options.blockClass) || - classMatches(current, options.maskTextClass) || - selectorMatches(current, options.blockSelector) || - selectorMatches(current, options.maskTextSelector) + matchesPrivacyRule(current, options.blockClass, options.blockSelector) || + matchesPrivacyRule(current, options.maskTextClass, options.maskTextSelector) ) { return true } @@ -271,8 +261,8 @@ export function startJsonLdCapture( MutationObserverClass: typeof MutationObserver, options: JsonLdPrivacyOptions & { emit: (jsonLd: unknown) => boolean - isEnabled?: () => boolean - shouldSuppress?: () => boolean + // Null updates the deduplication baseline without an event. + getCaptureState?: () => boolean | null } ): { scan: (force?: boolean) => void; stop: () => void } { const lastJsonByScript = new WeakMap() @@ -280,23 +270,23 @@ export function startJsonLdCapture( const captureScript = (script: HTMLScriptElement, force = false): void => { try { - const shouldSuppress = options.shouldSuppress?.() === true + const captureState = options.getCaptureState ? options.getCaptureState() : true if ( !remainingLength || - (options.isEnabled?.() === false && !shouldSuppress) || + captureState === false || !script.isConnected || script.ownerDocument !== doc || isWithinPrivacyBoundary(script, options) ) { return } - const sanitized = sanitizeJsonLd(script.textContent || '') + const sanitized = sanitizeJsonLd(script.text) if (!sanitized) { lastJsonByScript.delete(script) return } const [jsonLd, json] = sanitized - if (shouldSuppress) { + if (isNull(captureState)) { lastJsonByScript.set(script, json) return } @@ -318,7 +308,7 @@ export function startJsonLdCapture( try { const observer = new MutationObserverClass((mutations) => { try { - if (!remainingLength || (options.isEnabled?.() === false && options.shouldSuppress?.() !== true)) { + if (!remainingLength || options.getCaptureState?.() === false) { return } const scripts = new Set() @@ -358,14 +348,10 @@ export function startJsonLdCapture( subtree: true, }) const scan = (force = false): void => { - if (!remainingLength || (options.isEnabled?.() === false && options.shouldSuppress?.() !== true)) { + if (!remainingLength || options.getCaptureState?.() === false) { return } - doc.querySelectorAll('script').forEach((script) => { - if (isJsonLdScript(script)) { - captureScript(script, force) - } - }) + getJsonLdScripts(doc.documentElement).forEach((script) => captureScript(script, force)) } return { scan, stop: () => observer.disconnect() } diff --git a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts index 4776b2a62d..8919823b67 100644 --- a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts +++ b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts @@ -2760,10 +2760,11 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt blockSelector: sessionRecordingOptions.blockSelector, maskTextClass: sessionRecordingOptions.maskTextClass, maskTextSelector: sessionRecordingOptions.maskTextSelector, - isEnabled: () => this._canCaptureJsonLd(), - shouldSuppress: () => + getCaptureState: () => this._instance.config.session_recording?.captureJsonLd !== true || - this._urlTriggerMatching.isCurrentUrlBlocked(), + this._urlTriggerMatching.isCurrentUrlBlocked() + ? null + : this._canCaptureJsonLd(), emit: (jsonLd) => this._tryAddJsonLdEvent(jsonLd), }) if (this._urlTriggerMatching.isCurrentUrlBlocked()) { From 0fc8e5d72075d172d2489ba32845eea67ac6a650 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Tue, 25 Aug 2026 14:17:43 +0100 Subject: [PATCH 4/8] feat(replay): cover Google JSON-LD types --- .../extensions/replay/json-ld.test.ts | 62 ++++++++-- .../src/extensions/replay/external/json-ld.ts | 106 +++++++++++++----- 2 files changed, 136 insertions(+), 32 deletions(-) diff --git a/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts index d704df1c25..5cb51a32c9 100644 --- a/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts @@ -1,5 +1,10 @@ import { sanitizeJsonLd, startJsonLdCapture } from '../../../extensions/replay/external/json-ld' +const GOOGLE_SEARCH_TYPES = + '3DModel Accommodation Action AdministrativeArea AggregateOffer AggregateRating AlignmentObject Answer Article BedDetails Blog BlogPosting Book BorrowAction Brand BreadcrumbList BroadcastEvent Car Certification Clip Comment ContactPoint Country Course CreativeWork CreativeWorkSeason CreativeWorkSeries CreditCard DataCatalog DataDownload DataFeed Dataset DaySpa DefinedRegion DiscussionForumPosting EducationalOccupationalCredential Electrician EmployerAggregateRating EntryPoint Episode Event Game GeoCoordinates GeoShape HealthClub Hotel HowTo HowToDirection HowToSection HowToStep HowToTip ImageObject InteractionCounter ItemList JobPosting LearningResource Library LibrarySystem ListItem LocalBusiness LocationFeatureSpecification Locksmith LodgingBusiness MathSolver MediaObject MemberProgram MemberProgramTier MerchantReturnPolicy MerchantReturnPolicySeasonalOverride Message MobileApplication MonetaryAmount Movie MusicPlaylist MusicRecording NewsArticle NutritionInformation OccupationalExperienceRequirements Offer OfferShippingDetails OnlineStore OpeningHoursSpecification Organization PeopleAudience PerformingGroup Person Pharmacy Place Plumber PostalAddress PriceSpecification Product ProductGroup ProfilePage PropertyValue QAPage QuantitativeValue Question Quiz Rating ReadAction Recipe Restaurant Review SeekToAction ServicePeriod ShippingConditions ShippingDeliveryTime ShippingRateSettings ShippingService SocialMediaPosting SoftwareApplication SolveMathAction SpeakableSpecification State Store Thing UnitPriceSpecification VacationRental VideoGame VideoObject WatchAction WebApplication WebPage WebPageElement'.split( + ' ' + ) + function jsonLdScript(value: unknown): HTMLScriptElement { const script = document.createElement('script') script.type = 'application/ld+json' @@ -17,6 +22,46 @@ describe('JSON-LD replay capture', () => { document.body.replaceChildren() }) + it('accepts every Google-listed type', () => { + for (const type of GOOGLE_SEARCH_TYPES) { + expect(sanitizeJsonLd(JSON.stringify({ '@context': 'https://schema.org', '@type': type }))?.[0]).toEqual({ + '@context': 'https://schema.org', + '@type': type, + }) + } + }) + + it('sanitizes type arrays and full Schema.org type URLs', () => { + expect( + sanitizeJsonLd( + JSON.stringify({ + '@context': 'https://schema.org', + '@type': ['https://schema.org/Product', 'Car', 'PrivateType', 42], + name: 'Camera', + email: 'private@example.com', + }) + )?.[0] + ).toEqual({ + '@context': 'https://schema.org', + '@type': ['Product', 'Car'], + name: 'Camera', + }) + + expect( + sanitizeJsonLd( + JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'https://schema.org/Organization', + name: 'Acme', + }) + )?.[0] + ).toEqual({ + '@context': 'https://schema.org', + '@type': 'Organization', + name: 'Acme', + }) + }) + it('keeps only path-allowed properties and @id values', () => { const sanitized = sanitizeJsonLd( JSON.stringify({ @@ -109,33 +154,36 @@ describe('JSON-LD replay capture', () => { it.each([ 'not json', JSON.stringify({ '@context': 'https://example.com', '@type': 'Product' }), - JSON.stringify({ '@context': 'https://schema.org', '@type': 'Event' }), + JSON.stringify({ '@context': 'https://schema.org', '@type': 'PrivateType' }), + JSON.stringify({ '@context': 'https://schema.org', '@type': ['PrivateType', 'OtherPrivateType'] }), JSON.stringify({ '@context': 'https://schema.org', '@type': 'constructor', '@id': 'private@example.com' }), JSON.stringify({ '@context': 'https://schema.org', '@type': 'toString', '@id': 'private@example.com' }), JSON.stringify({ '@context': 'https://schema.org', '@type': '__proto__', '@id': 'private@example.com' }), JSON.stringify([ { '@context': 'https://schema.org', '@type': 'Product' }, - { '@context': 'https://schema.org', '@type': 'Event' }, + { '@context': 'https://schema.org', '@type': 'PrivateType' }, ]), ])('drops an invalid JSON-LD document', (value) => { expect(sanitizeJsonLd(value)).toBeNull() }) - it('drops Person properties other than @id', () => { + it.each(['ContactPoint', 'Person', 'PostalAddress'])('drops all %s properties other than @id', (type) => { expect( sanitizeJsonLd( JSON.stringify({ '@context': 'https://schema.org', - '@type': 'Person', - '@id': 'person-id', + '@type': type, + '@id': 'entity-id', name: 'Private name', email: 'private@example.com', + telephone: '+44 0000 000000', + streetAddress: 'Private address', }) )?.[0] ).toEqual({ '@context': 'https://schema.org', - '@type': 'Person', - '@id': 'person-id', + '@type': type, + '@id': 'entity-id', }) }) diff --git a/packages/browser/src/extensions/replay/external/json-ld.ts b/packages/browser/src/extensions/replay/external/json-ld.ts index 9874b741b6..52a419ad04 100644 --- a/packages/browser/src/extensions/replay/external/json-ld.ts +++ b/packages/browser/src/extensions/replay/external/json-ld.ts @@ -3,6 +3,7 @@ import { hasOwnProperty, isArray, isNull, isUndefined } from '@posthog/core' type JsonLdScalar = string | number | boolean | null type JsonLdPropertyRule = true | readonly string[] type JsonLdEntityRules = Record +type JsonLdRuleGroup = readonly [readonly string[], JsonLdEntityRules] const MAX_JSON_LD_LENGTH = 100_000 const MAX_JSON_LD_OUTPUT_LENGTH = 20_000 @@ -48,6 +49,17 @@ const ENTITY_RULES: Record = { aggregateRating: ['AggregateRating'], publisher: ['Organization'], }, + Event: { + startDate: true, + endDate: true, + previousStartDate: true, + eventStatus: true, + eventAttendanceMode: true, + maximumAttendeeCapacity: true, + isAccessibleForFree: true, + aggregateRating: ['AggregateRating'], + offers: ['AggregateOffer', 'Offer'], + }, Offer: { price: true, priceCurrency: true, @@ -100,6 +112,31 @@ const ENTITY_RULES: Record = { }, } +const EMPTY_ENTITY_RULES: JsonLdEntityRules = {} +const INHERITED_RULE_GROUPS: readonly JsonLdRuleGroup[] = [ + ['BorrowAction ReadAction SeekToAction SolveMathAction WatchAction'.split(' '), ENTITY_RULES.Action], + [ + '3DModel Answer Article Blog BlogPosting Book Clip Comment Course CreativeWorkSeason CreativeWorkSeries DataCatalog DataDownload DataFeed Dataset DiscussionForumPosting Episode Game HowTo HowToDirection HowToSection HowToStep HowToTip ImageObject LearningResource MediaObject Message MobileApplication Movie MusicPlaylist MusicRecording NewsArticle ProfilePage QAPage Question Quiz Recipe Review SocialMediaPosting SoftwareApplication VacationRental VideoGame VideoObject WebApplication WebPage WebPageElement'.split( + ' ' + ), + ENTITY_RULES.CreativeWork, + ], + [['BroadcastEvent'], ENTITY_RULES.Event], + [ + 'DaySpa Electrician HealthClub Hotel Library LibrarySystem LocalBusiness Locksmith LodgingBusiness OnlineStore PerformingGroup Pharmacy Plumber Restaurant Store'.split( + ' ' + ), + ENTITY_RULES.Organization, + ], + ['Accommodation AdministrativeArea Country State'.split(' '), ENTITY_RULES.Place], + ['Car ProductGroup'.split(' '), ENTITY_RULES.Product], + ['EmployerAggregateRating Rating'.split(' '), ENTITY_RULES.AggregateRating], +] +const TYPES_WITHOUT_PROPERTIES = + 'AlignmentObject BedDetails BreadcrumbList Certification ContactPoint CreditCard DefinedRegion EducationalOccupationalCredential EntryPoint GeoCoordinates GeoShape InteractionCounter ItemList JobPosting ListItem LocationFeatureSpecification MathSolver MemberProgram MemberProgramTier MerchantReturnPolicy MerchantReturnPolicySeasonalOverride MonetaryAmount NutritionInformation OccupationalExperienceRequirements OfferShippingDetails OpeningHoursSpecification PeopleAudience PostalAddress PriceSpecification PropertyValue QuantitativeValue ServicePeriod ShippingConditions ShippingDeliveryTime ShippingRateSettings ShippingService SpeakableSpecification Thing UnitPriceSpecification'.split( + ' ' + ) + export const JSON_LD_EVENT_TAG = '$json_ld' function isObject(value: unknown): value is Record { @@ -119,43 +156,62 @@ function sanitizeScalar(value: unknown): JsonLdScalar | JsonLdScalar[] | undefin return isScalar(value) || (isArray(value) && value.every(isScalar)) ? value : undefined } +function getEntityRules(type: string): JsonLdEntityRules | undefined { + if (hasOwnProperty.call(ENTITY_RULES, type)) { + return ENTITY_RULES[type] + } + for (const [types, rules] of INHERITED_RULE_GROUPS) { + if (types.includes(type)) { + return rules + } + } + return TYPES_WITHOUT_PROPERTIES.includes(type) ? EMPTY_ENTITY_RULES : undefined +} + +function getEntityTypes(value: unknown): string[] { + const values = typeof value === 'string' ? [value] : isArray(value) ? value : [] + return values + .filter((type): type is string => typeof type === 'string') + .map((type) => type.replace(/^https?:\/\/schema\.org\//, '')) + .filter((type) => !!getEntityRules(type)) +} + function sanitizeEntity(value: unknown, allowedTypes?: readonly string[]): Record | null { if (!isObject(value)) { return null } - const type = getOwnProperty(value, '@type') - if (typeof type !== 'string') { - return null - } - - if (!hasOwnProperty.call(ENTITY_RULES, type) || (allowedTypes && !allowedTypes.includes(type))) { + const typeValue = getOwnProperty(value, '@type') + const types = getEntityTypes(typeValue).filter((type) => !allowedTypes || allowedTypes.includes(type)) + if (!types.length) { return null } - const rules = ENTITY_RULES[type] - const result: Record = { '@type': type } + const result: Record = { '@type': typeof typeValue === 'string' ? types[0] : types } const id = sanitizeScalar(getOwnProperty(value, '@id')) if (!isUndefined(id)) { result['@id'] = id } - for (const property of Object.keys(rules)) { - const propertyValue = getOwnProperty(value, property) - const rule = rules[property] - if (rule === true) { - const scalar = sanitizeScalar(propertyValue) - if (!isUndefined(scalar)) { - result[property] = scalar - } - } else if (isArray(propertyValue)) { - const items = propertyValue.map((item) => sanitizeEntity(item, rule)).filter(isObject) - if (items.length) { - result[property] = items - } - } else { - const nestedEntity = sanitizeEntity(propertyValue, rule) - if (nestedEntity) { - result[property] = nestedEntity + for (const type of types) { + const rules = getEntityRules(type)! + for (const property of Object.keys(rules)) { + const propertyValue = getOwnProperty(value, property) + const rule = rules[property] + if (rule === true) { + const scalar = sanitizeScalar(propertyValue) + if (!isUndefined(scalar)) { + result[property] = scalar + } + } else if (isArray(propertyValue)) { + const items = propertyValue.map((item) => sanitizeEntity(item, rule)).filter(isObject) + if (items.length) { + result[property] = items + } + } else { + const nestedEntity = sanitizeEntity(propertyValue, rule) + if (nestedEntity) { + result[property] = nestedEntity + } } } } From a91779d98da95190591e2fa107d836a3d379de9e Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Tue, 25 Aug 2026 14:44:40 +0100 Subject: [PATCH 5/8] feat(replay): support common JSON-LD graphs --- .../session-recording-masking.spec.ts | 17 ++ .../extensions/replay/json-ld.test.ts | 162 ++++++++++++++++++ .../src/extensions/replay/external/json-ld.ts | 72 ++++++-- 3 files changed, 236 insertions(+), 15 deletions(-) diff --git a/packages/browser/playwright/mocked/session-recording/session-recording-masking.spec.ts b/packages/browser/playwright/mocked/session-recording/session-recording-masking.spec.ts index 384b227dcf..259338725e 100644 --- a/packages/browser/playwright/mocked/session-recording/session-recording-masking.spec.ts +++ b/packages/browser/playwright/mocked/session-recording/session-recording-masking.spec.ts @@ -87,6 +87,20 @@ test.describe('Session recording - masking', () => { email: 'PRIVATE_MANUFACTURER_EMAIL', }, }) + appendJsonLd({ + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'WebSite', + inLanguage: 'ALLOWED_GRAPH_LANGUAGE', + email: 'PRIVATE_GRAPH_EMAIL', + }, + { + '@type': 'PrivateType', + name: 'PRIVATE_GRAPH_ENTITY', + }, + ], + }) appendJsonLd( { '@context': 'https://schema.org', @@ -156,6 +170,7 @@ test.describe('Session recording - masking', () => { expect(eventBytes).toContain('ALLOWED_DYNAMIC_PRODUCT') expect(eventBytes).toContain('ALLOWED_MANUFACTURER') expect(eventBytes).toContain('ALLOWED_MANUFACTURER_LEGAL_NAME') + expect(eventBytes).toContain('ALLOWED_GRAPH_LANGUAGE') expect(eventBytes).not.toContain('"tagName":"script"') for (const privateMarker of [ 'PRIVATE_ATTRIBUTE', @@ -165,6 +180,8 @@ test.describe('Session recording - masking', () => { 'PRIVATE_URL_TOKEN', 'PRIVATE_NESTED_PERSON', 'PRIVATE_MANUFACTURER_EMAIL', + 'PRIVATE_GRAPH_EMAIL', + 'PRIVATE_GRAPH_ENTITY', 'PRIVATE_MASKED_PRODUCT', 'PRIVATE_DYNAMIC_EMAIL', 'PRIVATE_DYNAMIC_MASKED_PRODUCT', diff --git a/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts index 5cb51a32c9..da6f1787fe 100644 --- a/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts @@ -4,6 +4,10 @@ const GOOGLE_SEARCH_TYPES = '3DModel Accommodation Action AdministrativeArea AggregateOffer AggregateRating AlignmentObject Answer Article BedDetails Blog BlogPosting Book BorrowAction Brand BreadcrumbList BroadcastEvent Car Certification Clip Comment ContactPoint Country Course CreativeWork CreativeWorkSeason CreativeWorkSeries CreditCard DataCatalog DataDownload DataFeed Dataset DaySpa DefinedRegion DiscussionForumPosting EducationalOccupationalCredential Electrician EmployerAggregateRating EntryPoint Episode Event Game GeoCoordinates GeoShape HealthClub Hotel HowTo HowToDirection HowToSection HowToStep HowToTip ImageObject InteractionCounter ItemList JobPosting LearningResource Library LibrarySystem ListItem LocalBusiness LocationFeatureSpecification Locksmith LodgingBusiness MathSolver MediaObject MemberProgram MemberProgramTier MerchantReturnPolicy MerchantReturnPolicySeasonalOverride Message MobileApplication MonetaryAmount Movie MusicPlaylist MusicRecording NewsArticle NutritionInformation OccupationalExperienceRequirements Offer OfferShippingDetails OnlineStore OpeningHoursSpecification Organization PeopleAudience PerformingGroup Person Pharmacy Place Plumber PostalAddress PriceSpecification Product ProductGroup ProfilePage PropertyValue QAPage QuantitativeValue Question Quiz Rating ReadAction Recipe Restaurant Review SeekToAction ServicePeriod ShippingConditions ShippingDeliveryTime ShippingRateSettings ShippingService SocialMediaPosting SoftwareApplication SolveMathAction SpeakableSpecification State Store Thing UnitPriceSpecification VacationRental VideoGame VideoObject WatchAction WebApplication WebPage WebPageElement'.split( ' ' ) +const COMMON_SCHEMA_TYPES = + 'AboutPage AudioObject AutoDealer Bakery BarOrPub BusinessEvent CafeOrCoffeeShop CollegeOrUniversity CollectionPage ContactPage Corporation Dentist EducationEvent EducationalOrganization FAQPage Festival FoodEstablishment GovernmentOrganization IndividualProduct LegalService MedicalBusiness MusicEvent NGO OfferCatalog Photograph Physician PodcastEpisode PodcastSeries ProductModel RealEstateAgent ScholarlyArticle School SearchAction SearchResultsPage Service SiteNavigationElement SportsEvent SportsOrganization TVEpisode TVSeries TechArticle TheaterEvent WebSite'.split( + ' ' + ) function jsonLdScript(value: unknown): HTMLScriptElement { const script = document.createElement('script') @@ -31,6 +35,163 @@ describe('JSON-LD replay capture', () => { } }) + it('accepts common Schema.org types outside the Google list', () => { + for (const type of COMMON_SCHEMA_TYPES) { + expect(sanitizeJsonLd(JSON.stringify({ '@context': 'https://schema.org', '@type': type }))?.[0]).toEqual({ + '@context': 'https://schema.org', + '@type': type, + }) + } + }) + + it('sanitizes root graphs and drops unsupported graph entities', () => { + expect( + sanitizeJsonLd( + JSON.stringify({ + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'WebSite', + datePublished: '2026-08-25', + email: 'private@example.com', + potentialAction: { + '@type': 'SearchAction', + actionStatus: 'https://schema.org/PotentialActionStatus', + target: 'https://example.com/search?q={private}', + }, + }, + { + '@type': 'FAQPage', + inLanguage: 'en', + text: 'Private question and answer', + }, + { + '@type': 'Person', + '@id': 'person-id', + name: 'Private name', + }, + { + '@type': 'PrivateType', + email: 'private@example.com', + }, + 'private@example.com', + ], + }) + )?.[0] + ).toEqual({ + '@context': 'https://schema.org', + '@graph': [ + { + '@type': 'WebSite', + datePublished: '2026-08-25', + potentialAction: { + '@type': 'SearchAction', + actionStatus: 'https://schema.org/PotentialActionStatus', + }, + }, + { + '@type': 'FAQPage', + inLanguage: 'en', + }, + { + '@type': 'Person', + '@id': 'person-id', + }, + ], + }) + }) + + it.each(['BreadcrumbList', 'ItemList'])('sanitizes nested items in %s', (type) => { + expect( + sanitizeJsonLd( + JSON.stringify({ + '@context': 'https://schema.org', + '@type': type, + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'Private label', + item: { + '@type': 'Product', + name: 'Camera', + email: 'private@example.com', + }, + }, + { + '@type': 'Person', + '@id': 'private-person', + }, + ], + }) + )?.[0] + ).toEqual({ + '@context': 'https://schema.org', + '@type': type, + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + item: { + '@type': 'Product', + name: 'Camera', + }, + }, + ], + }) + }) + + it('sanitizes offer catalogs and services', () => { + expect( + sanitizeJsonLd( + JSON.stringify([ + { + '@context': 'https://schema.org', + '@type': 'OfferCatalog', + name: 'Services', + itemListElement: { + '@type': 'Offer', + price: 100, + email: 'private@example.com', + }, + }, + { + '@context': 'https://schema.org', + '@type': 'Service', + name: 'Installation', + serviceType: 'Installation', + email: 'private@example.com', + provider: { + '@type': 'EducationalOrganization', + name: 'Acme', + telephone: '+44 0000 000000', + }, + }, + ]) + )?.[0] + ).toEqual([ + { + '@context': 'https://schema.org', + '@type': 'OfferCatalog', + name: 'Services', + itemListElement: { + '@type': 'Offer', + price: 100, + }, + }, + { + '@context': 'https://schema.org', + '@type': 'Service', + name: 'Installation', + serviceType: 'Installation', + provider: { + '@type': 'EducationalOrganization', + name: 'Acme', + }, + }, + ]) + }) + it('sanitizes type arrays and full Schema.org type URLs', () => { expect( sanitizeJsonLd( @@ -156,6 +317,7 @@ describe('JSON-LD replay capture', () => { JSON.stringify({ '@context': 'https://example.com', '@type': 'Product' }), JSON.stringify({ '@context': 'https://schema.org', '@type': 'PrivateType' }), JSON.stringify({ '@context': 'https://schema.org', '@type': ['PrivateType', 'OtherPrivateType'] }), + JSON.stringify({ '@context': 'https://schema.org', '@graph': [{ '@type': 'PrivateType' }] }), JSON.stringify({ '@context': 'https://schema.org', '@type': 'constructor', '@id': 'private@example.com' }), JSON.stringify({ '@context': 'https://schema.org', '@type': 'toString', '@id': 'private@example.com' }), JSON.stringify({ '@context': 'https://schema.org', '@type': '__proto__', '@id': 'private@example.com' }), diff --git a/packages/browser/src/extensions/replay/external/json-ld.ts b/packages/browser/src/extensions/replay/external/json-ld.ts index 52a419ad04..15cc238c64 100644 --- a/packages/browser/src/extensions/replay/external/json-ld.ts +++ b/packages/browser/src/extensions/replay/external/json-ld.ts @@ -8,6 +8,13 @@ type JsonLdRuleGroup = readonly [readonly string[], JsonLdEntityRules] const MAX_JSON_LD_LENGTH = 100_000 const MAX_JSON_LD_OUTPUT_LENGTH = 20_000 const SCHEMA_CONTEXT = 'https://schema.org' +const ANY_ENTITY_TYPES: readonly string[] = [] +const ACTION_TYPES = 'Action BorrowAction ReadAction SearchAction SeekToAction SolveMathAction WatchAction'.split(' ') +const ORGANIZATION_TYPES = + 'AutoDealer Bakery BarOrPub CafeOrCoffeeShop CollegeOrUniversity Corporation DaySpa Dentist EducationalOrganization Electrician FoodEstablishment GovernmentOrganization HealthClub Hotel LegalService Library LibrarySystem LocalBusiness Locksmith LodgingBusiness MedicalBusiness NGO OnlineStore Organization PerformingGroup Pharmacy Physician Plumber RealEstateAgent Restaurant School SportsOrganization Store'.split( + ' ' + ) +const PLACE_TYPES = 'Accommodation AdministrativeArea Country Place State'.split(' ') const ENTITY_RULES: Record = { Action: { @@ -31,6 +38,9 @@ const ENTITY_RULES: Record = { Brand: { name: true, }, + BreadcrumbList: { + itemListElement: ['ListItem'], + }, CreativeWork: { genre: true, inLanguage: true, @@ -47,7 +57,8 @@ const ENTITY_RULES: Record = { educationalUse: true, interactivityType: true, aggregateRating: ['AggregateRating'], - publisher: ['Organization'], + potentialAction: ACTION_TYPES, + publisher: ORGANIZATION_TYPES, }, Event: { startDate: true, @@ -60,13 +71,22 @@ const ENTITY_RULES: Record = { aggregateRating: ['AggregateRating'], offers: ['AggregateOffer', 'Offer'], }, + ItemList: { + itemListOrder: true, + numberOfItems: true, + itemListElement: ['ListItem'], + }, + ListItem: { + position: true, + item: ANY_ENTITY_TYPES, + }, Offer: { price: true, priceCurrency: true, priceValidUntil: true, availability: true, itemCondition: true, - seller: ['Organization'], + seller: ORGANIZATION_TYPES, }, Organization: { name: true, @@ -106,34 +126,45 @@ const ENTITY_RULES: Record = { productionDate: true, releaseDate: true, brand: ['Brand', 'Organization'], - manufacturer: ['Organization'], + manufacturer: ORGANIZATION_TYPES, offers: ['Offer', 'AggregateOffer'], aggregateRating: ['AggregateRating'], }, + Service: { + name: true, + serviceType: true, + category: true, + provider: ORGANIZATION_TYPES, + areaServed: PLACE_TYPES, + offers: ['AggregateOffer', 'Offer'], + aggregateRating: ['AggregateRating'], + }, + OfferCatalog: { + name: true, + itemListElement: ANY_ENTITY_TYPES, + }, } const EMPTY_ENTITY_RULES: JsonLdEntityRules = {} const INHERITED_RULE_GROUPS: readonly JsonLdRuleGroup[] = [ - ['BorrowAction ReadAction SeekToAction SolveMathAction WatchAction'.split(' '), ENTITY_RULES.Action], + [ACTION_TYPES, ENTITY_RULES.Action], [ - '3DModel Answer Article Blog BlogPosting Book Clip Comment Course CreativeWorkSeason CreativeWorkSeries DataCatalog DataDownload DataFeed Dataset DiscussionForumPosting Episode Game HowTo HowToDirection HowToSection HowToStep HowToTip ImageObject LearningResource MediaObject Message MobileApplication Movie MusicPlaylist MusicRecording NewsArticle ProfilePage QAPage Question Quiz Recipe Review SocialMediaPosting SoftwareApplication VacationRental VideoGame VideoObject WebApplication WebPage WebPageElement'.split( + '3DModel AboutPage Answer Article AudioObject Blog BlogPosting Book Clip CollectionPage Comment ContactPage Course CreativeWorkSeason CreativeWorkSeries DataCatalog DataDownload DataFeed Dataset DiscussionForumPosting Episode FAQPage Game HowTo HowToDirection HowToSection HowToStep HowToTip ImageObject LearningResource MediaObject Message MobileApplication Movie MusicPlaylist MusicRecording NewsArticle Photograph PodcastEpisode PodcastSeries ProfilePage QAPage Question Quiz Recipe Review ScholarlyArticle SearchResultsPage SiteNavigationElement SocialMediaPosting SoftwareApplication TVEpisode TVSeries TechArticle VacationRental VideoGame VideoObject WebApplication WebPage WebPageElement WebSite'.split( ' ' ), ENTITY_RULES.CreativeWork, ], - [['BroadcastEvent'], ENTITY_RULES.Event], [ - 'DaySpa Electrician HealthClub Hotel Library LibrarySystem LocalBusiness Locksmith LodgingBusiness OnlineStore PerformingGroup Pharmacy Plumber Restaurant Store'.split( - ' ' - ), - ENTITY_RULES.Organization, + 'BroadcastEvent BusinessEvent EducationEvent Festival MusicEvent SportsEvent TheaterEvent'.split(' '), + ENTITY_RULES.Event, ], - ['Accommodation AdministrativeArea Country State'.split(' '), ENTITY_RULES.Place], - ['Car ProductGroup'.split(' '), ENTITY_RULES.Product], + [ORGANIZATION_TYPES, ENTITY_RULES.Organization], + [PLACE_TYPES, ENTITY_RULES.Place], + ['Car IndividualProduct ProductGroup ProductModel'.split(' '), ENTITY_RULES.Product], ['EmployerAggregateRating Rating'.split(' '), ENTITY_RULES.AggregateRating], ] const TYPES_WITHOUT_PROPERTIES = - 'AlignmentObject BedDetails BreadcrumbList Certification ContactPoint CreditCard DefinedRegion EducationalOccupationalCredential EntryPoint GeoCoordinates GeoShape InteractionCounter ItemList JobPosting ListItem LocationFeatureSpecification MathSolver MemberProgram MemberProgramTier MerchantReturnPolicy MerchantReturnPolicySeasonalOverride MonetaryAmount NutritionInformation OccupationalExperienceRequirements OfferShippingDetails OpeningHoursSpecification PeopleAudience PostalAddress PriceSpecification PropertyValue QuantitativeValue ServicePeriod ShippingConditions ShippingDeliveryTime ShippingRateSettings ShippingService SpeakableSpecification Thing UnitPriceSpecification'.split( + 'AlignmentObject BedDetails Certification ContactPoint CreditCard DefinedRegion EducationalOccupationalCredential EntryPoint GeoCoordinates GeoShape InteractionCounter JobPosting LocationFeatureSpecification MathSolver MemberProgram MemberProgramTier MerchantReturnPolicy MerchantReturnPolicySeasonalOverride MonetaryAmount NutritionInformation OccupationalExperienceRequirements OfferShippingDetails OpeningHoursSpecification PeopleAudience PostalAddress PriceSpecification PropertyValue QuantitativeValue ServicePeriod ShippingConditions ShippingDeliveryTime ShippingRateSettings ShippingService SpeakableSpecification Thing UnitPriceSpecification'.split( ' ' ) @@ -181,7 +212,9 @@ function sanitizeEntity(value: unknown, allowedTypes?: readonly string[]): Recor return null } const typeValue = getOwnProperty(value, '@type') - const types = getEntityTypes(typeValue).filter((type) => !allowedTypes || allowedTypes.includes(type)) + const types = getEntityTypes(typeValue).filter( + (type) => !allowedTypes || !allowedTypes.length || allowedTypes.includes(type) + ) if (!types.length) { return null } @@ -229,7 +262,16 @@ function sanitizeRoot(value: unknown): Record | null { } const entity = sanitizeEntity(value) - return entity ? { '@context': SCHEMA_CONTEXT, ...entity } : null + if (entity) { + return { '@context': SCHEMA_CONTEXT, ...entity } + } + + const graph = getOwnProperty(value, '@graph') + if (!isArray(graph)) { + return null + } + const entities = graph.map((entity) => sanitizeEntity(entity)).filter(isObject) + return entities.length ? { '@context': SCHEMA_CONTEXT, '@graph': entities } : null } export function sanitizeJsonLd(text: string): [unknown, string] | null { From 7b2c15187279aca5b10272986c151f7d664f2df5 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Tue, 25 Aug 2026 16:26:38 +0100 Subject: [PATCH 6/8] perf(replay): simplify JSON-LD capture --- .../extensions/replay/json-ld.test.ts | 49 ++++++++++- .../src/extensions/replay/external/json-ld.ts | 85 +++++++------------ packages/browser/terser-mangled-names.json | 5 ++ 3 files changed, 82 insertions(+), 57 deletions(-) diff --git a/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts index da6f1787fe..85a1b76805 100644 --- a/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/json-ld.test.ts @@ -285,6 +285,31 @@ describe('JSON-LD replay capture', () => { }) }) + it('keeps non-PII leaf properties without a type-specific path', () => { + expect( + sanitizeJsonLd( + JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Thing', + availability: 'https://schema.org/InStock', + isAccessibleForFree: true, + name: 'Private name', + numberOfItems: 2, + priceCurrency: 'GBP', + ratingValue: 4.5, + }) + )?.[0] + ).toEqual({ + '@context': 'https://schema.org', + '@type': 'Thing', + availability: 'https://schema.org/InStock', + isAccessibleForFree: true, + numberOfItems: 2, + priceCurrency: 'GBP', + ratingValue: 4.5, + }) + }) + it('sanitizes root and nested entity arrays', () => { expect( sanitizeJsonLd( @@ -329,7 +354,7 @@ describe('JSON-LD replay capture', () => { expect(sanitizeJsonLd(value)).toBeNull() }) - it.each(['ContactPoint', 'Person', 'PostalAddress'])('drops all %s properties other than @id', (type) => { + it.each(['ContactPoint', 'Person', 'PostalAddress'])('drops PII-bearing %s properties', (type) => { expect( sanitizeJsonLd( JSON.stringify({ @@ -350,8 +375,8 @@ describe('JSON-LD replay capture', () => { }) it('ignores inherited JSON-LD properties', () => { - const properties = ['@context', '@type', '@id', 'name'] - const values = ['https://schema.org', 'Product', 'private-id', 'private-name'] + const properties = ['@context', '@type', '@id', 'name', 'ratingValue'] + const values = ['https://schema.org', 'Product', 'private-id', 'private-name', 5] const descriptors = properties.map((property) => Object.getOwnPropertyDescriptor(Object.prototype, property)) let inheritedContext: ReturnType let inheritedType: ReturnType @@ -612,4 +637,22 @@ describe('JSON-LD replay capture', () => { expect(emit).toHaveBeenCalledTimes(2) capture.stop() }) + + it('retries an event that a forced scan cannot emit', () => { + let acceptsEvents = true + const emit = jest.fn(() => acceptsEvents) + document.body.appendChild( + jsonLdScript({ '@context': 'https://schema.org', '@type': 'Product', name: 'Camera' }) + ) + const capture = startJsonLdCapture(document, MutationObserver, { emit }) + + capture.scan() + acceptsEvents = false + capture.scan(true) + acceptsEvents = true + capture.scan() + + expect(emit).toHaveBeenCalledTimes(3) + capture.stop() + }) }) diff --git a/packages/browser/src/extensions/replay/external/json-ld.ts b/packages/browser/src/extensions/replay/external/json-ld.ts index 15cc238c64..736276d1be 100644 --- a/packages/browser/src/extensions/replay/external/json-ld.ts +++ b/packages/browser/src/extensions/replay/external/json-ld.ts @@ -9,6 +9,10 @@ const MAX_JSON_LD_LENGTH = 100_000 const MAX_JSON_LD_OUTPUT_LENGTH = 20_000 const SCHEMA_CONTEXT = 'https://schema.org' const ANY_ENTITY_TYPES: readonly string[] = [] +const TYPE_INDEPENDENT_LEAF_PROPERTIES = + 'actionStatus availability bestRating contentRating encodingFormat eventAttendanceMode eventStatus highPrice inLanguage isAccessibleForFree isFamilyFriendly itemCondition itemListOrder lowPrice maximumAttendeeCapacity nonprofitStatus numberOfItems offerCount position price priceCurrency priceValidUntil publicAccess ratingCount ratingValue reviewCount smokingAllowed worstRating'.split( + ' ' + ) const ACTION_TYPES = 'Action BorrowAction ReadAction SearchAction SeekToAction SolveMathAction WatchAction'.split(' ') const ORGANIZATION_TYPES = 'AutoDealer Bakery BarOrPub CafeOrCoffeeShop CollegeOrUniversity Corporation DaySpa Dentist EducationalOrganization Electrician FoodEstablishment GovernmentOrganization HealthClub Hotel LegalService Library LibrarySystem LocalBusiness Locksmith LodgingBusiness MedicalBusiness NGO OnlineStore Organization PerformingGroup Pharmacy Physician Plumber RealEstateAgent Restaurant School SportsOrganization Store'.split( @@ -17,24 +21,9 @@ const ORGANIZATION_TYPES = const PLACE_TYPES = 'Accommodation AdministrativeArea Country Place State'.split(' ') const ENTITY_RULES: Record = { - Action: { - actionStatus: true, - }, AggregateOffer: { - lowPrice: true, - highPrice: true, - priceCurrency: true, - offerCount: true, - availability: true, offers: ['Offer'], }, - AggregateRating: { - ratingValue: true, - ratingCount: true, - reviewCount: true, - bestRating: true, - worstRating: true, - }, Brand: { name: true, }, @@ -43,15 +32,10 @@ const ENTITY_RULES: Record = { }, CreativeWork: { genre: true, - inLanguage: true, - encodingFormat: true, dateCreated: true, dateModified: true, datePublished: true, expires: true, - isAccessibleForFree: true, - isFamilyFriendly: true, - contentRating: true, learningResourceType: true, educationalLevel: true, educationalUse: true, @@ -64,28 +48,16 @@ const ENTITY_RULES: Record = { startDate: true, endDate: true, previousStartDate: true, - eventStatus: true, - eventAttendanceMode: true, - maximumAttendeeCapacity: true, - isAccessibleForFree: true, aggregateRating: ['AggregateRating'], offers: ['AggregateOffer', 'Offer'], }, ItemList: { - itemListOrder: true, - numberOfItems: true, itemListElement: ['ListItem'], }, ListItem: { - position: true, item: ANY_ENTITY_TYPES, }, Offer: { - price: true, - priceCurrency: true, - priceValidUntil: true, - availability: true, - itemCondition: true, seller: ORGANIZATION_TYPES, }, Organization: { @@ -93,16 +65,11 @@ const ENTITY_RULES: Record = { legalName: true, foundingDate: true, dissolutionDate: true, - nonprofitStatus: true, aggregateRating: ['AggregateRating'], brand: ['Brand'], }, Person: {}, Place: { - publicAccess: true, - smokingAllowed: true, - maximumAttendeeCapacity: true, - isAccessibleForFree: true, aggregateRating: ['AggregateRating'], }, Product: { @@ -147,7 +114,7 @@ const ENTITY_RULES: Record = { const EMPTY_ENTITY_RULES: JsonLdEntityRules = {} const INHERITED_RULE_GROUPS: readonly JsonLdRuleGroup[] = [ - [ACTION_TYPES, ENTITY_RULES.Action], + [ACTION_TYPES, EMPTY_ENTITY_RULES], [ '3DModel AboutPage Answer Article AudioObject Blog BlogPosting Book Clip CollectionPage Comment ContactPage Course CreativeWorkSeason CreativeWorkSeries DataCatalog DataDownload DataFeed Dataset DiscussionForumPosting Episode FAQPage Game HowTo HowToDirection HowToSection HowToStep HowToTip ImageObject LearningResource MediaObject Message MobileApplication Movie MusicPlaylist MusicRecording NewsArticle Photograph PodcastEpisode PodcastSeries ProfilePage QAPage Question Quiz Recipe Review ScholarlyArticle SearchResultsPage SiteNavigationElement SocialMediaPosting SoftwareApplication TVEpisode TVSeries TechArticle VacationRental VideoGame VideoObject WebApplication WebPage WebPageElement WebSite'.split( ' ' @@ -161,7 +128,7 @@ const INHERITED_RULE_GROUPS: readonly JsonLdRuleGroup[] = [ [ORGANIZATION_TYPES, ENTITY_RULES.Organization], [PLACE_TYPES, ENTITY_RULES.Place], ['Car IndividualProduct ProductGroup ProductModel'.split(' '), ENTITY_RULES.Product], - ['EmployerAggregateRating Rating'.split(' '), ENTITY_RULES.AggregateRating], + ['AggregateRating EmployerAggregateRating Rating'.split(' '), EMPTY_ENTITY_RULES], ] const TYPES_WITHOUT_PROPERTIES = 'AlignmentObject BedDetails Certification ContactPoint CreditCard DefinedRegion EducationalOccupationalCredential EntryPoint GeoCoordinates GeoShape InteractionCounter JobPosting LocationFeatureSpecification MathSolver MemberProgram MemberProgramTier MerchantReturnPolicy MerchantReturnPolicySeasonalOverride MonetaryAmount NutritionInformation OccupationalExperienceRequirements OfferShippingDetails OpeningHoursSpecification PeopleAudience PostalAddress PriceSpecification PropertyValue QuantitativeValue ServicePeriod ShippingConditions ShippingDeliveryTime ShippingRateSettings ShippingService SpeakableSpecification Thing UnitPriceSpecification'.split( @@ -225,6 +192,13 @@ function sanitizeEntity(value: unknown, allowedTypes?: readonly string[]): Recor result['@id'] = id } + for (const property of TYPE_INDEPENDENT_LEAF_PROPERTIES) { + const scalar = sanitizeScalar(getOwnProperty(value, property)) + if (!isUndefined(scalar)) { + result[property] = scalar + } + } + for (const type of types) { const rules = getEntityRules(type)! for (const property of Object.keys(rules)) { @@ -364,11 +338,12 @@ export function startJsonLdCapture( } ): { scan: (force?: boolean) => void; stop: () => void } { const lastJsonByScript = new WeakMap() + const getCaptureState = options.getCaptureState || (() => true) let remainingLength = MAX_JSON_LD_LENGTH - const captureScript = (script: HTMLScriptElement, force = false): void => { + const captureScript = (script: HTMLScriptElement): void => { try { - const captureState = options.getCaptureState ? options.getCaptureState() : true + const captureState = getCaptureState() if ( !remainingLength || captureState === false || @@ -388,7 +363,7 @@ export function startJsonLdCapture( lastJsonByScript.set(script, json) return } - if (force || lastJsonByScript.get(script) !== json) { + if (lastJsonByScript.get(script) !== json) { if (json.length > remainingLength) { remainingLength = 0 return @@ -406,33 +381,30 @@ export function startJsonLdCapture( try { const observer = new MutationObserverClass((mutations) => { try { - if (!remainingLength || options.getCaptureState?.() === false) { + if (!remainingLength || getCaptureState() === false) { return } - const scripts = new Set() - const addScripts = (node: Node): void => { + const captureScripts = (node: Node): void => { for (const script of getJsonLdScripts(node)) { - scripts.add(script) + captureScript(script) } } for (const mutation of mutations) { if (mutation.type === 'childList') { if (isJsonLdScript(mutation.target)) { - scripts.add(mutation.target) + captureScript(mutation.target) } - mutation.addedNodes.forEach(addScripts) + mutation.addedNodes.forEach(captureScripts) } else if (mutation.type === 'characterData') { const parent = mutation.target.parentNode if (parent && isJsonLdScript(parent)) { - scripts.add(parent) + captureScript(parent) } } else if (mutation.type === 'attributes' && isJsonLdScript(mutation.target)) { - scripts.add(mutation.target) + captureScript(mutation.target) } } - - scripts.forEach((script) => captureScript(script)) } catch { return } @@ -446,10 +418,15 @@ export function startJsonLdCapture( subtree: true, }) const scan = (force = false): void => { - if (!remainingLength || options.getCaptureState?.() === false) { + if (!remainingLength || getCaptureState() === false) { return } - getJsonLdScripts(doc.documentElement).forEach((script) => captureScript(script, force)) + getJsonLdScripts(doc.documentElement).forEach((script) => { + if (force) { + lastJsonByScript.delete(script) + } + captureScript(script) + }) } return { scan, stop: () => observer.disconnect() } diff --git a/packages/browser/terser-mangled-names.json b/packages/browser/terser-mangled-names.json index 2abbf30572..44b4060295 100644 --- a/packages/browser/terser-mangled-names.json +++ b/packages/browser/terser-mangled-names.json @@ -49,6 +49,7 @@ "_callLoadToolbar", "_callbackFlavor", "_campaign_params_url", + "_canCaptureJsonLd", "_canUseSessionStorage", "_cancelAllPendingTours", "_cancelEventToItems", @@ -401,6 +402,8 @@ "_isWidgetSurveyOpen", "_is_persistence_disabled", "_is_supported", + "_jsonLdCapture", + "_jsonLdCaptureReady", "_keepaliveDisabled", "_lastActivityTimestamp", "_lastCheckedUrl", @@ -668,6 +671,7 @@ "_scheduleCheck", "_scheduleFlushBuffer", "_scheduleFullSnapshot", + "_scheduleJsonLdScan", "_scopeName", "_scriptName", "_scrollPosition", @@ -792,6 +796,7 @@ "_triggeringPage", "_trimToMaxBytes", "_tryAddCustomEvent", + "_tryAddJsonLdEvent", "_tryRRWebMethod", "_tryTakeFullSnapshot", "_typeByName", From 7a57ee8026aea07e2bce1d4f3094b93be9c37917 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Wed, 26 Aug 2026 09:24:41 +0100 Subject: [PATCH 7/8] fix(replay): address JSON-LD review feedback --- packages/browser/src/extensions/replay/external/json-ld.ts | 6 +----- .../replay/external/lazy-loaded-session-recorder.ts | 1 + packages/types/src/posthog-config.ts | 2 +- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/browser/src/extensions/replay/external/json-ld.ts b/packages/browser/src/extensions/replay/external/json-ld.ts index 736276d1be..c9aace61a6 100644 --- a/packages/browser/src/extensions/replay/external/json-ld.ts +++ b/packages/browser/src/extensions/replay/external/json-ld.ts @@ -1,4 +1,4 @@ -import { hasOwnProperty, isArray, isNull, isUndefined } from '@posthog/core' +import { hasOwnProperty, isArray, isNull, isObject, isUndefined } from '@posthog/core' type JsonLdScalar = string | number | boolean | null type JsonLdPropertyRule = true | readonly string[] @@ -137,10 +137,6 @@ const TYPES_WITHOUT_PROPERTIES = export const JSON_LD_EVENT_TAG = '$json_ld' -function isObject(value: unknown): value is Record { - return typeof value === 'object' && !isNull(value) -} - function getOwnProperty(value: Record, property: string): unknown { return hasOwnProperty.call(value, property) ? value[property] : undefined } diff --git a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts index 8919823b67..3d2ff0a89d 100644 --- a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts +++ b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts @@ -924,6 +924,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt } private _scheduleJsonLdScan(force = false): void { + // Run the scan after the current rrweb event updates the JSON-LD capture state. // eslint-disable-next-line compat/compat Promise.resolve().then(() => this._jsonLdCapture?.scan(force)) } diff --git a/packages/types/src/posthog-config.ts b/packages/types/src/posthog-config.ts index 6fbf743137..03179ec6dc 100644 --- a/packages/types/src/posthog-config.ts +++ b/packages/types/src/posthog-config.ts @@ -692,8 +692,8 @@ export interface SessionRecordingOptions { * The recorder keeps `@id` values without changes. * The event tag is `$json_ld`. The payload is a JSON-LD object or array. * The recorder removes all script nodes from snapshots when this option is enabled. - * Supported types are Action, AggregateOffer, AggregateRating, Brand, CreativeWork, Offer, Organization, Person, Place, and Product. * The JSON-LD observer starts only when this option is true at recording start. + * @see https://github.com/PostHog/posthog-js/blob/main/packages/browser/src/extensions/replay/external/json-ld.ts * @default false */ captureJsonLd?: boolean From b42b2a6fa5b9d6eb896bb28070cc36110c26fff8 Mon Sep 17 00:00:00 2001 From: Robbie Coomber Date: Wed, 26 Aug 2026 09:41:32 +0100 Subject: [PATCH 8/8] fix(replay): suppress JSON-LD during URL transitions --- .../extensions/replay/lazy-sessionrecording.test.ts | 7 ++++++- .../replay/external/lazy-loaded-session-recorder.ts | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts index c637e718bb..1dbfc828e3 100644 --- a/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts +++ b/packages/browser/src/__tests__/extensions/replay/lazy-sessionrecording.test.ts @@ -4595,13 +4595,18 @@ describe('Lazy SessionRecording', () => { // Simulate URL change to allowed URL fakeNavigateTo('https://test.com/allowed') + blockedJsonLd.textContent = JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: 'Product changed before resume', + }) // Verify recording resumes with resume event _emit(createIncrementalSnapshot({ data: { source: 5 } })) await Promise.resolve() expect(_addCustomEvent).not.toHaveBeenCalledWith( '$json_ld', - expect.objectContaining({ name: 'Blocked page product' }) + expect.objectContaining({ name: 'Product changed before resume' }) ) blockedJsonLd.textContent = JSON.stringify({ diff --git a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts index 3d2ff0a89d..6344982799 100644 --- a/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts +++ b/packages/browser/src/extensions/replay/external/lazy-loaded-session-recorder.ts @@ -1046,6 +1046,8 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt return } + // Baseline JSON-LD while capture is suppressed so blocked-page mutations cannot emit after resume. + this._jsonLdCapture?.scan() this._urlTriggerMatching.urlBlocked = false this._tryTakeFullSnapshot() @@ -2763,6 +2765,7 @@ export class LazyLoadedSessionRecording implements LazyLoadedSessionRecordingInt maskTextSelector: sessionRecordingOptions.maskTextSelector, getCaptureState: () => this._instance.config.session_recording?.captureJsonLd !== true || + this._urlTriggerMatching.urlBlocked || this._urlTriggerMatching.isCurrentUrlBlocked() ? null : this._canCaptureJsonLd(),