Skip to content

fix(replay): Fix re-sampled sessions after a click #17008

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

Open
wants to merge 7 commits into
base: develop
Choose a base branch
from
Open
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
@@ -1,4 +1,5 @@
import { expect } from '@playwright/test';
import type { replayIntegration as actualReplayIntegration } from '@sentry-internal/replay';
import { sentryTest } from '../../../utils/fixtures';
import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates';
import {
Expand All @@ -13,55 +14,122 @@ import {
// Session should expire after 2s - keep in sync with init.js
const SESSION_TIMEOUT = 2000;

sentryTest('handles an expired session', async ({ browserName, forceFlushReplay, getLocalTestUrl, page }) => {
if (shouldSkipReplayTest() || browserName !== 'chromium') {
sentryTest.skip();
}
sentryTest(
'handles an expired session that re-samples to session',
async ({ browserName, forceFlushReplay, getLocalTestUrl, page }) => {
if (shouldSkipReplayTest() || browserName !== 'chromium') {
sentryTest.skip();
}

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

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

await page.goto(url);
const req0 = await reqPromise0;
await page.goto(url);
const req0 = await reqPromise0;

const replayEvent0 = getReplayEvent(req0);
expect(replayEvent0).toEqual(getExpectedReplayEvent({}));
const replayEvent0 = getReplayEvent(req0);
expect(replayEvent0).toEqual(getExpectedReplayEvent({}));

const fullSnapshots0 = getFullRecordingSnapshots(req0);
expect(fullSnapshots0.length).toEqual(1);
const stringifiedSnapshot = normalize(fullSnapshots0[0]);
expect(stringifiedSnapshot).toMatchSnapshot('snapshot-0.json');
const fullSnapshots0 = getFullRecordingSnapshots(req0);
expect(fullSnapshots0.length).toEqual(1);
const stringifiedSnapshot = normalize(fullSnapshots0[0]);
expect(stringifiedSnapshot).toMatchSnapshot('snapshot-0.json');

// We wait for another segment 0
const reqPromise2 = waitForReplayRequest(page, 0);
// We wait for another segment 0
const reqPromise2 = waitForReplayRequest(page, 0);

await page.locator('#button1').click();
await forceFlushReplay();
const req1 = await reqPromise1;
await page.locator('#button1').click();
await forceFlushReplay();
const req1 = await reqPromise1;

const replayEvent1 = getReplayEvent(req1);
expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] }));
const replayEvent1 = getReplayEvent(req1);
expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] }));

const replay = await getReplaySnapshot(page);
const oldSessionId = replay.session?.id;
const replay = await getReplaySnapshot(page);
const oldSessionId = replay.session?.id;

await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT));
await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT));

await page.locator('#button2').click();
await forceFlushReplay();
const req2 = await reqPromise2;
await page.locator('#button2').click();
await forceFlushReplay();
const req2 = await reqPromise2;

const replay2 = await getReplaySnapshot(page);
const replay2 = await getReplaySnapshot(page);

expect(replay2.session?.id).not.toEqual(oldSessionId);
expect(replay2.session?.id).not.toEqual(oldSessionId);

const replayEvent2 = getReplayEvent(req2);
expect(replayEvent2).toEqual(getExpectedReplayEvent({}));
const replayEvent2 = getReplayEvent(req2);
expect(replayEvent2).toEqual(getExpectedReplayEvent({}));

const fullSnapshots2 = getFullRecordingSnapshots(req2);
expect(fullSnapshots2.length).toEqual(1);
const stringifiedSnapshot2 = normalize(fullSnapshots2[0]);
expect(stringifiedSnapshot2).toMatchSnapshot('snapshot-2.json');
});
const fullSnapshots2 = getFullRecordingSnapshots(req2);
expect(fullSnapshots2.length).toEqual(1);
const stringifiedSnapshot2 = normalize(fullSnapshots2[0]);
expect(stringifiedSnapshot2).toMatchSnapshot('snapshot-2.json');
},
);

sentryTest(
'handles an expired session that re-samples to buffer',
async ({ browserName, forceFlushReplay, getLocalTestUrl, page }) => {
if (shouldSkipReplayTest() || browserName !== 'chromium') {
sentryTest.skip();
}

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

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

await page.goto(url);
const req0 = await reqPromise0;

const replayEvent0 = getReplayEvent(req0);
expect(replayEvent0).toEqual(getExpectedReplayEvent({}));

const fullSnapshots0 = getFullRecordingSnapshots(req0);
expect(fullSnapshots0.length).toEqual(1);
const stringifiedSnapshot = normalize(fullSnapshots0[0]);
expect(stringifiedSnapshot).toMatchSnapshot('snapshot-0.json');

await page.locator('#button1').click();
await forceFlushReplay();
const req1 = await reqPromise1;

const replayEvent1 = getReplayEvent(req1);
expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] }));

const replay = await getReplaySnapshot(page);
const oldSessionId = replay.session?.id;

await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT));
await page.evaluate(() => {
const replayIntegration = (window as unknown as Window & { Replay: ReturnType<typeof actualReplayIntegration> })
.Replay;
replayIntegration['_replay'].getOptions().errorSampleRate = 1.0;
replayIntegration['_replay'].getOptions().sessionSampleRate = 0.0;
});

let wasReplayFlushed = false;
page.on('request', request => {
if (request.url().includes('/api/1337/envelope/')) {
wasReplayFlushed = true;
}
});
await page.locator('#button2').click();
await forceFlushReplay();

// This timeout is not ideal, but not sure of a better way to ensure replay is not flushed
await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT));

expect(wasReplayFlushed).toBe(false);

const currentSessionId = await page.evaluate(() => {
// @ts-expect-error - Replay is not typed
return window.Replay._replay.session?.id;
});

expect(currentSessionId).not.toEqual(oldSessionId);
},
);
7 changes: 6 additions & 1 deletion packages/replay-internal/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ export class ReplayContainer implements ReplayContainerInterface {
);

this.session = session;
this.recordingMode = 'session';

this._initializeRecording();
}
Expand Down Expand Up @@ -503,6 +504,10 @@ export class ReplayContainer implements ReplayContainerInterface {
// enter into an infinite loop when `stop()` is called while flushing.
this._isEnabled = false;

// Make sure to reset `recordingMode` to `buffer` to avoid any additional
// breadcrumbs to trigger a flush (e.g. in `addUpdate()`)
this.recordingMode = 'buffer';
Copy link
Member

Choose a reason for hiding this comment

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

not sure, do we (need to?) reset this at some point? I guess not, as ended usually means ended for good for us I suppose...?

Copy link
Member Author

Choose a reason for hiding this comment

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

We currently don't need to, but I think it's a safer option to have it reset here.


try {
DEBUG_BUILD && debug.log(`Stopping Replay${reason ? ` triggered by ${reason}` : ''}`);

Expand Down Expand Up @@ -623,7 +628,7 @@ export class ReplayContainer implements ReplayContainerInterface {

// If this option is turned on then we will only want to call `flush`
// explicitly
if (this.recordingMode === 'buffer') {
if (this.recordingMode === 'buffer' || !this._isEnabled) {
return;
}

Expand Down
33 changes: 29 additions & 4 deletions packages/replay-internal/test/integration/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,14 +207,39 @@ describe('Integration | session', () => {
await vi.advanceTimersByTimeAsync(DEFAULT_FLUSH_MIN_DELAY);
await new Promise(process.nextTick);

const newTimestamp = BASE_TIMESTAMP + ELAPSED;
// The click actually does not trigger a flush because it never gets added to event buffer because
// the session is expired. We stop recording and re-sample the session expires.
expect(replay).not.toHaveLastSentReplay();

// This click will trigger a flush now that the session is active
// (sessionSampleRate=1 when resampling)
domHandler({
name: 'click',
event: new Event('click'),
});
await vi.advanceTimersByTimeAsync(DEFAULT_FLUSH_MIN_DELAY);
await new Promise(process.nextTick);
const newTimestamp = BASE_TIMESTAMP + ELAPSED + DEFAULT_FLUSH_MIN_DELAY;

expect(replay).toHaveLastSentReplay({
recordingPayloadHeader: { segment_id: 0 },
recordingData: JSON.stringify([
{ data: { isCheckout: true }, timestamp: newTimestamp, type: 2 },
{ data: { isCheckout: true }, timestamp: newTimestamp - DEFAULT_FLUSH_MIN_DELAY, type: 2 },
optionsEvent,
// the click is lost, but that's OK
{
type: 5,
timestamp: newTimestamp,
data: {
tag: 'breadcrumb',
payload: {
timestamp: newTimestamp / 1000,
type: 'default',
category: 'ui.click',
message: '<unknown>',
data: {},
},
},
},
]),
});

Expand All @@ -224,7 +249,7 @@ describe('Integration | session', () => {
// `_context` should be reset when a new session is created
expect(replay.getContext()).toEqual({
initialUrl: 'http://dummy/',
initialTimestamp: newTimestamp,
initialTimestamp: newTimestamp - DEFAULT_FLUSH_MIN_DELAY,
urls: [],
errorIds: new Set(),
traceIds: new Set(),
Expand Down