Skip to content

test(replay): Add Playwright tests for error-mode and error linking #7251

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

Merged
merged 5 commits into from
Feb 22, 2023
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.Replay = new Sentry.Replay({
flushMinDelay: 500,
flushMaxDelay: 500,
});

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
sampleRate: 1,
replaysSessionSampleRate: 0.0,
replaysOnErrorSampleRate: 1.0,
beforeSend(_) {
return null;
},
integrations: [window.Replay],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../utils/fixtures';
import { getExpectedReplayEvent } from '../../../../utils/replayEventTemplates';
import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';

/*
* This scenario currently shows somewhat unexpected behavior from the PoV of a user:
* The error is dropped, but the recording is started and continued anyway.
* If folks only sample error replays, this will lead to a lot of confusion as the resulting replay
* won't contain the error that started it (possibly none or only additional errors that occurred later on).
*
* This is because in error-mode, we start recording as soon as replay's eventProcessor is called with an error.
* If later event processors or beforeSend drop the error, the recording is already started.
*
* We'll need a proper SDK lifecycle hook (WIP) to fix this properly.
* TODO: Once we have lifecycle hooks, we should revisit this test and make sure it behaves as expected.
* This means that the recording should not be started or stopped if the error that triggered it is not sent.
*/
sentryTest(
'[error-mode] should start recording if an error occurred although the error was dropped',
async ({ getLocalTestPath, page }) => {
if (shouldSkipReplayTest()) {
sentryTest.skip();
}

let callsToSentry = 0;
const reqPromise0 = waitForReplayRequest(page, 0);

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
callsToSentry++;
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestPath({ testDir: __dirname });

await page.goto(url);
await page.click('#go-background');
expect(callsToSentry).toEqual(0);

await page.click('#error');
const req0 = await reqPromise0;

await page.click('#go-background');
expect(callsToSentry).toEqual(2); // 2 replay events

await page.click('#log');
await page.click('#go-background');

const event0 = getReplayEvent(req0);

expect(event0).toEqual(
getExpectedReplayEvent({
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also really shows a problem with how we handle this today 😅 we should address this with hooks as soon as we can, IMHO, as this is pretty confusing if you think about this 😅 (the test is good though to make clear this is expected)

Copy link
Member Author

@Lms24 Lms24 Feb 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, agreed. As we discussed on Monday, the test, in this case, shows how this behavior is unexpected to users. Will leave a note about this in the test file to clarify.

// This is by design. A dropped error shouldn't be in this list.
error_ids: [],
replay_type: 'error',
}),
);
},
);
133 changes: 133 additions & 0 deletions packages/integration-tests/suites/replay/errors/errorMode/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../utils/fixtures';
import { envelopeRequestParser } from '../../../../utils/helpers';
import {
expectedClickBreadcrumb,
expectedConsoleBreadcrumb,
getExpectedReplayEvent,
} from '../../../../utils/replayEventTemplates';
import {
getReplayEvent,
getReplayRecordingContent,
shouldSkipReplayTest,
waitForReplayRequest,
} from '../../../../utils/replayHelpers';

sentryTest(
'[error-mode] should start recording and switch to session mode once an error is thrown',
async ({ getLocalTestPath, page }) => {
if (shouldSkipReplayTest()) {
sentryTest.skip();
}

let callsToSentry = 0;
let errorEventId: string | undefined;
const reqPromise0 = waitForReplayRequest(page, 0);
const reqPromise1 = waitForReplayRequest(page, 1);
const reqPromise2 = waitForReplayRequest(page, 2);

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
const event = envelopeRequestParser(route.request());
// error events have no type field
if (event && !event.type && event.event_id) {
errorEventId = event.event_id;
}
callsToSentry++;

return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestPath({ testDir: __dirname });

await page.goto(url);
await page.click('#go-background');
expect(callsToSentry).toEqual(0);

await page.click('#error');
const req0 = await reqPromise0;

await page.click('#go-background');
const req1 = await reqPromise1;

expect(callsToSentry).toEqual(3); // 1 error, 2 replay events

await page.click('#log');
await page.click('#go-background');
const req2 = await reqPromise2;

const event0 = getReplayEvent(req0);
const content0 = getReplayRecordingContent(req0);

const event1 = getReplayEvent(req1);
const content1 = getReplayRecordingContent(req1);

const event2 = getReplayEvent(req2);
const content2 = getReplayRecordingContent(req2);

expect(event0).toEqual(
getExpectedReplayEvent({
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
// @ts-ignore this is fine
error_ids: [errorEventId],
replay_type: 'error',
}),
);

// The first event should have both, full and incremental snapshots,
// as we recorded and kept all events in the buffer
expect(content0.fullSnapshots).toHaveLength(1);
// We don't know how many incremental snapshots we'll have (also browser-dependent),
// but we know that we have at least 5
expect(content0.incrementalSnapshots.length).toBeGreaterThan(5);
// We want to make sure that the event that triggered the error was recorded.
expect(content0.breadcrumbs).toEqual(
expect.arrayContaining([
{
...expectedClickBreadcrumb,
message: 'body > button#error',
},
]),
);

expect(event1).toEqual(
getExpectedReplayEvent({
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
// @ts-ignore this is fine
replay_type: 'error', // although we're in session mode, we still send 'error' as replay_type
replay_start_timestamp: undefined,
segment_id: 1,
urls: [],
}),
);

// Also the second snapshot should have a full snapshot, as we switched from error to session
// mode which triggers another checkout
expect(content1.fullSnapshots).toHaveLength(1);
expect(content1.incrementalSnapshots).toHaveLength(0);

// The next event should just be a normal replay event as we're now in session mode and
// we continue recording everything
expect(event2).toEqual(
getExpectedReplayEvent({
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
// @ts-ignore this is fine
replay_type: 'error',
replay_start_timestamp: undefined,
segment_id: 2,
urls: [],
}),
);

expect(content2.breadcrumbs).toEqual(
expect.arrayContaining([
{ ...expectedClickBreadcrumb, message: 'body > button#log' },
{ ...expectedConsoleBreadcrumb, level: 'log', message: 'Some message' },
]),
);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.Replay = new Sentry.Replay({
flushMinDelay: 500,
flushMaxDelay: 500,
});

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
sampleRate: 1,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 0.0,
beforeSend(event, hint) {
if (hint.originalException.message.includes('[drop]')) {
return null;
}
return event;
},
integrations: [window.Replay],
debug: true,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../utils/fixtures';
import { envelopeRequestParser } from '../../../../utils/helpers';
import { expectedClickBreadcrumb, getExpectedReplayEvent } from '../../../../utils/replayEventTemplates';
import {
getReplayEvent,
getReplayRecordingContent,
shouldSkipReplayTest,
waitForReplayRequest,
} from '../../../../utils/replayHelpers';

sentryTest(
'[session-mode] replay event should contain an error id of an error that occurred during session recording',
async ({ getLocalTestPath, page }) => {
if (shouldSkipReplayTest()) {
sentryTest.skip();
}

let errorEventId: string = 'invalid_id';

const reqPromise0 = waitForReplayRequest(page, 0);
const reqPromise1 = waitForReplayRequest(page, 1);

await page.route('https://dsn.ingest.sentry.io/**/*', route => {
const event = envelopeRequestParser(route.request());
// error events have no type field
if (event && !event.type && event.event_id) {
errorEventId = event.event_id;
}

return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-id' }),
});
});

const url = await getLocalTestPath({ testDir: __dirname });

await page.goto(url);
await page.click('#go-background');
const req0 = await reqPromise0;

await page.click('#error');
await page.click('#go-background');
const req1 = await reqPromise1;

const event0 = getReplayEvent(req0);

const event1 = getReplayEvent(req1);
const content1 = getReplayRecordingContent(req1);

expect(event0).toEqual(getExpectedReplayEvent());

expect(event1).toEqual(
getExpectedReplayEvent({
replay_start_timestamp: undefined,
segment_id: 1,
// @ts-ignore this is fine
error_ids: [errorEventId],
urls: [],
}),
);

expect(content1.breadcrumbs).toEqual(
expect.arrayContaining([{ ...expectedClickBreadcrumb, message: 'body > button#error' }]),
);
},
);

sentryTest(
'[session-mode] replay event should not contain an error id of a dropped error while recording',
async ({ getLocalTestPath, page }) => {
if (shouldSkipReplayTest()) {
sentryTest.skip();
}

const reqPromise1 = waitForReplayRequest(page, 1);

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);
await page.click('#go-background');

await page.click('#drop');
await page.click('#go-background');
const req1 = await reqPromise1;

const event1 = getReplayEvent(req1);
const content1 = getReplayRecordingContent(req1);

expect(event1).toEqual(
getExpectedReplayEvent({
replay_start_timestamp: undefined,
segment_id: 1,
error_ids: [], // <-- no error id
urls: [],
}),
);

// The button click that triggered the error should still be recorded
expect(content1.breadcrumbs).toEqual(
expect.arrayContaining([{ ...expectedClickBreadcrumb, message: 'body > button#drop' }]),
);
},
);
16 changes: 16 additions & 0 deletions packages/integration-tests/suites/replay/errors/init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;
window.Replay = new Sentry.Replay({
flushMinDelay: 500,
flushMaxDelay: 500,
});

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
sampleRate: 1,
replaysSessionSampleRate: 0.0,
replaysOnErrorSampleRate: 1.0,

integrations: [window.Replay],
});
18 changes: 18 additions & 0 deletions packages/integration-tests/suites/replay/errors/subject.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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);
});

document.getElementById('error').addEventListener('click', () => {
throw new Error('Ooops');
});

document.getElementById('drop').addEventListener('click', () => {
throw new Error('[drop] Ooops');
});

document.getElementById('log').addEventListener('click', () => {
console.log('Some message');
});
Loading