Skip to content

Commit eb32159

Browse files
committed
feat(replay): Use new afterSend hook to improve error linking
1 parent 1d6d216 commit eb32159

File tree

20 files changed

+679
-175
lines changed

20 files changed

+679
-175
lines changed

packages/browser/src/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export class BrowserClient extends BaseClient<BrowserClientOptions> {
146146
} else {
147147
// If beacon is not supported or if they are using the tunnel option
148148
// use our regular transport to send client reports to Sentry.
149-
this._sendEnvelope(envelope);
149+
void this._sendEnvelope(envelope);
150150
}
151151
} catch (e) {
152152
__DEBUG_BUILD__ && logger.error(e);

packages/core/src/baseclient.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
Transaction,
2121
TransactionEvent,
2222
Transport,
23+
TransportMakeRequestResponse,
2324
} from '@sentry/types';
2425
import {
2526
addItemToEnvelope,
@@ -320,7 +321,10 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
320321
);
321322
}
322323

323-
this._sendEnvelope(env);
324+
const promise = this._sendEnvelope(env);
325+
if (promise && isErrorEvent(event)) {
326+
promise.then(sendResponse => this.emit('afterSendErrorEvent', event, sendResponse), null);
327+
}
324328
}
325329
}
326330

@@ -330,7 +334,7 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
330334
public sendSession(session: Session | SessionAggregates): void {
331335
if (this._dsn) {
332336
const env = createSessionEnvelope(session, this._dsn, this._options._metadata, this._options.tunnel);
333-
this._sendEnvelope(env);
337+
void this._sendEnvelope(env);
334338
}
335339
}
336340

@@ -363,6 +367,12 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
363367
/** @inheritdoc */
364368
public on(hook: 'beforeEnvelope', callback: (envelope: Envelope) => void): void;
365369

370+
/** @inheritdoc */
371+
public on(
372+
hook: 'afterSendErrorEvent',
373+
callback: (event: ErrorEvent, sendResponse: TransportMakeRequestResponse | void) => void,
374+
): void;
375+
366376
/** @inheritdoc */
367377
public on(hook: string, callback: unknown): void {
368378
if (!this._hooks[hook]) {
@@ -379,6 +389,9 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
379389
/** @inheritdoc */
380390
public emit(hook: 'beforeEnvelope', envelope: Envelope): void;
381391

392+
/** @inheritdoc */
393+
public emit(hook: 'afterSendErrorEvent', event: ErrorEvent, sendResponse: TransportMakeRequestResponse | void): void;
394+
382395
/** @inheritdoc */
383396
public emit(hook: string, ...rest: unknown[]): void {
384397
if (this._hooks[hook]) {
@@ -624,9 +637,9 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
624637
/**
625638
* @inheritdoc
626639
*/
627-
protected _sendEnvelope(envelope: Envelope): void {
640+
protected _sendEnvelope(envelope: Envelope): PromiseLike<void | TransportMakeRequestResponse> | void {
628641
if (this._transport && this._dsn) {
629-
this._transport.send(envelope).then(null, reason => {
642+
return this._transport.send(envelope).then(null, reason => {
630643
__DEBUG_BUILD__ && logger.error('Error while sending event:', reason);
631644
});
632645
} else {

packages/core/src/transports/base.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ export function createTransport(
104104
);
105105
}
106106

107+
// We use this to identifify if the transport is the base transport
108+
send.__sentry__baseTransport__ = true;
109+
107110
return {
108111
send,
109112
flush,

packages/core/test/lib/base.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,6 +1634,108 @@ describe('BaseClient', () => {
16341634
});
16351635
});
16361636

1637+
describe('sendEvent', () => {
1638+
beforeEach(() => {
1639+
jest.useFakeTimers();
1640+
});
1641+
1642+
afterEach(() => {
1643+
jest.useRealTimers();
1644+
});
1645+
1646+
it('emits `afterSendErrorEvent` when sending an error', async () => {
1647+
const client = new TestClient(
1648+
getDefaultTestClientOptions({
1649+
dsn: PUBLIC_DSN,
1650+
enableSend: true,
1651+
}),
1652+
);
1653+
1654+
// @ts-ignore
1655+
const mockSend = jest.spyOn(client._transport, 'send');
1656+
1657+
const errorEvent: Event = { message: 'error' };
1658+
1659+
const callback = jest.fn();
1660+
client.on('afterSendErrorEvent', callback);
1661+
1662+
client.sendEvent(errorEvent);
1663+
jest.runAllTimers();
1664+
// Wait for two ticks
1665+
// note that for whatever reason, await new Promise(resolve => setTimeout(resolve, 0)) causes the test to hang
1666+
await undefined;
1667+
await undefined;
1668+
1669+
expect(mockSend).toBeCalledTimes(1);
1670+
expect(callback).toBeCalledTimes(1);
1671+
expect(callback).toBeCalledWith(errorEvent, {});
1672+
});
1673+
1674+
it('still triggers `afterSendErrorEvent` when transport.send rejects', async () => {
1675+
expect.assertions(3);
1676+
1677+
const client = new TestClient(
1678+
getDefaultTestClientOptions({
1679+
dsn: PUBLIC_DSN,
1680+
enableSend: true,
1681+
}),
1682+
);
1683+
1684+
// @ts-ignore
1685+
const mockSend = jest.spyOn(client._transport, 'send').mockImplementation(() => {
1686+
return Promise.reject('send error');
1687+
});
1688+
1689+
const errorEvent: Event = { message: 'error' };
1690+
1691+
const callback = jest.fn();
1692+
client.on('afterSendErrorEvent', callback);
1693+
1694+
client.sendEvent(errorEvent);
1695+
jest.runAllTimers();
1696+
// Wait for two ticks
1697+
// note that for whatever reason, await new Promise(resolve => setTimeout(resolve, 0)) causes the test to hang
1698+
await undefined;
1699+
await undefined;
1700+
1701+
expect(mockSend).toBeCalledTimes(1);
1702+
expect(callback).toBeCalledTimes(1);
1703+
expect(callback).toBeCalledWith(errorEvent, undefined);
1704+
});
1705+
1706+
it('passes the response to the hook', async () => {
1707+
expect.assertions(3);
1708+
1709+
const client = new TestClient(
1710+
getDefaultTestClientOptions({
1711+
dsn: PUBLIC_DSN,
1712+
enableSend: true,
1713+
}),
1714+
);
1715+
1716+
// @ts-ignore
1717+
const mockSend = jest.spyOn(client._transport, 'send').mockImplementation(() => {
1718+
return Promise.resolve({ statusCode: 200 });
1719+
});
1720+
1721+
const errorEvent: Event = { message: 'error' };
1722+
1723+
const callback = jest.fn();
1724+
client.on('afterSendErrorEvent', callback);
1725+
1726+
client.sendEvent(errorEvent);
1727+
jest.runAllTimers();
1728+
// Wait for two ticks
1729+
// note that for whatever reason, await new Promise(resolve => setTimeout(resolve, 0)) causes the test to hang
1730+
await undefined;
1731+
await undefined;
1732+
1733+
expect(mockSend).toBeCalledTimes(1);
1734+
expect(callback).toBeCalledTimes(1);
1735+
expect(callback).toBeCalledWith(errorEvent, { statusCode: 200 });
1736+
});
1737+
});
1738+
16371739
describe('captureSession()', () => {
16381740
test('sends sessions to the client', () => {
16391741
expect.assertions(1);
Lines changed: 5 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,15 @@
11
import { expect } from '@playwright/test';
22

33
import { sentryTest } from '../../../../utils/fixtures';
4-
import { getExpectedReplayEvent } from '../../../../utils/replayEventTemplates';
5-
import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
4+
import { getReplaySnapshot, shouldSkipReplayTest } from '../../../../utils/replayHelpers';
65

7-
/*
8-
* This scenario currently shows somewhat unexpected behavior from the PoV of a user:
9-
* The error is dropped, but the recording is started and continued anyway.
10-
* If folks only sample error replays, this will lead to a lot of confusion as the resulting replay
11-
* won't contain the error that started it (possibly none or only additional errors that occurred later on).
12-
*
13-
* This is because in error-mode, we start recording as soon as replay's eventProcessor is called with an error.
14-
* If later event processors or beforeSend drop the error, the recording is already started.
15-
*
16-
* We'll need a proper SDK lifecycle hook (WIP) to fix this properly.
17-
* TODO: Once we have lifecycle hooks, we should revisit this test and make sure it behaves as expected.
18-
* This means that the recording should not be started or stopped if the error that triggered it is not sent.
19-
*/
206
sentryTest(
21-
'[error-mode] should start recording if an error occurred although the error was dropped',
7+
'[error-mode] should not start recording if an error occurred when the error was dropped',
228
async ({ getLocalTestPath, page }) => {
239
if (shouldSkipReplayTest()) {
2410
sentryTest.skip();
2511
}
2612

27-
const reqPromise0 = waitForReplayRequest(page, 0);
28-
const reqPromise1 = waitForReplayRequest(page, 1);
29-
const reqPromise2 = waitForReplayRequest(page, 2);
30-
3113
let callsToSentry = 0;
3214

3315
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
@@ -47,26 +29,14 @@ sentryTest(
4729
expect(callsToSentry).toEqual(0);
4830

4931
await page.click('#error');
50-
const req0 = await reqPromise0;
5132

5233
await page.click('#go-background');
53-
await reqPromise1;
54-
5534
await page.click('#log');
5635
await page.click('#go-background');
57-
await reqPromise2;
5836

59-
// Note: The fact that reqPromise1/reqPromise2 are fulfilled prooves that the recording continues
60-
61-
const event0 = getReplayEvent(req0);
37+
expect(callsToSentry).toEqual(0);
6238

63-
expect(event0).toEqual(
64-
getExpectedReplayEvent({
65-
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
66-
// This is by design. A dropped error shouldn't be in this list.
67-
error_ids: [],
68-
replay_type: 'error',
69-
}),
70-
);
39+
const replay = await getReplaySnapshot(page);
40+
expect(replay.recordingMode).toBe('error');
7141
},
7242
);
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 500,
6+
flushMaxDelay: 500,
7+
});
8+
9+
Sentry.init({
10+
dsn: 'https://[email protected]/1337',
11+
sampleRate: 1,
12+
replaysSessionSampleRate: 0.0,
13+
replaysOnErrorSampleRate: 1.0,
14+
integrations: [window.Replay],
15+
transport: options => {
16+
const transport = new Sentry.makeXHRTransport(options);
17+
18+
delete transport.send.__sentry__baseTransport__;
19+
20+
return transport;
21+
},
22+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getReplaySnapshot, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers';
5+
6+
sentryTest('[error-mode] should handle errors with custom transport', async ({ getLocalTestPath, page }) => {
7+
if (shouldSkipReplayTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
const promiseReq0 = waitForReplayRequest(page, 0);
12+
const promiseReq1 = waitForReplayRequest(page, 1);
13+
14+
let callsToSentry = 0;
15+
16+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
17+
callsToSentry++;
18+
19+
return route.fulfill({
20+
// Only error out for error, then succeed
21+
status: callsToSentry === 1 ? 422 : 200,
22+
});
23+
});
24+
25+
const url = await getLocalTestPath({ testDir: __dirname });
26+
27+
await page.goto(url);
28+
await page.click('#go-background');
29+
expect(callsToSentry).toEqual(0);
30+
31+
await page.click('#error');
32+
await promiseReq0;
33+
34+
await page.click('#go-background');
35+
await promiseReq1;
36+
37+
const replay = await getReplaySnapshot(page);
38+
expect(replay.recordingMode).toBe('session');
39+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 500,
6+
flushMaxDelay: 500,
7+
});
8+
9+
Sentry.init({
10+
dsn: 'https://[email protected]/1337',
11+
sampleRate: 1,
12+
replaysSessionSampleRate: 0.0,
13+
replaysOnErrorSampleRate: 1.0,
14+
integrations: [window.Replay],
15+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getReplaySnapshot, shouldSkipReplayTest } from '../../../../utils/replayHelpers';
5+
6+
sentryTest(
7+
'[error-mode] should handle errors that result in API error response',
8+
async ({ getLocalTestPath, page }) => {
9+
if (shouldSkipReplayTest()) {
10+
sentryTest.skip();
11+
}
12+
13+
let callsToSentry = 0;
14+
15+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
16+
callsToSentry++;
17+
18+
return route.fulfill({
19+
status: 422,
20+
contentType: 'application/json',
21+
});
22+
});
23+
24+
const url = await getLocalTestPath({ testDir: __dirname });
25+
26+
await page.goto(url);
27+
await page.click('#go-background');
28+
expect(callsToSentry).toEqual(0);
29+
30+
await page.click('#error');
31+
32+
await page.click('#go-background');
33+
await page.click('#log');
34+
await page.click('#go-background');
35+
36+
// Only sent once, but since API failed we do not go into session mode
37+
expect(callsToSentry).toEqual(1);
38+
39+
const replay = await getReplaySnapshot(page);
40+
expect(replay.recordingMode).toBe('error');
41+
},
42+
);

0 commit comments

Comments
 (0)