Skip to content

Commit a05de17

Browse files
authored
feat(replay): Capture hydration error breadcrumb (#9759)
Adds a hydration error breadcrumb for nextjs / other react ssr frameworks. fixes #9649
1 parent 13e3425 commit a05de17

File tree

4 files changed

+125
-1
lines changed

4 files changed

+125
-1
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { ErrorEvent, Event } from '@sentry/types';
2+
3+
import type { ReplayContainer } from '../types';
4+
import { createBreadcrumb } from '../util/createBreadcrumb';
5+
import { isErrorEvent } from '../util/eventUtils';
6+
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';
7+
8+
type BeforeSendEventCallback = (event: Event) => void;
9+
10+
/**
11+
* Returns a listener to be added to `client.on('afterSendErrorEvent, listener)`.
12+
*/
13+
export function handleBeforeSendEvent(replay: ReplayContainer): BeforeSendEventCallback {
14+
return (event: Event) => {
15+
if (!replay.isEnabled() || !isErrorEvent(event)) {
16+
return;
17+
}
18+
19+
handleHydrationError(replay, event);
20+
};
21+
}
22+
23+
function handleHydrationError(replay: ReplayContainer, event: ErrorEvent): void {
24+
const exceptionValue = event.exception && event.exception.values && event.exception.values[0].value;
25+
if (typeof exceptionValue !== 'string') {
26+
return;
27+
}
28+
29+
if (
30+
// Only matches errors in production builds of react-dom
31+
// Example https://reactjs.org/docs/error-decoder.html?invariant=423
32+
exceptionValue.match(/reactjs\.org\/docs\/error-decoder\.html\?invariant=(418|419|422|423|425)/) ||
33+
// Development builds of react-dom
34+
// Example Text: content did not match. Server: "A" Client: "B"
35+
exceptionValue.match(/(hydration|content does not match|did not match)/i)
36+
) {
37+
const breadcrumb = createBreadcrumb({
38+
category: 'replay.hydrate-error',
39+
});
40+
addBreadcrumbEvent(replay, breadcrumb);
41+
}
42+
}

packages/replay/src/replay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/* eslint-disable max-lines */ // TODO: We might want to split this file up
22
import { EventType, record } from '@sentry-internal/rrweb';
33
import { captureException, getClient, getCurrentHub } from '@sentry/core';
4-
import type { ReplayRecordingMode, Transaction } from '@sentry/types';
4+
import type { Event as SentryEvent, ReplayRecordingMode, Transaction } from '@sentry/types';
55
import { logger } from '@sentry/utils';
66

77
import {

packages/replay/src/util/addGlobalListeners.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Client, DynamicSamplingContext } from '@sentry/types';
44
import { addClickKeypressInstrumentationHandler, addHistoryInstrumentationHandler } from '@sentry/utils';
55

66
import { handleAfterSendEvent } from '../coreHandlers/handleAfterSendEvent';
7+
import { handleBeforeSendEvent } from '../coreHandlers/handleBeforeSendEvent';
78
import { handleDomListener } from '../coreHandlers/handleDom';
89
import { handleGlobalEventListener } from '../coreHandlers/handleGlobalEvent';
910
import { handleHistorySpanListener } from '../coreHandlers/handleHistory';
@@ -35,6 +36,7 @@ export function addGlobalListeners(replay: ReplayContainer): void {
3536

3637
// If a custom client has no hooks yet, we continue to use the "old" implementation
3738
if (hasHooks(client)) {
39+
client.on('beforeSendEvent', handleBeforeSendEvent(replay));
3840
client.on('afterSendEvent', handleAfterSendEvent(replay));
3941
client.on('createDsc', (dsc: DynamicSamplingContext) => {
4042
const replayId = replay.getSessionId();
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { handleBeforeSendEvent } from '../../../src/coreHandlers/handleBeforeSendEvent';
2+
import type { ReplayContainer } from '../../../src/replay';
3+
import { Error } from '../../fixtures/error';
4+
import { resetSdkMock } from '../../mocks/resetSdkMock';
5+
import { useFakeTimers } from '../../utils/use-fake-timers';
6+
7+
useFakeTimers();
8+
let replay: ReplayContainer;
9+
10+
describe('Integration | coreHandlers | handleBeforeSendEvent', () => {
11+
afterEach(() => {
12+
replay.stop();
13+
});
14+
15+
it('adds a hydration breadcrumb on development hydration error', async () => {
16+
({ replay } = await resetSdkMock({
17+
replayOptions: {
18+
stickySession: false,
19+
},
20+
sentryOptions: {
21+
replaysSessionSampleRate: 0.0,
22+
replaysOnErrorSampleRate: 1.0,
23+
},
24+
}));
25+
26+
const handler = handleBeforeSendEvent(replay);
27+
const addBreadcrumbSpy = jest.spyOn(replay, 'throttledAddEvent');
28+
29+
const error = Error();
30+
error.exception.values[0].value = 'Text content did not match. Server: "A" Client: "B"';
31+
handler(error);
32+
33+
expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1);
34+
expect(addBreadcrumbSpy).toHaveBeenCalledWith({
35+
data: {
36+
payload: {
37+
category: 'replay.hydrate-error',
38+
timestamp: expect.any(Number),
39+
type: 'default',
40+
},
41+
tag: 'breadcrumb',
42+
},
43+
timestamp: expect.any(Number),
44+
type: 5,
45+
});
46+
});
47+
48+
it('adds a hydration breadcrumb on production hydration error', async () => {
49+
({ replay } = await resetSdkMock({
50+
replayOptions: {
51+
stickySession: false,
52+
},
53+
sentryOptions: {
54+
replaysSessionSampleRate: 0.0,
55+
replaysOnErrorSampleRate: 1.0,
56+
},
57+
}));
58+
59+
const handler = handleBeforeSendEvent(replay);
60+
const addBreadcrumbSpy = jest.spyOn(replay, 'throttledAddEvent');
61+
62+
const error = Error();
63+
error.exception.values[0].value = 'https://reactjs.org/docs/error-decoder.html?invariant=423';
64+
handler(error);
65+
66+
expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1);
67+
expect(addBreadcrumbSpy).toHaveBeenCalledWith({
68+
data: {
69+
payload: {
70+
category: 'replay.hydrate-error',
71+
timestamp: expect.any(Number),
72+
type: 'default',
73+
},
74+
tag: 'breadcrumb',
75+
},
76+
timestamp: expect.any(Number),
77+
type: 5,
78+
});
79+
});
80+
});

0 commit comments

Comments
 (0)