-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
test(replay): Test compressed recording payloads #7082
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import * as Sentry from '@sentry/browser'; | ||
import { Replay } from '@sentry/replay'; | ||
|
||
window.Sentry = Sentry; | ||
window.Replay = new Replay({ | ||
flushMinDelay: 500, | ||
flushMaxDelay: 500, | ||
useCompression: true, | ||
}); | ||
|
||
Sentry.init({ | ||
dsn: 'https://[email protected]/1337', | ||
sampleRate: 0, | ||
replaysSessionSampleRate: 1.0, | ||
replaysOnErrorSampleRate: 0.0, | ||
|
||
integrations: [window.Replay], | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
document.getElementById('go-background').addEventListener('click', () => { | ||
Object.defineProperty(document, 'hidden', { value: true, writable: true }); | ||
const ev = document.createEvent('Event'); | ||
ev.initEvent('visibilitychange'); | ||
document.dispatchEvent(ev); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
<!DOCTYPE html> | ||
<html> | ||
<head> | ||
<meta charset="utf-8" /> | ||
</head> | ||
<body> | ||
<button id="go-background">New Tab</button> | ||
</body> | ||
</html> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import { expect } from '@playwright/test'; | ||
|
||
import { sentryTest } from '../../../utils/fixtures'; | ||
import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates'; | ||
import { getFullRecordingSnapshots, getReplayEvent, waitForReplayRequest } from '../../../utils/replayHelpers'; | ||
|
||
sentryTest('replay recording should be compressed by default', async ({ getLocalTestPath, page }) => { | ||
// Replay bundles are es6 only | ||
if (process.env.PW_BUNDLE && process.env.PW_BUNDLE.startsWith('bundle_es5')) { | ||
sentryTest.skip(); | ||
} | ||
|
||
const reqPromise0 = waitForReplayRequest(page, 0); | ||
|
||
await page.route('https://dsn.ingest.sentry.io/**/*', route => { | ||
return route.fulfill({ | ||
status: 200, | ||
contentType: 'application/json', | ||
body: JSON.stringify({ id: 'test-id' }), | ||
}); | ||
}); | ||
|
||
const url = await getLocalTestPath({ testDir: __dirname }); | ||
|
||
await page.goto(url); | ||
const replayEvent0 = getReplayEvent(await reqPromise0); | ||
expect(replayEvent0).toEqual(getExpectedReplayEvent()); | ||
|
||
const snapshots = getFullRecordingSnapshots(await reqPromise0); | ||
expect(snapshots.length).toEqual(1); | ||
|
||
const stringifiedSnapshot = JSON.stringify(snapshots[0]); | ||
expect(stringifiedSnapshot).toContain('"tagName":"body"'); | ||
expect(stringifiedSnapshot).toContain('"tagName":"html"'); | ||
expect(stringifiedSnapshot).toContain('"tagName":"button"'); | ||
expect(stringifiedSnapshot).toContain('"textContent":"*** ***"'); | ||
expect(stringifiedSnapshot).toContain('"id":"go-background"'); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
import type { RecordingEvent, ReplayContainer } from '@sentry/replay/build/npm/types/types'; | ||
import type { Breadcrumb, Event, ReplayEvent } from '@sentry/types'; | ||
import pako from 'pako'; | ||
import type { Page, Request } from 'playwright'; | ||
|
||
import { envelopeRequestParser } from './helpers'; | ||
|
@@ -13,6 +14,17 @@ type PerformanceSpan = { | |
data: Record<string, number>; | ||
}; | ||
|
||
export type RecordingSnapshot = { | ||
node: SnapshotNode; | ||
initialOffset: number; | ||
}; | ||
|
||
type SnapshotNode = { | ||
type: number; | ||
id: number; | ||
childNodes: SnapshotNode[]; | ||
}; | ||
|
||
/** | ||
* Waits for a replay request to be sent by the page and returns it. | ||
* | ||
|
@@ -85,7 +97,7 @@ export function getCustomRecordingEvents(replayRequest: Request): { | |
breadcrumbs: Breadcrumb[]; | ||
performanceSpans: PerformanceSpan[]; | ||
} { | ||
const recordingEvents = envelopeRequestParser(replayRequest, 5) as RecordingEvent[]; | ||
const recordingEvents = getDecompressedRecordingEvents(replayRequest); | ||
|
||
const breadcrumbs = getReplayBreadcrumbs(recordingEvents); | ||
const performanceSpans = getReplayPerformanceSpans(recordingEvents); | ||
|
@@ -108,3 +120,60 @@ function getReplayPerformanceSpans(recordingEvents: RecordingEvent[]): Performan | |
.filter(data => data.tag === 'performanceSpan') | ||
.map(data => data.payload) as PerformanceSpan[]; | ||
} | ||
|
||
export function getFullRecordingSnapshots(replayRequest: Request): RecordingSnapshot[] { | ||
const events = getDecompressedRecordingEvents(replayRequest) as RecordingEvent[]; | ||
return events.filter(event => event.type === 2).map(event => event.data as RecordingSnapshot); | ||
} | ||
|
||
function getDecompressedRecordingEvents(replayRequest: Request): RecordingEvent[] { | ||
return replayEnvelopeRequestParser(replayRequest, 5) as RecordingEvent[]; | ||
} | ||
|
||
/** | ||
* Copy of the envelopeParser from ./helpers.ts, but with the ability | ||
* to decompress zlib-compressed envelope payloads which we need to inspect for replay recordings. | ||
* This parser can handle uncompressed as well as compressed replay recordings. | ||
*/ | ||
const replayEnvelopeRequestParser = (request: Request | null, envelopeIndex = 2): Event => { | ||
const envelope = replayEnvelopeParser(request); | ||
return envelope[envelopeIndex] as Event; | ||
}; | ||
|
||
const replayEnvelopeParser = (request: Request | null): unknown[] => { | ||
// https://develop.sentry.dev/sdk/envelopes/ | ||
const envelopeBytes = request?.postDataBuffer() || ''; | ||
|
||
// first, we convert the bugger to string to split and go through the uncompressed lines | ||
const envelopeString = envelopeBytes.toString(); | ||
|
||
const lines = envelopeString.split('\n').map(line => { | ||
try { | ||
return JSON.parse(line); | ||
} catch (error) { | ||
// If we fail to parse a line, we _might_ have found a compressed payload, | ||
// so let's check if this is actually the case. | ||
// This is quite hacky but we can't go through `line` because the prior operations | ||
// seem to have altered its binary content. Hence, we take the raw envelope and | ||
// look up the place where the zlib compression header(0x78 0x9c) starts | ||
for (let i = 0; i < envelopeBytes.length; i++) { | ||
if (envelopeBytes[i] === 0x78 && envelopeBytes[i + 1] === 0x9c) { | ||
Comment on lines
+154
to
+160
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yikes, wish we had added some additional headers to determine the payload type. I wouldn't have expected split to change the binary content though. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yup, that would have been a good idea 😅 I think it's not so much the split as rather the |
||
try { | ||
// We found a zlib-compressed payload - let's decompress it | ||
const payload = envelopeBytes.slice(i); | ||
// now we return the decompressed payload as JSON | ||
const decompressedPayload = pako.inflate(payload as unknown as Uint8Array, { to: 'string' }); | ||
return JSON.parse(decompressedPayload); | ||
} catch { | ||
// Let's log that something went wrong | ||
return line; | ||
} | ||
} | ||
} | ||
|
||
return line; | ||
} | ||
}); | ||
|
||
return lines; | ||
}; |
Large diffs are not rendered by default.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we'll hit some string encoding issues -- does this need to be utf8? I guess easy way to test this is to add some unicode to our tests :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point. I added it to the improvement issue. I'm gonna tackle this separately to not widen the scope of this PR.