Skip to content

Commit cf0152a

Browse files
authored
feat(replay): Add onError callback + other small improvements to debugging (#13721)
* Adds an `onError` callback for replay SDK exceptions * Do not log empty messages when calling `logger.exception` * Send `ratelimit_backoff` client report when necessary (instead of generic `send_error`)
1 parent 336a236 commit cf0152a

File tree

6 files changed

+113
-15
lines changed

6 files changed

+113
-15
lines changed

packages/replay-internal/src/replay.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { getHandleRecordingEmit } from './util/handleRecordingEmit';
5454
import { isExpired } from './util/isExpired';
5555
import { isSessionExpired } from './util/isSessionExpired';
5656
import { sendReplay } from './util/sendReplay';
57+
import { RateLimitError } from './util/sendReplayRequest';
5758
import type { SKIPPED } from './util/throttle';
5859
import { THROTTLED, throttle } from './util/throttle';
5960

@@ -245,6 +246,9 @@ export class ReplayContainer implements ReplayContainerInterface {
245246
/** A wrapper to conditionally capture exceptions. */
246247
public handleException(error: unknown): void {
247248
DEBUG_BUILD && logger.exception(error);
249+
if (this._options.onError) {
250+
this._options.onError(error);
251+
}
248252
}
249253

250254
/**
@@ -1157,8 +1161,8 @@ export class ReplayContainer implements ReplayContainerInterface {
11571161
segmentId,
11581162
eventContext,
11591163
session: this.session,
1160-
options: this.getOptions(),
11611164
timestamp,
1165+
onError: err => this.handleException(err),
11621166
});
11631167
} catch (err) {
11641168
this.handleException(err);
@@ -1173,7 +1177,8 @@ export class ReplayContainer implements ReplayContainerInterface {
11731177
const client = getClient();
11741178

11751179
if (client) {
1176-
client.recordDroppedEvent('send_error', 'replay');
1180+
const dropReason = err instanceof RateLimitError ? 'ratelimit_backoff' : 'send_error';
1181+
client.recordDroppedEvent(dropReason, 'replay');
11771182
}
11781183
}
11791184
}

packages/replay-internal/src/types/replay.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export interface SendReplayData {
2626
eventContext: PopEventContext;
2727
timestamp: number;
2828
session: Session;
29-
options: ReplayPluginOptions;
29+
onError?: (err: unknown) => void;
3030
}
3131

3232
export interface Timeouts {
@@ -222,6 +222,12 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
222222
*/
223223
beforeErrorSampling?: (event: ErrorEvent) => boolean;
224224

225+
/**
226+
* Callback when an internal SDK error occurs. This can be used to debug SDK
227+
* issues.
228+
*/
229+
onError?: (err: unknown) => void;
230+
225231
/**
226232
* _experiments allows users to enable experimental or internal features.
227233
* We don't consider such features as part of the public API and hence we don't guarantee semver for them.

packages/replay-internal/src/util/logger.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { addBreadcrumb, captureException } from '@sentry/core';
22
import type { ConsoleLevel, SeverityLevel } from '@sentry/types';
3-
import { logger as coreLogger } from '@sentry/utils';
3+
import { logger as coreLogger, severityLevelFromString } from '@sentry/utils';
44

55
import { DEBUG_BUILD } from '../debug-build';
66

@@ -64,13 +64,13 @@ function makeReplayLogger(): ReplayLogger {
6464
_logger[name] = (...args: unknown[]) => {
6565
coreLogger[name](PREFIX, ...args);
6666
if (_trace) {
67-
_addBreadcrumb(args[0]);
67+
_addBreadcrumb(args.join(''), severityLevelFromString(name));
6868
}
6969
};
7070
});
7171

7272
_logger.exception = (error: unknown, ...message: unknown[]) => {
73-
if (_logger.error) {
73+
if (message.length && _logger.error) {
7474
_logger.error(...message);
7575
}
7676

@@ -79,9 +79,9 @@ function makeReplayLogger(): ReplayLogger {
7979
if (_capture) {
8080
captureException(error);
8181
} else if (_trace) {
82-
// No need for a breadcrumb is `_capture` is enabled since it should be
82+
// No need for a breadcrumb if `_capture` is enabled since it should be
8383
// captured as an exception
84-
_addBreadcrumb(error);
84+
_addBreadcrumb(error, 'error');
8585
}
8686
};
8787

packages/replay-internal/src/util/sendReplay.ts

+4-5
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { setTimeout } from '@sentry-internal/browser-utils';
2-
import { captureException, setContext } from '@sentry/core';
2+
import { setContext } from '@sentry/core';
33

44
import { RETRY_BASE_INTERVAL, RETRY_MAX_COUNT, UNABLE_TO_SEND_REPLAY } from '../constants';
5-
import { DEBUG_BUILD } from '../debug-build';
65
import type { SendReplayData } from '../types';
76
import { RateLimitError, TransportStatusCodeError, sendReplayRequest } from './sendReplayRequest';
87

@@ -16,7 +15,7 @@ export async function sendReplay(
1615
interval: RETRY_BASE_INTERVAL,
1716
},
1817
): Promise<unknown> {
19-
const { recordingData, options } = replayData;
18+
const { recordingData, onError } = replayData;
2019

2120
// short circuit if there's no events to upload (this shouldn't happen as _runFlush makes this check)
2221
if (!recordingData.length) {
@@ -36,8 +35,8 @@ export async function sendReplay(
3635
_retryCount: retryConfig.count,
3736
});
3837

39-
if (DEBUG_BUILD && options._experiments && options._experiments.captureExceptions) {
40-
captureException(err);
38+
if (onError) {
39+
onError(err);
4140
}
4241

4342
// If an error happened here, it's likely that uploading the attachment

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

+2-2
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,8 @@ describe('Integration | flush', () => {
188188
segmentId: 0,
189189
eventContext: expect.anything(),
190190
session: expect.any(Object),
191-
options: expect.any(Object),
192191
timestamp: expect.any(Number),
192+
onError: expect.any(Function),
193193
});
194194

195195
// Add this to test that segment ID increases
@@ -238,7 +238,7 @@ describe('Integration | flush', () => {
238238
segmentId: 1,
239239
eventContext: expect.anything(),
240240
session: expect.any(Object),
241-
options: expect.any(Object),
241+
onError: expect.any(Function),
242242
timestamp: expect.any(Number),
243243
});
244244

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { beforeEach, describe, expect, it } from 'vitest';
2+
3+
import * as SentryCore from '@sentry/core';
4+
import { logger as coreLogger } from '@sentry/utils';
5+
import { logger } from '../../../src/util/logger';
6+
7+
const mockCaptureException = vi.spyOn(SentryCore, 'captureException');
8+
const mockAddBreadcrumb = vi.spyOn(SentryCore, 'addBreadcrumb');
9+
const mockLogError = vi.spyOn(coreLogger, 'error');
10+
vi.spyOn(coreLogger, 'info');
11+
vi.spyOn(coreLogger, 'log');
12+
vi.spyOn(coreLogger, 'warn');
13+
14+
describe('logger', () => {
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
});
18+
19+
describe.each([
20+
[false, false],
21+
[false, true],
22+
[true, false],
23+
[true, true],
24+
])('with options: captureExceptions:%s, traceInternals:%s', (captureExceptions, traceInternals) => {
25+
beforeEach(() => {
26+
logger.setConfig({
27+
captureExceptions,
28+
traceInternals,
29+
});
30+
});
31+
32+
it.each([
33+
['info', 'info', 'info message'],
34+
['log', 'log', 'log message'],
35+
['warn', 'warning', 'warn message'],
36+
['error', 'error', 'error message'],
37+
])('%s', (fn, level, message) => {
38+
logger[fn](message);
39+
expect(coreLogger[fn]).toHaveBeenCalledWith('[Replay] ', message);
40+
41+
if (traceInternals) {
42+
expect(mockAddBreadcrumb).toHaveBeenLastCalledWith(
43+
{
44+
category: 'console',
45+
data: { logger: 'replay' },
46+
level,
47+
message: `[Replay] ${message}`,
48+
},
49+
{ level },
50+
);
51+
}
52+
});
53+
54+
it('logs exceptions with a message', () => {
55+
const err = new Error('An error');
56+
logger.exception(err, 'a message');
57+
if (captureExceptions) {
58+
expect(mockCaptureException).toHaveBeenCalledWith(err);
59+
}
60+
expect(mockLogError).toHaveBeenCalledWith('[Replay] ', 'a message');
61+
expect(mockLogError).toHaveBeenLastCalledWith('[Replay] ', err);
62+
expect(mockLogError).toHaveBeenCalledTimes(2);
63+
64+
if (traceInternals) {
65+
expect(mockAddBreadcrumb).toHaveBeenCalledWith(
66+
{
67+
category: 'console',
68+
data: { logger: 'replay' },
69+
level: 'error',
70+
message: '[Replay] a message',
71+
},
72+
{ level: 'error' },
73+
);
74+
}
75+
});
76+
77+
it('logs exceptions without a message', () => {
78+
const err = new Error('An error');
79+
logger.exception(err);
80+
if (captureExceptions) {
81+
expect(mockCaptureException).toHaveBeenCalledWith(err);
82+
expect(mockAddBreadcrumb).not.toHaveBeenCalled();
83+
}
84+
expect(mockLogError).toHaveBeenCalledTimes(1);
85+
expect(mockLogError).toHaveBeenLastCalledWith('[Replay] ', err);
86+
});
87+
});
88+
});

0 commit comments

Comments
 (0)