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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/replay-json-ld-events.md
Original file line number Diff line number Diff line change
@@ -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`. When enabled, the recorder emits sanitized JSON-LD as custom replay events and excludes all script elements from replay snapshots.
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -8,11 +8,12 @@ import { CaptureResult } from '@/types'

const remoteMaskingTextSelector = '*'

const startOptions = (masking: Record<string, any>) => ({
const startOptions = (masking: Record<string, any>, captureJsonLd = false) => ({
options: {
session_recording: {
// not the default but makes for easier test assertions
compress_events: false,
captureJsonLd,
},
},
flagsResponseOverrides: {
Expand Down Expand Up @@ -55,6 +56,140 @@ 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<string, unknown>, 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',
'@graph': [
{
'@type': 'WebSite',
inLanguage: 'ALLOWED_GRAPH_LANGUAGE',
email: 'PRIVATE_GRAPH_EMAIL',
},
{
'@type': 'PrivateType',
name: 'PRIVATE_GRAPH_ENTITY',
},
],
})
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).toContain('ALLOWED_GRAPH_LANGUAGE')
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_GRAPH_EMAIL',
'PRIVATE_GRAPH_ENTITY',
'PRIVATE_MASKED_PRODUCT',
'PRIVATE_DYNAMIC_EMAIL',
'PRIVATE_DYNAMIC_MASKED_PRODUCT',
]) {
expect(eventBytes).not.toContain(privateMarker)
}
})

test('masks text', async ({ page, context }) => {
await start(
startOptions({
Expand Down
Loading