Skip to content

Commit a2ba075

Browse files
authored
fix(replay): Ensure handleRecordingEmit aborts when event is not added (#8938)
I noticed that in `handleRecordingEmit`, due to the async nature of `addEvent` it could happen that an event is actually not added (because it is discarded due to timestamp etc.). But we are still updating the initial session timestamp (`[Replay] Updating session start time to earliest event in buffer to...`), as we don't actually abort there. This PR changes this to actually abort `handleRecordingEmit` in this case. I added an `addEventSync` method for this that just returns true/false instead of a promise, which should not change anything there as we haven't been waiting for the result of the promise anyhow.
1 parent 30c4540 commit a2ba075

File tree

7 files changed

+150
-132
lines changed

7 files changed

+150
-132
lines changed

packages/replay/src/replay.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import type {
3838
Timeouts,
3939
} from './types';
4040
import { ReplayEventTypeCustom } from './types';
41-
import { addEvent } from './util/addEvent';
41+
import { addEvent, addEventSync } from './util/addEvent';
4242
import { addGlobalListeners } from './util/addGlobalListeners';
4343
import { addMemoryEntry } from './util/addMemoryEntry';
4444
import { createBreadcrumb } from './util/createBreadcrumb';
@@ -666,7 +666,8 @@ export class ReplayContainer implements ReplayContainerInterface {
666666
});
667667

668668
this.addUpdate(() => {
669-
void addEvent(this, {
669+
// Return `false` if the event _was_ added, as that means we schedule a flush
670+
return !addEventSync(this, {
670671
type: ReplayEventTypeCustom,
671672
timestamp: breadcrumb.timestamp || 0,
672673
data: {

packages/replay/src/util/addEvent.ts

Lines changed: 59 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,39 +13,46 @@ function isCustomEvent(event: RecordingEvent): event is ReplayFrameEvent {
1313

1414
/**
1515
* Add an event to the event buffer.
16+
* In contrast to `addEvent`, this does not return a promise & does not wait for the adding of the event to succeed/fail.
17+
* Instead this returns `true` if we tried to add the event, else false.
18+
* It returns `false` e.g. if we are paused, disabled, or out of the max replay duration.
19+
*
1620
* `isCheckout` is true if this is either the very first event, or an event triggered by `checkoutEveryNms`.
1721
*/
18-
export async function addEvent(
22+
export function addEventSync(replay: ReplayContainer, event: RecordingEvent, isCheckout?: boolean): boolean {
23+
if (!shouldAddEvent(replay, event)) {
24+
return false;
25+
}
26+
27+
void _addEvent(replay, event, isCheckout);
28+
29+
return true;
30+
}
31+
32+
/**
33+
* Add an event to the event buffer.
34+
* Resolves to `null` if no event was added, else to `void`.
35+
*
36+
* `isCheckout` is true if this is either the very first event, or an event triggered by `checkoutEveryNms`.
37+
*/
38+
export function addEvent(
1939
replay: ReplayContainer,
2040
event: RecordingEvent,
2141
isCheckout?: boolean,
2242
): Promise<AddEventResult | null> {
23-
if (!replay.eventBuffer) {
24-
// This implies that `_isEnabled` is false
25-
return null;
26-
}
27-
28-
if (replay.isPaused()) {
29-
// Do not add to event buffer when recording is paused
30-
return null;
43+
if (!shouldAddEvent(replay, event)) {
44+
return Promise.resolve(null);
3145
}
3246

33-
const timestampInMs = timestampToMs(event.timestamp);
34-
35-
// Throw out events that happen more than 5 minutes ago. This can happen if
36-
// page has been left open and idle for a long period of time and user
37-
// comes back to trigger a new session. The performance entries rely on
38-
// `performance.timeOrigin`, which is when the page first opened.
39-
if (timestampInMs + replay.timeouts.sessionIdlePause < Date.now()) {
40-
return null;
41-
}
47+
return _addEvent(replay, event, isCheckout);
48+
}
4249

43-
// Throw out events that are +60min from the initial timestamp
44-
if (timestampInMs > replay.getContext().initialTimestamp + replay.getOptions().maxReplayDuration) {
45-
logInfo(
46-
`[Replay] Skipping event with timestamp ${timestampInMs} because it is after maxReplayDuration`,
47-
replay.getOptions()._experiments.traceInternals,
48-
);
50+
async function _addEvent(
51+
replay: ReplayContainer,
52+
event: RecordingEvent,
53+
isCheckout?: boolean,
54+
): Promise<AddEventResult | null> {
55+
if (!replay.eventBuffer) {
4956
return null;
5057
}
5158

@@ -81,6 +88,34 @@ export async function addEvent(
8188
}
8289
}
8390

91+
/** Exported only for tests. */
92+
export function shouldAddEvent(replay: ReplayContainer, event: RecordingEvent): boolean {
93+
if (!replay.eventBuffer || replay.isPaused() || !replay.isEnabled()) {
94+
return false;
95+
}
96+
97+
const timestampInMs = timestampToMs(event.timestamp);
98+
99+
// Throw out events that happen more than 5 minutes ago. This can happen if
100+
// page has been left open and idle for a long period of time and user
101+
// comes back to trigger a new session. The performance entries rely on
102+
// `performance.timeOrigin`, which is when the page first opened.
103+
if (timestampInMs + replay.timeouts.sessionIdlePause < Date.now()) {
104+
return false;
105+
}
106+
107+
// Throw out events that are +60min from the initial timestamp
108+
if (timestampInMs > replay.getContext().initialTimestamp + replay.getOptions().maxReplayDuration) {
109+
logInfo(
110+
`[Replay] Skipping event with timestamp ${timestampInMs} because it is after maxReplayDuration`,
111+
replay.getOptions()._experiments.traceInternals,
112+
);
113+
return false;
114+
}
115+
116+
return true;
117+
}
118+
84119
function maybeApplyCallback(
85120
event: RecordingEvent,
86121
callback: ReplayPluginOptions['beforeAddRecordingEvent'],

packages/replay/src/util/handleRecordingEmit.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { EventType } from '@sentry-internal/rrweb';
22
import { logger } from '@sentry/utils';
33

44
import { saveSession } from '../session/saveSession';
5-
import type { AddEventResult, RecordingEvent, ReplayContainer, ReplayOptionFrameEvent } from '../types';
6-
import { addEvent } from './addEvent';
5+
import type { RecordingEvent, ReplayContainer, ReplayOptionFrameEvent } from '../types';
6+
import { addEventSync } from './addEvent';
77
import { logInfo } from './log';
88

99
type RecordingEmitCallback = (event: RecordingEvent, isCheckout?: boolean) => void;
@@ -40,9 +40,12 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
4040
replay.setInitialState();
4141
}
4242

43-
// We need to clear existing events on a checkout, otherwise they are
44-
// incremental event updates and should be appended
45-
void addEvent(replay, event, isCheckout);
43+
// If the event is not added (e.g. due to being paused, disabled, or out of the max replay duration),
44+
// Skip all further steps
45+
if (!addEventSync(replay, event, isCheckout)) {
46+
// Return true to skip scheduling a debounced flush
47+
return true;
48+
}
4649

4750
// Different behavior for full snapshots (type=2), ignore other event types
4851
// See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16
@@ -56,7 +59,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
5659
//
5760
// `isCheckout` is always true, but want to be explicit that it should
5861
// only be added for checkouts
59-
void addSettingsEvent(replay, isCheckout);
62+
addSettingsEvent(replay, isCheckout);
6063

6164
// If there is a previousSessionId after a full snapshot occurs, then
6265
// the replay session was started due to session expiration. The new session
@@ -130,11 +133,11 @@ export function createOptionsEvent(replay: ReplayContainer): ReplayOptionFrameEv
130133
* Add a "meta" event that contains a simplified view on current configuration
131134
* options. This should only be included on the first segment of a recording.
132135
*/
133-
function addSettingsEvent(replay: ReplayContainer, isCheckout?: boolean): Promise<AddEventResult | null> {
136+
function addSettingsEvent(replay: ReplayContainer, isCheckout?: boolean): void {
134137
// Only need to add this event when sending the first segment
135138
if (!isCheckout || !replay.session || replay.session.segmentId !== 0) {
136-
return Promise.resolve(null);
139+
return;
137140
}
138141

139-
return addEvent(replay, createOptionsEvent(replay), false);
142+
addEventSync(replay, createOptionsEvent(replay), false);
140143
}

packages/replay/test/integration/errorSampleRate.test.ts

Lines changed: 0 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -810,76 +810,6 @@ describe('Integration | errorSampleRate', () => {
810810
expect(replay).toHaveLastSentReplay();
811811
});
812812

813-
it('does not refresh replay based on earliest event in buffer', async () => {
814-
jest.setSystemTime(BASE_TIMESTAMP);
815-
816-
const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP - 60000 });
817-
mockRecord._emitter(TEST_EVENT);
818-
819-
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
820-
expect(replay).not.toHaveLastSentReplay();
821-
822-
jest.runAllTimers();
823-
await new Promise(process.nextTick);
824-
825-
expect(replay).not.toHaveLastSentReplay();
826-
captureException(new Error('testing'));
827-
828-
await waitForBufferFlush();
829-
830-
expect(replay).toHaveLastSentReplay();
831-
832-
// Flush from calling `stopRecording`
833-
await waitForFlush();
834-
835-
// Now wait after session expires - should stop recording
836-
mockRecord.takeFullSnapshot.mockClear();
837-
(getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance<any>).mockClear();
838-
839-
expect(replay).not.toHaveLastSentReplay();
840-
841-
const TICKS = 80;
842-
843-
// We advance time so that we are on the border of expiring, taking into
844-
// account that TEST_EVENT timestamp is 60000 ms before BASE_TIMESTAMP. The
845-
// 3 DEFAULT_FLUSH_MIN_DELAY is to account for the `waitForFlush` that has
846-
// happened, and for the next two that will happen. The first following
847-
// `waitForFlush` does not expire session, but the following one will.
848-
jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 60000 - 3 * DEFAULT_FLUSH_MIN_DELAY - TICKS);
849-
await new Promise(process.nextTick);
850-
851-
mockRecord._emitter(TEST_EVENT);
852-
expect(replay).not.toHaveLastSentReplay();
853-
await waitForFlush();
854-
855-
expect(replay).not.toHaveLastSentReplay();
856-
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
857-
expect(replay.isEnabled()).toBe(true);
858-
859-
mockRecord._emitter(TEST_EVENT);
860-
expect(replay).not.toHaveLastSentReplay();
861-
await waitForFlush();
862-
863-
expect(replay).not.toHaveLastSentReplay();
864-
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
865-
expect(replay.isEnabled()).toBe(true);
866-
867-
const sessionId = replay.getSessionId();
868-
869-
// It's hard to test, but if we advance the below time less 1 ms, it should
870-
// be enabled, but we can't trigger a session check via flush without
871-
// incurring another DEFAULT_FLUSH_MIN_DELAY timeout.
872-
jest.advanceTimersByTime(60000 - DEFAULT_FLUSH_MIN_DELAY);
873-
mockRecord._emitter(TEST_EVENT);
874-
expect(replay).not.toHaveLastSentReplay();
875-
await waitForFlush();
876-
877-
expect(replay).not.toHaveLastSentReplay();
878-
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
879-
expect(replay.isEnabled()).toBe(true);
880-
expect(replay.getSessionId()).not.toBe(sessionId);
881-
});
882-
883813
it('handles very long active buffer session', async () => {
884814
const stepDuration = 10_000;
885815
const steps = 5_000;

packages/replay/test/integration/flush.test.ts

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,8 @@ describe('Integration | flush', () => {
403403
it('logs warning if adding event that is after maxReplayDuration', async () => {
404404
replay.getOptions()._experiments.traceInternals = true;
405405

406+
const spyLogger = jest.spyOn(SentryUtils.logger, 'info');
407+
406408
sessionStorage.clear();
407409
clearSession(replay);
408410
replay['_initializeSessionForSampling']();
@@ -422,32 +424,18 @@ describe('Integration | flush', () => {
422424
// no checkout!
423425
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
424426

425-
expect(mockFlush).toHaveBeenCalledTimes(1);
426-
expect(mockSendReplay).toHaveBeenCalledTimes(1);
427-
428-
const replayData = mockSendReplay.mock.calls[0][0];
427+
// No flush is scheduled is aborted because event is after maxReplayDuration
428+
expect(mockFlush).toHaveBeenCalledTimes(0);
429+
expect(mockSendReplay).toHaveBeenCalledTimes(0);
429430

430-
expect(JSON.parse(replayData.recordingData)).toEqual([
431-
{
432-
type: 5,
433-
timestamp: BASE_TIMESTAMP,
434-
data: {
435-
tag: 'breadcrumb',
436-
payload: {
437-
timestamp: BASE_TIMESTAMP / 1000,
438-
type: 'default',
439-
category: 'console',
440-
data: { logger: 'replay' },
441-
level: 'info',
442-
message: `[Replay] Skipping event with timestamp ${
443-
BASE_TIMESTAMP + MAX_REPLAY_DURATION + 100
444-
} because it is after maxReplayDuration`,
445-
},
446-
},
447-
},
448-
]);
431+
expect(spyLogger).toHaveBeenLastCalledWith(
432+
`[Replay] Skipping event with timestamp ${
433+
BASE_TIMESTAMP + MAX_REPLAY_DURATION + 100
434+
} because it is after maxReplayDuration`,
435+
);
449436

450437
replay.getOptions()._experiments.traceInternals = false;
438+
spyLogger.mockRestore();
451439
});
452440

453441
/**

packages/replay/test/unit/util/addEvent.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import 'jsdom-worker';
22

33
import { BASE_TIMESTAMP } from '../..';
4-
import { REPLAY_MAX_EVENT_BUFFER_SIZE } from '../../../src/constants';
4+
import { MAX_REPLAY_DURATION, REPLAY_MAX_EVENT_BUFFER_SIZE, SESSION_IDLE_PAUSE_DURATION } from '../../../src/constants';
55
import type { EventBufferProxy } from '../../../src/eventBuffer/EventBufferProxy';
6-
import { addEvent } from '../../../src/util/addEvent';
6+
import { addEvent, shouldAddEvent } from '../../../src/util/addEvent';
77
import { getTestEventIncremental } from '../../utils/getTestEvent';
88
import { setupReplayContainer } from '../../utils/setupReplayContainer';
99
import { useFakeTimers } from '../../utils/use-fake-timers';
@@ -57,4 +57,65 @@ describe('Unit | util | addEvent', () => {
5757

5858
expect(replay.isEnabled()).toEqual(false);
5959
});
60+
61+
describe('shouldAddEvent', () => {
62+
beforeEach(() => {
63+
jest.setSystemTime(BASE_TIMESTAMP);
64+
});
65+
66+
it('returns true by default', () => {
67+
const replay = setupReplayContainer({});
68+
const event = getTestEventIncremental({ timestamp: BASE_TIMESTAMP });
69+
70+
expect(shouldAddEvent(replay, event)).toEqual(true);
71+
});
72+
73+
it('returns false when paused', () => {
74+
const replay = setupReplayContainer({});
75+
const event = getTestEventIncremental({ timestamp: BASE_TIMESTAMP });
76+
77+
replay.pause();
78+
79+
expect(shouldAddEvent(replay, event)).toEqual(false);
80+
});
81+
82+
it('returns false when disabled', async () => {
83+
const replay = setupReplayContainer({});
84+
const event = getTestEventIncremental({ timestamp: BASE_TIMESTAMP });
85+
86+
await replay.stop();
87+
88+
expect(shouldAddEvent(replay, event)).toEqual(false);
89+
});
90+
91+
it('returns false if there is no eventBuffer', () => {
92+
const replay = setupReplayContainer({});
93+
const event = getTestEventIncremental({ timestamp: BASE_TIMESTAMP });
94+
95+
replay.eventBuffer = null;
96+
97+
expect(shouldAddEvent(replay, event)).toEqual(false);
98+
});
99+
100+
it('returns false when event is too old', () => {
101+
const replay = setupReplayContainer({});
102+
const event = getTestEventIncremental({ timestamp: BASE_TIMESTAMP - SESSION_IDLE_PAUSE_DURATION - 1 });
103+
104+
expect(shouldAddEvent(replay, event)).toEqual(false);
105+
});
106+
107+
it('returns false if event is too long after initial timestamp', () => {
108+
const replay = setupReplayContainer({});
109+
const event = getTestEventIncremental({ timestamp: BASE_TIMESTAMP + MAX_REPLAY_DURATION + 1 });
110+
111+
expect(shouldAddEvent(replay, event)).toEqual(false);
112+
});
113+
114+
it('returns true if event is withing max duration after after initial timestamp', () => {
115+
const replay = setupReplayContainer({});
116+
const event = getTestEventIncremental({ timestamp: BASE_TIMESTAMP + MAX_REPLAY_DURATION - 1 });
117+
118+
expect(shouldAddEvent(replay, event)).toEqual(true);
119+
});
120+
});
60121
});

packages/replay/test/unit/util/handleRecordingEmit.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ describe('Unit | util | handleRecordingEmit', () => {
1616

1717
beforeEach(function () {
1818
jest.setSystemTime(BASE_TIMESTAMP);
19-
addEventMock = jest.spyOn(SentryAddEvent, 'addEvent').mockImplementation(async () => {
20-
// Do nothing
19+
addEventMock = jest.spyOn(SentryAddEvent, 'addEventSync').mockImplementation(() => {
20+
return true;
2121
});
2222
});
2323

0 commit comments

Comments
 (0)