Skip to content

Commit 03bb73f

Browse files
committed
feat(replay): Change the behavior of error-based sampling
WIP
1 parent 52b764e commit 03bb73f

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
/**
@@ -226,6 +217,27 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
226217
return this._replay.flushImmediate();
227218
}
228219

220+
/**
221+
* Initializes replay.
222+
*/
223+
protected _initialize(): void {
224+
// Once upon a time, we tried to create a transaction in `setupOnce` and it would
225+
// potentially create a transaction before some native SDK integrations have run
226+
// and applied their own global event processor. An example is:
227+
// https://github.com/getsentry/sentry-javascript/blob/b47ceafbdac7f8b99093ce6023726ad4687edc48/packages/browser/src/integrations/useragent.ts
228+
//
229+
// So we call `replay.initialize()` in next event loop as a workaround to wait for other
230+
// global event processors to finish. This is no longer needed, but keeping it
231+
// here to avoid any future issues.
232+
setTimeout(() => {
233+
if (!this._replay) {
234+
return;
235+
}
236+
237+
this._replay.initializeSampling();
238+
});
239+
}
240+
229241
/** Setup the integration. */
230242
private _setup(): void {
231243
// 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
@@ -48,9 +48,10 @@ export class ReplayContainer implements ReplayContainerInterface {
4848
public session: Session | undefined;
4949

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

@@ -148,49 +149,72 @@ export class ReplayContainer implements ReplayContainerInterface {
148149
}
149150

150151
/**
151-
* Initializes the plugin.
152-
*
153-
* Creates or loads a session, attaches listeners to varying events (DOM,
154-
* _performanceObserver, Recording, Sentry SDK, etc)
152+
* Initializes the plugin based on configuration options. Should not be called outside of constructor
155153
*/
156-
public start(): void {
157-
this.setInitialState();
154+
public initializeSampling(): void {
155+
const { errorSampleRate, sessionSampleRate } = this._options;
158156

159-
if (!this._loadAndCheckSession()) {
157+
// if neither sample rate is > 0, then do nothing
158+
if (errorSampleRate <= 0 && sessionSampleRate <= 0) {
159+
return;
160+
}
161+
162+
// Otherwise if there is _any_ sample rate set, try to load/create session
163+
const isSessionSampled = this._loadAndCheckSession();
164+
165+
if (!isSessionSampled) {
166+
// This should only occur if `errorSampleRate` is 0 and was unsampled for
167+
// session-based replay. In this case there is nothing to do.
160168
return;
161169
}
162170

163-
// If there is no session, then something bad has happened - can't continue
164171
if (!this.session) {
165-
this._handleException(new Error('No session found'));
172+
// This should not happen, something wrong has occurred
173+
this._handleException(new Error('Unable to initialize and create session'));
166174
return;
167175
}
168176

169-
if (!this.session.sampled) {
170-
// If session was not sampled, then we do not initialize the integration at all.
177+
// Only call start if this session is sampled for session-based replays
178+
if (this.session && this.session.sampled === 'session') {
179+
this._initializeRecording();
171180
return;
172181
}
173182

174-
// If session is sampled for errors, then we need to set the recordingMode
175-
// to 'error', which will configure recording with different options.
183+
// If not sampled as session-based, it will always be sampled as
184+
// error-based if `errorSampleRate` is > 0
176185
if (this.session.sampled === 'error') {
186+
// If session is sampled for errors, then we need to set the recordingMode
187+
// to 'error', which will configure recording with different options.
177188
this.recordingMode = 'error';
189+
this._initializeRecording();
178190
}
179191

180-
// setup() is generally called on page load or manually - in both cases we
181-
// should treat it as an activity
182-
this._updateSessionActivity();
192+
// There should be no other cases
193+
}
183194

184-
this.eventBuffer = createEventBuffer({
185-
useCompression: this._options.useCompression,
186-
});
195+
/**
196+
* Create and start a replay.
197+
*
198+
* Creates or loads a session, attaches listeners to varying events (DOM,
199+
* _performanceObserver, Recording, Sentry SDK, etc)
200+
*/
201+
public start(): void {
202+
// TODO: Should we allow you to call start if there is an existing replay in progress?
187203

188-
this._addListeners();
204+
const previousSessionId = this.session && this.session.id;
189205

190-
// Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout
191-
this._isEnabled = true;
206+
const { session } = getSession({
207+
timeouts: this.timeouts,
208+
stickySession: Boolean(this._options.stickySession),
209+
currentSession: this.session,
210+
sessionSampleRate: 1,
211+
errorSampleRate: 0,
212+
});
192213

193-
this.startRecording();
214+
session.previousSessionId = previousSessionId;
215+
this.session = session;
216+
217+
this._initializeRecording();
194218
}
195219

196220
/**
@@ -430,6 +454,30 @@ export class ReplayContainer implements ReplayContainerInterface {
430454
this._context.urls.push(url);
431455
}
432456

457+
/**
458+
* Initialize and start all listeners to varying events (DOM,
459+
* Performance Observer, Recording, Sentry SDK, etc)
460+
*/
461+
private _initializeRecording(): void {
462+
this.setInitialState();
463+
464+
// this method is generally called on page load or manually - in both cases
465+
// we should treat it as an activity
466+
this._updateSessionActivity();
467+
468+
this.eventBuffer = createEventBuffer({
469+
useCompression: this._options.useCompression,
470+
});
471+
472+
// TODO: we should probably remove listeners before adding listeners
473+
this._addListeners();
474+
475+
// Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout
476+
this._isEnabled = true;
477+
478+
this.startRecording();
479+
}
480+
433481
/** A wrapper to conditionally capture exceptions. */
434482
private _handleException(error: unknown): void {
435483
__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
@@ -436,6 +436,7 @@ export interface ReplayContainer {
436436
isEnabled(): boolean;
437437
isPaused(): boolean;
438438
getContext(): InternalEventContext;
439+
initializeSampling(): void;
439440
start(): void;
440441
stop(reason?: string): void;
441442
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)