Skip to content

Commit 9c2c018

Browse files
authored
test(replay): Add Playwright tests for error-mode and error linking (#7251)
Add four integration tests for replays where errors occur: * error mode: * error to session transition once the error occurs * if the error is dropped, it shouldn't be linked in the replay event (see test comment about the problem with this scenario) * session mode * if an error occurs, make sure it's linked in the replay event * unless the error is dropped
1 parent 46a527b commit 9c2c018

File tree

9 files changed

+411
-0
lines changed

9 files changed

+411
-0
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 500,
6+
flushMaxDelay: 500,
7+
});
8+
9+
Sentry.init({
10+
dsn: 'https://[email protected]/1337',
11+
sampleRate: 1,
12+
replaysSessionSampleRate: 0.0,
13+
replaysOnErrorSampleRate: 1.0,
14+
beforeSend(_) {
15+
return null;
16+
},
17+
integrations: [window.Replay],
18+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getExpectedReplayEvent } from '../../../../utils/replayEventTemplates';
5+
import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
6+
7+
/*
8+
* This scenario currently shows somewhat unexpected behavior from the PoV of a user:
9+
* The error is dropped, but the recording is started and continued anyway.
10+
* If folks only sample error replays, this will lead to a lot of confusion as the resulting replay
11+
* won't contain the error that started it (possibly none or only additional errors that occurred later on).
12+
*
13+
* This is because in error-mode, we start recording as soon as replay's eventProcessor is called with an error.
14+
* If later event processors or beforeSend drop the error, the recording is already started.
15+
*
16+
* We'll need a proper SDK lifecycle hook (WIP) to fix this properly.
17+
* TODO: Once we have lifecycle hooks, we should revisit this test and make sure it behaves as expected.
18+
* This means that the recording should not be started or stopped if the error that triggered it is not sent.
19+
*/
20+
sentryTest(
21+
'[error-mode] should start recording if an error occurred although the error was dropped',
22+
async ({ getLocalTestPath, page }) => {
23+
if (shouldSkipReplayTest()) {
24+
sentryTest.skip();
25+
}
26+
27+
let callsToSentry = 0;
28+
const reqPromise0 = waitForReplayRequest(page, 0);
29+
30+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
31+
callsToSentry++;
32+
return route.fulfill({
33+
status: 200,
34+
contentType: 'application/json',
35+
body: JSON.stringify({ id: 'test-id' }),
36+
});
37+
});
38+
39+
const url = await getLocalTestPath({ testDir: __dirname });
40+
41+
await page.goto(url);
42+
await page.click('#go-background');
43+
expect(callsToSentry).toEqual(0);
44+
45+
await page.click('#error');
46+
const req0 = await reqPromise0;
47+
48+
await page.click('#go-background');
49+
expect(callsToSentry).toEqual(2); // 2 replay events
50+
51+
await page.click('#log');
52+
await page.click('#go-background');
53+
54+
const event0 = getReplayEvent(req0);
55+
56+
expect(event0).toEqual(
57+
getExpectedReplayEvent({
58+
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
59+
// This is by design. A dropped error shouldn't be in this list.
60+
error_ids: [],
61+
replay_type: 'error',
62+
}),
63+
);
64+
},
65+
);
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { envelopeRequestParser } from '../../../../utils/helpers';
5+
import {
6+
expectedClickBreadcrumb,
7+
expectedConsoleBreadcrumb,
8+
getExpectedReplayEvent,
9+
} from '../../../../utils/replayEventTemplates';
10+
import {
11+
getReplayEvent,
12+
getReplayRecordingContent,
13+
shouldSkipReplayTest,
14+
waitForReplayRequest,
15+
} from '../../../../utils/replayHelpers';
16+
17+
sentryTest(
18+
'[error-mode] should start recording and switch to session mode once an error is thrown',
19+
async ({ getLocalTestPath, page }) => {
20+
if (shouldSkipReplayTest()) {
21+
sentryTest.skip();
22+
}
23+
24+
let callsToSentry = 0;
25+
let errorEventId: string | undefined;
26+
const reqPromise0 = waitForReplayRequest(page, 0);
27+
const reqPromise1 = waitForReplayRequest(page, 1);
28+
const reqPromise2 = waitForReplayRequest(page, 2);
29+
30+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
31+
const event = envelopeRequestParser(route.request());
32+
// error events have no type field
33+
if (event && !event.type && event.event_id) {
34+
errorEventId = event.event_id;
35+
}
36+
callsToSentry++;
37+
38+
return route.fulfill({
39+
status: 200,
40+
contentType: 'application/json',
41+
body: JSON.stringify({ id: 'test-id' }),
42+
});
43+
});
44+
45+
const url = await getLocalTestPath({ testDir: __dirname });
46+
47+
await page.goto(url);
48+
await page.click('#go-background');
49+
expect(callsToSentry).toEqual(0);
50+
51+
await page.click('#error');
52+
const req0 = await reqPromise0;
53+
54+
await page.click('#go-background');
55+
const req1 = await reqPromise1;
56+
57+
expect(callsToSentry).toEqual(3); // 1 error, 2 replay events
58+
59+
await page.click('#log');
60+
await page.click('#go-background');
61+
const req2 = await reqPromise2;
62+
63+
const event0 = getReplayEvent(req0);
64+
const content0 = getReplayRecordingContent(req0);
65+
66+
const event1 = getReplayEvent(req1);
67+
const content1 = getReplayRecordingContent(req1);
68+
69+
const event2 = getReplayEvent(req2);
70+
const content2 = getReplayRecordingContent(req2);
71+
72+
expect(event0).toEqual(
73+
getExpectedReplayEvent({
74+
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
75+
// @ts-ignore this is fine
76+
error_ids: [errorEventId],
77+
replay_type: 'error',
78+
}),
79+
);
80+
81+
// The first event should have both, full and incremental snapshots,
82+
// as we recorded and kept all events in the buffer
83+
expect(content0.fullSnapshots).toHaveLength(1);
84+
// We don't know how many incremental snapshots we'll have (also browser-dependent),
85+
// but we know that we have at least 5
86+
expect(content0.incrementalSnapshots.length).toBeGreaterThan(5);
87+
// We want to make sure that the event that triggered the error was recorded.
88+
expect(content0.breadcrumbs).toEqual(
89+
expect.arrayContaining([
90+
{
91+
...expectedClickBreadcrumb,
92+
message: 'body > button#error',
93+
},
94+
]),
95+
);
96+
97+
expect(event1).toEqual(
98+
getExpectedReplayEvent({
99+
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
100+
// @ts-ignore this is fine
101+
replay_type: 'error', // although we're in session mode, we still send 'error' as replay_type
102+
replay_start_timestamp: undefined,
103+
segment_id: 1,
104+
urls: [],
105+
}),
106+
);
107+
108+
// Also the second snapshot should have a full snapshot, as we switched from error to session
109+
// mode which triggers another checkout
110+
expect(content1.fullSnapshots).toHaveLength(1);
111+
expect(content1.incrementalSnapshots).toHaveLength(0);
112+
113+
// The next event should just be a normal replay event as we're now in session mode and
114+
// we continue recording everything
115+
expect(event2).toEqual(
116+
getExpectedReplayEvent({
117+
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
118+
// @ts-ignore this is fine
119+
replay_type: 'error',
120+
replay_start_timestamp: undefined,
121+
segment_id: 2,
122+
urls: [],
123+
}),
124+
);
125+
126+
expect(content2.breadcrumbs).toEqual(
127+
expect.arrayContaining([
128+
{ ...expectedClickBreadcrumb, message: 'body > button#log' },
129+
{ ...expectedConsoleBreadcrumb, level: 'log', message: 'Some message' },
130+
]),
131+
);
132+
},
133+
);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 500,
6+
flushMaxDelay: 500,
7+
});
8+
9+
Sentry.init({
10+
dsn: 'https://[email protected]/1337',
11+
sampleRate: 1,
12+
replaysSessionSampleRate: 1.0,
13+
replaysOnErrorSampleRate: 0.0,
14+
beforeSend(event, hint) {
15+
if (hint.originalException.message.includes('[drop]')) {
16+
return null;
17+
}
18+
return event;
19+
},
20+
integrations: [window.Replay],
21+
debug: true,
22+
});
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { envelopeRequestParser } from '../../../../utils/helpers';
5+
import { expectedClickBreadcrumb, getExpectedReplayEvent } from '../../../../utils/replayEventTemplates';
6+
import {
7+
getReplayEvent,
8+
getReplayRecordingContent,
9+
shouldSkipReplayTest,
10+
waitForReplayRequest,
11+
} from '../../../../utils/replayHelpers';
12+
13+
sentryTest(
14+
'[session-mode] replay event should contain an error id of an error that occurred during session recording',
15+
async ({ getLocalTestPath, page }) => {
16+
if (shouldSkipReplayTest()) {
17+
sentryTest.skip();
18+
}
19+
20+
let errorEventId: string = 'invalid_id';
21+
22+
const reqPromise0 = waitForReplayRequest(page, 0);
23+
const reqPromise1 = waitForReplayRequest(page, 1);
24+
25+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
26+
const event = envelopeRequestParser(route.request());
27+
// error events have no type field
28+
if (event && !event.type && event.event_id) {
29+
errorEventId = event.event_id;
30+
}
31+
32+
return route.fulfill({
33+
status: 200,
34+
contentType: 'application/json',
35+
body: JSON.stringify({ id: 'test-id' }),
36+
});
37+
});
38+
39+
const url = await getLocalTestPath({ testDir: __dirname });
40+
41+
await page.goto(url);
42+
await page.click('#go-background');
43+
const req0 = await reqPromise0;
44+
45+
await page.click('#error');
46+
await page.click('#go-background');
47+
const req1 = await reqPromise1;
48+
49+
const event0 = getReplayEvent(req0);
50+
51+
const event1 = getReplayEvent(req1);
52+
const content1 = getReplayRecordingContent(req1);
53+
54+
expect(event0).toEqual(getExpectedReplayEvent());
55+
56+
expect(event1).toEqual(
57+
getExpectedReplayEvent({
58+
replay_start_timestamp: undefined,
59+
segment_id: 1,
60+
// @ts-ignore this is fine
61+
error_ids: [errorEventId],
62+
urls: [],
63+
}),
64+
);
65+
66+
expect(content1.breadcrumbs).toEqual(
67+
expect.arrayContaining([{ ...expectedClickBreadcrumb, message: 'body > button#error' }]),
68+
);
69+
},
70+
);
71+
72+
sentryTest(
73+
'[session-mode] replay event should not contain an error id of a dropped error while recording',
74+
async ({ getLocalTestPath, page }) => {
75+
if (shouldSkipReplayTest()) {
76+
sentryTest.skip();
77+
}
78+
79+
const reqPromise1 = waitForReplayRequest(page, 1);
80+
81+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
82+
return route.fulfill({
83+
status: 200,
84+
contentType: 'application/json',
85+
body: JSON.stringify({ id: 'test-id' }),
86+
});
87+
});
88+
89+
const url = await getLocalTestPath({ testDir: __dirname });
90+
91+
await page.goto(url);
92+
await page.click('#go-background');
93+
94+
await page.click('#drop');
95+
await page.click('#go-background');
96+
const req1 = await reqPromise1;
97+
98+
const event1 = getReplayEvent(req1);
99+
const content1 = getReplayRecordingContent(req1);
100+
101+
expect(event1).toEqual(
102+
getExpectedReplayEvent({
103+
replay_start_timestamp: undefined,
104+
segment_id: 1,
105+
error_ids: [], // <-- no error id
106+
urls: [],
107+
}),
108+
);
109+
110+
// The button click that triggered the error should still be recorded
111+
expect(content1.breadcrumbs).toEqual(
112+
expect.arrayContaining([{ ...expectedClickBreadcrumb, message: 'body > button#drop' }]),
113+
);
114+
},
115+
);
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 500,
6+
flushMaxDelay: 500,
7+
});
8+
9+
Sentry.init({
10+
dsn: 'https://[email protected]/1337',
11+
sampleRate: 1,
12+
replaysSessionSampleRate: 0.0,
13+
replaysOnErrorSampleRate: 1.0,
14+
15+
integrations: [window.Replay],
16+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
document.getElementById('go-background').addEventListener('click', () => {
2+
Object.defineProperty(document, 'hidden', { value: true, writable: true });
3+
const ev = document.createEvent('Event');
4+
ev.initEvent('visibilitychange');
5+
document.dispatchEvent(ev);
6+
});
7+
8+
document.getElementById('error').addEventListener('click', () => {
9+
throw new Error('Ooops');
10+
});
11+
12+
document.getElementById('drop').addEventListener('click', () => {
13+
throw new Error('[drop] Ooops');
14+
});
15+
16+
document.getElementById('log').addEventListener('click', () => {
17+
console.log('Some message');
18+
});

0 commit comments

Comments
 (0)