Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(replay): Capture hydration error breadcrumb #9759

Merged
merged 13 commits into from
Dec 12, 2023
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;
}

handleErrorEvent(replay, event);
scttcper marked this conversation as resolved.
Show resolved Hide resolved
};
}

function handleErrorEvent(replay: ReplayContainer, event: ErrorEvent): void {
scttcper marked this conversation as resolved.
Show resolved Hide resolved
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,82 @@
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',
data: {},
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',
data: {},
timestamp: expect.any(Number),
type: 'default',
},
tag: 'breadcrumb',
},
timestamp: expect.any(Number),
type: 5,
});
});
});