Skip to content

Commit ebbfb49

Browse files
committed
feat(replay): Change the behavior of error-based sampling
WIP
1 parent be6799e commit ebbfb49

File tree

9 files changed

+170
-52
lines changed

9 files changed

+170
-52
lines changed

packages/replay/src/integration.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -169,24 +169,15 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
169169
}
170170

171171
/**
172-
* We previously used to create a transaction in `setupOnce` and it would
173-
* potentially create a transaction before some native SDK integrations have run
174-
* and applied their own global event processor. An example is:
175-
* https://github.com/getsentry/sentry-javascript/blob/b47ceafbdac7f8b99093ce6023726ad4687edc48/packages/browser/src/integrations/useragent.ts
176-
*
177-
* So we call `replay.setup` in next event loop as a workaround to wait for other
178-
* global event processors to finish. This is no longer needed, but keeping it
179-
* here to avoid any future issues.
172+
* Setup and initialize replay container
180173
*/
181174
public setupOnce(): void {
182175
if (!isBrowser()) {
183176
return;
184177
}
185178

186179
this._setup();
187-
188-
// XXX: See method comments above
189-
setTimeout(() => this.start());
180+
this._initialize();
190181
}
191182

192183
/**
@@ -230,6 +221,27 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
230221
return this._replay.sendBufferedReplayOrFlush(options);
231222
}
232223

224+
/**
225+
* Initializes replay.
226+
*/
227+
protected _initialize(): void {
228+
// Once upon a time, we tried to create a transaction in `setupOnce` and it would
229+
// potentially create a transaction before some native SDK integrations have run
230+
// and applied their own global event processor. An example is:
231+
// https://github.com/getsentry/sentry-javascript/blob/b47ceafbdac7f8b99093ce6023726ad4687edc48/packages/browser/src/integrations/useragent.ts
232+
//
233+
// So we call `replay.initialize()` in next event loop as a workaround to wait for other
234+
// global event processors to finish. This is no longer needed, but keeping it
235+
// here to avoid any future issues.
236+
setTimeout(() => {
237+
if (!this._replay) {
238+
return;
239+
}
240+
241+
this._replay.initializeSampling();
242+
});
243+
}
244+
233245
/** Setup the integration. */
234246
private _setup(): void {
235247
// Client is not available in constructor, so we need to wait until setupOnce

packages/replay/src/replay.ts

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ export class ReplayContainer implements ReplayContainerInterface {
5050
public session: Session | undefined;
5151

5252
/**
53-
* Recording can happen in one of two modes:
54-
* * session: Record the whole session, sending it continuously
55-
* * error: Always keep the last 60s of recording, and when an error occurs, send it immediately
53+
* Recording can happen in one of three modes:
54+
* - session: Record the whole session, sending it continuously
55+
* - error: Always keep the last 60s of recording, and when an error occurs, send the replay
56+
* - buffer: Always keep the last 60s of recording, requires calling `capture()` to send the replay
5657
*/
5758
public recordingMode: ReplayRecordingMode = 'session';
5859

@@ -155,49 +156,72 @@ export class ReplayContainer implements ReplayContainerInterface {
155156
}
156157

157158
/**
158-
* Initializes the plugin.
159-
*
160-
* Creates or loads a session, attaches listeners to varying events (DOM,
161-
* _performanceObserver, Recording, Sentry SDK, etc)
159+
* Initializes the plugin based on configuration options. Should not be called outside of constructor
162160
*/
163-
public start(): void {
164-
this.setInitialState();
161+
public initializeSampling(): void {
162+
const { errorSampleRate, sessionSampleRate } = this._options;
165163

166-
if (!this._loadAndCheckSession()) {
164+
// if neither sample rate is > 0, then do nothing
165+
if (errorSampleRate <= 0 && sessionSampleRate <= 0) {
166+
return;
167+
}
168+
169+
// Otherwise if there is _any_ sample rate set, try to load/create session
170+
const isSessionSampled = this._loadAndCheckSession();
171+
172+
if (!isSessionSampled) {
173+
// This should only occur if `errorSampleRate` is 0 and was unsampled for
174+
// session-based replay. In this case there is nothing to do.
167175
return;
168176
}
169177

170-
// If there is no session, then something bad has happened - can't continue
171178
if (!this.session) {
172-
this._handleException(new Error('No session found'));
179+
// This should not happen, something wrong has occurred
180+
this._handleException(new Error('Unable to initialize and create session'));
173181
return;
174182
}
175183

176-
if (!this.session.sampled) {
177-
// If session was not sampled, then we do not initialize the integration at all.
184+
// Only call start if this session is sampled for session-based replays
185+
if (this.session && this.session.sampled === 'session') {
186+
this._initializeRecording();
178187
return;
179188
}
180189

181-
// If session is sampled for errors, then we need to set the recordingMode
182-
// to 'error', which will configure recording with different options.
190+
// If not sampled as session-based, it will always be sampled as
191+
// error-based if `errorSampleRate` is > 0
183192
if (this.session.sampled === 'error') {
193+
// If session is sampled for errors, then we need to set the recordingMode
194+
// to 'error', which will configure recording with different options.
184195
this.recordingMode = 'error';
196+
this._initializeRecording();
185197
}
186198

187-
// setup() is generally called on page load or manually - in both cases we
188-
// should treat it as an activity
189-
this._updateSessionActivity();
199+
// There should be no other cases
200+
}
190201

191-
this.eventBuffer = createEventBuffer({
192-
useCompression: this._options.useCompression,
193-
});
202+
/**
203+
* Create and start a replay.
204+
*
205+
* Creates or loads a session, attaches listeners to varying events (DOM,
206+
* _performanceObserver, Recording, Sentry SDK, etc)
207+
*/
208+
public start(): void {
209+
// TODO: Should we allow you to call start if there is an existing replay in progress?
194210

195-
this._addListeners();
211+
const previousSessionId = this.session && this.session.id;
196212

197-
// Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout
198-
this._isEnabled = true;
213+
const { session } = getSession({
214+
timeouts: this.timeouts,
215+
stickySession: Boolean(this._options.stickySession),
216+
currentSession: this.session,
217+
sessionSampleRate: 1,
218+
errorSampleRate: 0,
219+
});
199220

200-
this.startRecording();
221+
session.previousSessionId = previousSessionId;
222+
this.session = session;
223+
224+
this._initializeRecording();
201225
}
202226

203227
/**
@@ -483,6 +507,30 @@ export class ReplayContainer implements ReplayContainerInterface {
483507
this._context.urls.push(url);
484508
}
485509

510+
/**
511+
* Initialize and start all listeners to varying events (DOM,
512+
* Performance Observer, Recording, Sentry SDK, etc)
513+
*/
514+
private _initializeRecording(): void {
515+
this.setInitialState();
516+
517+
// this method is generally called on page load or manually - in both cases
518+
// we should treat it as an activity
519+
this._updateSessionActivity();
520+
521+
this.eventBuffer = createEventBuffer({
522+
useCompression: this._options.useCompression,
523+
});
524+
525+
// TODO: we should probably remove listeners before adding listeners
526+
this._addListeners();
527+
528+
// Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout
529+
this._isEnabled = true;
530+
531+
this.startRecording();
532+
}
533+
486534
/** A wrapper to conditionally capture exceptions. */
487535
private _handleException(error: unknown): void {
488536
__DEBUG_BUILD__ && logger.error('[Replay]', error);

packages/replay/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,7 @@ export interface ReplayContainer {
440440
isEnabled(): boolean;
441441
isPaused(): boolean;
442442
getContext(): InternalEventContext;
443+
initializeSampling(): void;
443444
start(): void;
444445
stop(reason?: string): Promise<void>;
445446
pause(): void;

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -525,13 +525,14 @@ it('sends a replay after loading the session multiple times', async () => {
525525
REPLAY_SESSION_KEY,
526526
`{"segmentId":0,"id":"fd09adfc4117477abc8de643e5a5798a","sampled":"error","started":${BASE_TIMESTAMP},"lastActivity":${BASE_TIMESTAMP}}`,
527527
);
528-
const { mockRecord, replay } = await resetSdkMock({
528+
const { mockRecord, replay, integration } = await resetSdkMock({
529529
replayOptions: {
530530
stickySession: true,
531531
},
532532
autoStart: false,
533533
});
534-
replay.start();
534+
// @ts-ignore this is protected, but we want to call it for this test
535+
integration._initialize();
535536

536537
jest.runAllTimers();
537538

@@ -546,6 +547,7 @@ it('sends a replay after loading the session multiple times', async () => {
546547
await new Promise(process.nextTick);
547548
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
548549
await new Promise(process.nextTick);
550+
await new Promise(process.nextTick);
549551

550552
expect(replay).toHaveSentReplay({
551553
recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]),
Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { mockRrweb, mockSdk } from '../index';
1+
import { resetSdkMock } from '../mocks/resetSdkMock';
22
import { useFakeTimers } from '../utils/use-fake-timers';
33

44
useFakeTimers();
55

66
describe('Integration | sampling', () => {
7+
beforeEach(() => {
8+
jest.clearAllMocks();
9+
});
10+
711
it('does nothing if not sampled', async () => {
8-
const { record: mockRecord } = mockRrweb();
9-
const { replay } = await mockSdk({
12+
const { mockRecord, replay } = await resetSdkMock({
1013
replayOptions: {
1114
stickySession: true,
1215
},
@@ -20,14 +23,57 @@ describe('Integration | sampling', () => {
2023
const spyAddListeners = jest.spyOn(replay, '_addListeners');
2124
jest.runAllTimers();
2225

23-
expect(replay.session?.sampled).toBe(false);
24-
expect(replay.getContext()).toEqual(
25-
expect.objectContaining({
26-
initialTimestamp: expect.any(Number),
27-
initialUrl: 'http://localhost/',
28-
}),
29-
);
26+
expect(replay.session).toBe(undefined);
27+
28+
// This is what the `_context` member is initialized with
29+
expect(replay.getContext()).toEqual({
30+
errorIds: new Set(),
31+
traceIds: new Set(),
32+
urls: [],
33+
earliestEvent: null,
34+
initialTimestamp: expect.any(Number),
35+
initialUrl: '',
36+
});
3037
expect(mockRecord).not.toHaveBeenCalled();
3138
expect(spyAddListeners).not.toHaveBeenCalled();
39+
40+
// TODO: Should we initialize recordingMode to something else?
41+
expect(replay.recordingMode).toBe('session');
42+
});
43+
44+
it('samples for error based session', async () => {
45+
const { mockRecord, replay, integration } = await resetSdkMock({
46+
replayOptions: {
47+
stickySession: true,
48+
},
49+
sentryOptions: {
50+
replaysSessionSampleRate: 0.0,
51+
replaysOnErrorSampleRate: 1.0,
52+
},
53+
autoStart: false, // Needs to be false in order to spy on replay
54+
});
55+
56+
// @ts-ignore private API
57+
const spyAddListeners = jest.spyOn(replay, '_addListeners');
58+
59+
// @ts-ignore protected
60+
integration._initialize();
61+
62+
jest.runAllTimers();
63+
64+
expect(replay.session?.id).toBeDefined();
65+
66+
// This is what the `_context` member is initialized with
67+
expect(replay.getContext()).toEqual({
68+
errorIds: new Set(),
69+
earliestEvent: expect.any(Number),
70+
initialTimestamp: expect.any(Number),
71+
initialUrl: 'http://localhost/',
72+
traceIds: new Set(),
73+
urls: ['http://localhost/'],
74+
});
75+
expect(replay.recordingMode).toBe('error');
76+
expect(spyAddListeners).toHaveBeenCalledTimes(1);
77+
expect(mockRecord).toHaveBeenCalledTimes(1);
3278
});
3379
});

packages/replay/test/integration/session.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ describe('Integration | session', () => {
118118
it('creates a new session if user has been idle for more than SESSION_IDLE_DURATION and comes back to click their mouse', async () => {
119119
const initialSession = { ...replay.session } as Session;
120120

121+
expect(mockRecord).toHaveBeenCalledTimes(1);
121122
expect(initialSession?.id).toBeDefined();
122123
expect(replay.getContext()).toEqual(
123124
expect.objectContaining({

packages/replay/test/mocks/mockSdk.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ export async function mockSdk({ replayOptions, sentryOptions, autoStart = true }
6868
public setupOnce(): void {
6969
// do nothing
7070
}
71+
72+
public initialize(): void {
73+
return super._initialize();
74+
}
7175
}
7276

7377
const replayIntegration = new TestReplayIntegration({
@@ -91,7 +95,8 @@ export async function mockSdk({ replayOptions, sentryOptions, autoStart = true }
9195
replayIntegration['_setup']();
9296

9397
if (autoStart) {
94-
replayIntegration.start();
98+
// Only exists in our mock
99+
replayIntegration.initialize();
95100
}
96101

97102
const replay = replayIntegration['_replay']!;

packages/replay/test/mocks/resetSdkMock.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Replay as ReplayIntegration } from '../../src';
12
import type { ReplayContainer } from '../../src/replay';
23
import type { RecordMock } from './../index';
34
import { BASE_TIMESTAMP } from './../index';
@@ -9,6 +10,7 @@ export async function resetSdkMock({ replayOptions, sentryOptions, autoStart }:
910
domHandler: DomHandler;
1011
mockRecord: RecordMock;
1112
replay: ReplayContainer;
13+
integration: ReplayIntegration;
1214
}> {
1315
let domHandler: DomHandler;
1416

@@ -27,7 +29,7 @@ export async function resetSdkMock({ replayOptions, sentryOptions, autoStart }:
2729
const { mockRrweb } = await import('./mockRrweb');
2830
const { record: mockRecord } = mockRrweb();
2931

30-
const { replay } = await mockSdk({
32+
const { replay, integration } = await mockSdk({
3133
replayOptions,
3234
sentryOptions,
3335
autoStart,
@@ -43,5 +45,6 @@ export async function resetSdkMock({ replayOptions, sentryOptions, autoStart }:
4345
domHandler,
4446
mockRecord,
4547
replay,
48+
integration,
4649
};
4750
}

packages/types/src/replay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,4 @@ export type ReplayRecordingData = string | Uint8Array;
2424
* NOTE: These types are still considered Beta and subject to change.
2525
* @hidden
2626
*/
27-
export type ReplayRecordingMode = 'session' | 'error';
27+
export type ReplayRecordingMode = 'session' | 'error' | 'buffer';

0 commit comments

Comments
 (0)