Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
Loading