Skip to content

Commit 968301a

Browse files
authored
fix(replay): Start replay in afterAllSetup instead of next tick (#12709)
This should hopefully fix some race conditions. Instead of initializing replay in the next tick, do it in the current tick but in `afterAllSetup`, to ensure this runs e.g. after the replay-canvas integration. Fixes #12707
1 parent 0d558de commit 968301a

File tree

20 files changed

+122
-59
lines changed

20 files changed

+122
-59
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,7 @@ jobs:
761761
- loader_debug
762762
- loader_tracing
763763
- loader_replay
764+
- loader_replay_buffer
764765
- loader_tracing_replay
765766

766767
steps:

dev-packages/browser-integration-tests/loader-suites/loader/noOnLoad/replay/test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import { expect } from '@playwright/test';
33
import { sentryTest } from '../../../../utils/fixtures';
44
import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
55

6+
const bundle = process.env.PW_BUNDLE || '';
7+
68
sentryTest('should capture a replay', async ({ getLocalTestUrl, page }) => {
7-
if (shouldSkipReplayTest()) {
9+
// When in buffer mode, there will not be a replay by default
10+
if (shouldSkipReplayTest() || bundle === 'loader_replay_buffer') {
811
sentryTest.skip();
912
}
1013

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
window.doSomethingWrong();
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { envelopeRequestParser, waitForErrorRequestOnUrl } from '../../../../utils/helpers';
5+
import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
6+
7+
sentryTest('should capture a replay & attach an error', async ({ getLocalTestUrl, page }) => {
8+
if (shouldSkipReplayTest()) {
9+
sentryTest.skip();
10+
}
11+
12+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
13+
return route.fulfill({
14+
status: 200,
15+
contentType: 'application/json',
16+
body: JSON.stringify({ id: 'test-id' }),
17+
});
18+
});
19+
20+
const req = waitForReplayRequest(page);
21+
22+
const url = await getLocalTestUrl({ testDir: __dirname });
23+
const reqError = await waitForErrorRequestOnUrl(page, url);
24+
25+
const errorEventData = envelopeRequestParser(reqError);
26+
expect(errorEventData.exception?.values?.length).toBe(1);
27+
expect(errorEventData.exception?.values?.[0]?.value).toContain('window.doSomethingWrong is not a function');
28+
29+
const eventData = getReplayEvent(await req);
30+
31+
expect(eventData).toBeDefined();
32+
expect(eventData.segment_id).toBe(0);
33+
34+
expect(errorEventData.tags?.replayId).toEqual(eventData.replay_id);
35+
});

dev-packages/browser-integration-tests/loader-suites/loader/onLoad/customReplay/init.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,7 @@ Sentry.onLoad(function () {
66
useCompression: false,
77
}),
88
],
9+
10+
replaysSessionSampleRate: 1,
911
});
1012
});
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
Sentry.onLoad(function () {
2-
Sentry.init({});
2+
Sentry.init({
3+
replaysSessionSampleRate: 1,
4+
});
35
});

dev-packages/browser-integration-tests/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"test:loader:eager": "PW_BUNDLE=loader_eager yarn test:loader",
3333
"test:loader:tracing": "PW_BUNDLE=loader_tracing yarn test:loader",
3434
"test:loader:replay": "PW_BUNDLE=loader_replay yarn test:loader",
35+
"test:loader:replay_buffer": "PW_BUNDLE=loader_replay_buffer yarn test:loader",
3536
"test:loader:full": "PW_BUNDLE=loader_tracing_replay yarn test:loader",
3637
"test:loader:debug": "PW_BUNDLE=loader_debug yarn test:loader",
3738
"test:ci": "yarn test:all --reporter='line'",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
window.doSomethingWrong();
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { envelopeRequestParser, waitForErrorRequestOnUrl } from '../../../../utils/helpers';
5+
import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
6+
7+
sentryTest(
8+
'[error-mode] should capture error that happens immediately after init',
9+
async ({ getLocalTestUrl, page }) => {
10+
if (shouldSkipReplayTest()) {
11+
sentryTest.skip();
12+
}
13+
14+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
15+
return route.fulfill({
16+
status: 200,
17+
contentType: 'application/json',
18+
body: JSON.stringify({ id: 'test-id' }),
19+
});
20+
});
21+
22+
const req = waitForReplayRequest(page);
23+
24+
const url = await getLocalTestUrl({ testDir: __dirname });
25+
const reqError = await waitForErrorRequestOnUrl(page, url);
26+
27+
const errorEventData = envelopeRequestParser(reqError);
28+
expect(errorEventData.exception?.values?.length).toBe(1);
29+
expect(errorEventData.exception?.values?.[0]?.value).toContain('window.doSomethingWrong is not a function');
30+
31+
const eventData = getReplayEvent(await req);
32+
33+
expect(eventData).toBeDefined();
34+
expect(eventData.segment_id).toBe(0);
35+
36+
expect(errorEventData.tags?.replayId).toEqual(eventData.replay_id);
37+
},
38+
);

dev-packages/browser-integration-tests/utils/generatePlugin.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const BUNDLE_PATHS: Record<string, Record<string, string>> = {
5555
loader_debug: 'build/bundles/bundle.debug.min.js',
5656
loader_tracing: 'build/bundles/bundle.tracing.min.js',
5757
loader_replay: 'build/bundles/bundle.replay.min.js',
58+
loader_replay_buffer: 'build/bundles/bundle.replay.min.js',
5859
loader_tracing_replay: 'build/bundles/bundle.tracing.replay.debug.min.js',
5960
},
6061
integrations: {
@@ -96,6 +97,10 @@ export const LOADER_CONFIGS: Record<string, { options: Record<string, unknown>;
9697
options: { replaysSessionSampleRate: 1, replaysOnErrorSampleRate: 1 },
9798
lazy: false,
9899
},
100+
loader_replay_buffer: {
101+
options: { replaysSessionSampleRate: 0, replaysOnErrorSampleRate: 1 },
102+
lazy: false,
103+
},
99104
loader_tracing_replay: {
100105
options: { tracesSampleRate: 1, replaysSessionSampleRate: 1, replaysOnErrorSampleRate: 1, debug: true },
101106
lazy: false,

packages/replay-internal/src/integration.ts

Lines changed: 13 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { getClient, parseSampleRate } from '@sentry/core';
2-
import type { BrowserClientReplayOptions, Integration, IntegrationFn } from '@sentry/types';
1+
import { parseSampleRate } from '@sentry/core';
2+
import type { BrowserClientReplayOptions, Client, Integration, IntegrationFn } from '@sentry/types';
33
import { consoleSandbox, dropUndefinedKeys, isBrowser } from '@sentry/utils';
44

55
import {
@@ -215,22 +215,13 @@ export class Replay implements Integration {
215215
/**
216216
* Setup and initialize replay container
217217
*/
218-
public setupOnce(): void {
219-
if (!isBrowser()) {
218+
public afterAllSetup(client: Client): void {
219+
if (!isBrowser() || this._replay) {
220220
return;
221221
}
222222

223-
this._setup();
224-
225-
// Once upon a time, we tried to create a transaction in `setupOnce` and it would
226-
// potentially create a transaction before some native SDK integrations have run
227-
// and applied their own global event processor. An example is:
228-
// https://github.com/getsentry/sentry-javascript/blob/b47ceafbdac7f8b99093ce6023726ad4687edc48/packages/browser/src/integrations/useragent.ts
229-
//
230-
// So we call `this._initialize()` in next event loop as a workaround to wait for other
231-
// global event processors to finish. This is no longer needed, but keeping it
232-
// here to avoid any future issues.
233-
setTimeout(() => this._initialize());
223+
this._setup(client);
224+
this._initialize(client);
234225
}
235226

236227
/**
@@ -301,24 +292,19 @@ export class Replay implements Integration {
301292
/**
302293
* Initializes replay.
303294
*/
304-
protected _initialize(): void {
295+
protected _initialize(client: Client): void {
305296
if (!this._replay) {
306297
return;
307298
}
308299

309-
// We have to run this in _initialize, because this runs in setTimeout
310-
// So when this runs all integrations have been added
311-
// Before this, we cannot access integrations on the client,
312-
// so we need to mutate the options here
313-
this._maybeLoadFromReplayCanvasIntegration();
314-
300+
this._maybeLoadFromReplayCanvasIntegration(client);
315301
this._replay.initializeSampling();
316302
}
317303

318304
/** Setup the integration. */
319-
private _setup(): void {
305+
private _setup(client: Client): void {
320306
// Client is not available in constructor, so we need to wait until setupOnce
321-
const finalOptions = loadReplayOptionsFromClient(this._initialOptions);
307+
const finalOptions = loadReplayOptionsFromClient(this._initialOptions, client);
322308

323309
this._replay = new ReplayContainer({
324310
options: finalOptions,
@@ -327,12 +313,11 @@ export class Replay implements Integration {
327313
}
328314

329315
/** Get canvas options from ReplayCanvas integration, if it is also added. */
330-
private _maybeLoadFromReplayCanvasIntegration(): void {
316+
private _maybeLoadFromReplayCanvasIntegration(client: Client): void {
331317
// To save bundle size, we skip checking for stuff here
332318
// and instead just try-catch everything - as generally this should all be defined
333319
/* eslint-disable @typescript-eslint/no-non-null-assertion */
334320
try {
335-
const client = getClient()!;
336321
const canvasIntegration = client.getIntegrationByName('ReplayCanvas') as Integration & {
337322
getOptions(): ReplayCanvasIntegrationOptions;
338323
};
@@ -349,24 +334,15 @@ export class Replay implements Integration {
349334
}
350335

351336
/** Parse Replay-related options from SDK options */
352-
function loadReplayOptionsFromClient(initialOptions: InitialReplayPluginOptions): ReplayPluginOptions {
353-
const client = getClient();
354-
const opt = client && (client.getOptions() as BrowserClientReplayOptions);
337+
function loadReplayOptionsFromClient(initialOptions: InitialReplayPluginOptions, client: Client): ReplayPluginOptions {
338+
const opt = client.getOptions() as BrowserClientReplayOptions;
355339

356340
const finalOptions: ReplayPluginOptions = {
357341
sessionSampleRate: 0,
358342
errorSampleRate: 0,
359343
...dropUndefinedKeys(initialOptions),
360344
};
361345

362-
if (!opt) {
363-
consoleSandbox(() => {
364-
// eslint-disable-next-line no-console
365-
console.warn('SDK client is not available.');
366-
});
367-
return finalOptions;
368-
}
369-
370346
const replaysSessionSampleRate = parseSampleRate(opt.replaysSessionSampleRate);
371347
const replaysOnErrorSampleRate = parseSampleRate(opt.replaysOnErrorSampleRate);
372348

packages/replay-internal/test/integration/coreHandlers/handleGlobalEvent.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getClient } from '@sentry/core';
12
import type { Event } from '@sentry/types';
23

34
import { REPLAY_EVENT_NAME, SESSION_IDLE_EXPIRE_DURATION } from '../../../src/constants';
@@ -133,8 +134,8 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => {
133134

134135
it('tags errors and transactions with replay id for session samples', async () => {
135136
const { replay, integration } = await resetSdkMock({});
136-
// @ts-expect-error protected but ok to use for testing
137-
integration._initialize();
137+
integration['_initialize'](getClient()!);
138+
138139
const transaction = Transaction();
139140
const error = Error();
140141
expect(handleGlobalEventListener(replay)(transaction, {})).toEqual(

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -866,7 +866,7 @@ describe('Integration | errorSampleRate', () => {
866866
},
867867
autoStart: false,
868868
});
869-
integration['_initialize']();
869+
integration['_initialize'](getClient()!);
870870

871871
expect(replay.recordingMode).toBe('session');
872872
const sessionId = replay.getSessionId();
@@ -899,7 +899,7 @@ describe('Integration | errorSampleRate', () => {
899899
},
900900
autoStart: false,
901901
});
902-
integration['_initialize']();
902+
integration['_initialize'](getClient()!);
903903

904904
vi.runAllTimers();
905905

@@ -943,7 +943,7 @@ describe('Integration | errorSampleRate', () => {
943943
},
944944
autoStart: false,
945945
});
946-
integration['_initialize']();
946+
integration['_initialize'](getClient()!);
947947
const optionsEvent = createOptionsEvent(replay);
948948

949949
const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP });

packages/replay-internal/test/integration/sampling.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getClient } from '@sentry/core';
12
import { resetSdkMock } from '../mocks/resetSdkMock';
23
import { useFakeTimers } from '../utils/use-fake-timers';
34

@@ -57,8 +58,7 @@ describe('Integration | sampling', () => {
5758
// @ts-expect-error private API
5859
const spyAddListeners = vi.spyOn(replay, '_addListeners');
5960

60-
// @ts-expect-error protected
61-
integration._initialize();
61+
integration['_initialize'](getClient()!);
6262

6363
vi.runAllTimers();
6464

packages/replay-internal/test/mocks/mockSdk.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,8 @@ export async function mockSdk({ replayOptions, sentryOptions, autoStart = true }
6161
_initialized = value;
6262
}
6363

64-
public setupOnce(): void {
65-
// do nothing
66-
}
67-
68-
public initialize(): void {
69-
return super._initialize();
64+
public afterAllSetup(): void {
65+
// do nothing, we need to manually initialize this
7066
}
7167
}
7268

@@ -76,7 +72,7 @@ export async function mockSdk({ replayOptions, sentryOptions, autoStart = true }
7672
...replayOptions,
7773
});
7874

79-
init({
75+
const client = init({
8076
...getDefaultClientOptions(),
8177
dsn: 'https://dsn@ingest.f00.f00/1',
8278
autoSessionTracking: false,
@@ -86,14 +82,14 @@ export async function mockSdk({ replayOptions, sentryOptions, autoStart = true }
8682
replaysOnErrorSampleRate: 0.0,
8783
...sentryOptions,
8884
integrations: [replayIntegration],
89-
});
85+
})!;
9086

91-
// Instead of `setupOnce`, which is tricky to test, we call this manually here
92-
replayIntegration['_setup']();
87+
// Instead of `afterAllSetup`, which is tricky to test, we call this manually here
88+
replayIntegration['_setup'](client);
9389

9490
if (autoStart) {
9591
// Only exists in our mock
96-
replayIntegration.initialize();
92+
replayIntegration['_initialize'](client);
9793
}
9894

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

packages/replay-internal/test/utils/TestClient.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BaseClient, createTransport, initAndBind } from '@sentry/core';
22
import type {
33
BrowserClientReplayOptions,
4+
Client,
45
ClientOptions,
56
Event,
67
ParameterizedString,
@@ -33,8 +34,8 @@ export class TestClient extends BaseClient<TestClientOptions> {
3334
}
3435
}
3536

36-
export function init(options: TestClientOptions): void {
37-
initAndBind(TestClient, options);
37+
export function init(options: TestClientOptions): Client {
38+
return initAndBind(TestClient, options);
3839
}
3940

4041
export function getDefaultClientOptions(options: Partial<TestClientOptions> = {}): ClientOptions {

0 commit comments

Comments
 (0)