Skip to content

Commit

Permalink
feat(replay): Capture hydration error breadcrumb (#9759)
Browse files Browse the repository at this point in the history
Adds a hydration error breadcrumb for nextjs / other react ssr
frameworks.

fixes #9649
  • Loading branch information
scttcper committed Dec 12, 2023
1 parent 13e3425 commit a05de17
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 1 deletion.
42 changes: 42 additions & 0 deletions packages/replay/src/coreHandlers/handleBeforeSendEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { ErrorEvent, Event } from '@sentry/types';

import type { ReplayContainer } from '../types';
import { createBreadcrumb } from '../util/createBreadcrumb';
import { isErrorEvent } from '../util/eventUtils';
import { addBreadcrumbEvent } from './util/addBreadcrumbEvent';

type BeforeSendEventCallback = (event: Event) => void;

/**
* Returns a listener to be added to `client.on('afterSendErrorEvent, listener)`.
*/
export function handleBeforeSendEvent(replay: ReplayContainer): BeforeSendEventCallback {
return (event: Event) => {
if (!replay.isEnabled() || !isErrorEvent(event)) {
return;
}

handleHydrationError(replay, event);
};
}

function handleHydrationError(replay: ReplayContainer, event: ErrorEvent): void {
const exceptionValue = event.exception && event.exception.values && event.exception.values[0].value;
if (typeof exceptionValue !== 'string') {
return;
}

if (
// Only matches errors in production builds of react-dom
// Example https://reactjs.org/docs/error-decoder.html?invariant=423
exceptionValue.match(/reactjs\.org\/docs\/error-decoder\.html\?invariant=(418|419|422|423|425)/) ||
// Development builds of react-dom
// Example Text: content did not match. Server: "A" Client: "B"
exceptionValue.match(/(hydration|content does not match|did not match)/i)
) {
const breadcrumb = createBreadcrumb({
category: 'replay.hydrate-error',
});
addBreadcrumbEvent(replay, breadcrumb);
}
}
2 changes: 1 addition & 1 deletion packages/replay/src/replay.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* eslint-disable max-lines */ // TODO: We might want to split this file up
import { EventType, record } from '@sentry-internal/rrweb';
import { captureException, getClient, getCurrentHub } from '@sentry/core';
import type { ReplayRecordingMode, Transaction } from '@sentry/types';
import type { Event as SentryEvent, ReplayRecordingMode, Transaction } from '@sentry/types';
import { logger } from '@sentry/utils';

import {
Expand Down
2 changes: 2 additions & 0 deletions packages/replay/src/util/addGlobalListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Client, DynamicSamplingContext } from '@sentry/types';
import { addClickKeypressInstrumentationHandler, addHistoryInstrumentationHandler } from '@sentry/utils';

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

// If a custom client has no hooks yet, we continue to use the "old" implementation
if (hasHooks(client)) {
client.on('beforeSendEvent', handleBeforeSendEvent(replay));
client.on('afterSendEvent', handleAfterSendEvent(replay));
client.on('createDsc', (dsc: DynamicSamplingContext) => {
const replayId = replay.getSessionId();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { handleBeforeSendEvent } from '../../../src/coreHandlers/handleBeforeSendEvent';
import type { ReplayContainer } from '../../../src/replay';
import { Error } from '../../fixtures/error';
import { resetSdkMock } from '../../mocks/resetSdkMock';
import { useFakeTimers } from '../../utils/use-fake-timers';

useFakeTimers();
let replay: ReplayContainer;

describe('Integration | coreHandlers | handleBeforeSendEvent', () => {
afterEach(() => {
replay.stop();
});

it('adds a hydration breadcrumb on development hydration error', async () => {
({ replay } = await resetSdkMock({
replayOptions: {
stickySession: false,
},
sentryOptions: {
replaysSessionSampleRate: 0.0,
replaysOnErrorSampleRate: 1.0,
},
}));

const handler = handleBeforeSendEvent(replay);
const addBreadcrumbSpy = jest.spyOn(replay, 'throttledAddEvent');

const error = Error();
error.exception.values[0].value = 'Text content did not match. Server: "A" Client: "B"';
handler(error);

expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1);
expect(addBreadcrumbSpy).toHaveBeenCalledWith({
data: {
payload: {
category: 'replay.hydrate-error',
timestamp: expect.any(Number),
type: 'default',
},
tag: 'breadcrumb',
},
timestamp: expect.any(Number),
type: 5,
});
});

it('adds a hydration breadcrumb on production hydration error', async () => {
({ replay } = await resetSdkMock({
replayOptions: {
stickySession: false,
},
sentryOptions: {
replaysSessionSampleRate: 0.0,
replaysOnErrorSampleRate: 1.0,
},
}));

const handler = handleBeforeSendEvent(replay);
const addBreadcrumbSpy = jest.spyOn(replay, 'throttledAddEvent');

const error = Error();
error.exception.values[0].value = 'https://reactjs.org/docs/error-decoder.html?invariant=423';
handler(error);

expect(addBreadcrumbSpy).toHaveBeenCalledTimes(1);
expect(addBreadcrumbSpy).toHaveBeenCalledWith({
data: {
payload: {
category: 'replay.hydrate-error',
timestamp: expect.any(Number),
type: 'default',
},
tag: 'breadcrumb',
},
timestamp: expect.any(Number),
type: 5,
});
});
});

0 comments on commit a05de17

Please sign in to comment.