diff --git a/packages/replay/src/coreHandlers/handleBeforeSendEvent.ts b/packages/replay/src/coreHandlers/handleBeforeSendEvent.ts new file mode 100644 index 000000000000..d7276897497e --- /dev/null +++ b/packages/replay/src/coreHandlers/handleBeforeSendEvent.ts @@ -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); + } +} diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index 635db47245d6..8e1740845c7b 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -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 { diff --git a/packages/replay/src/util/addGlobalListeners.ts b/packages/replay/src/util/addGlobalListeners.ts index 828731bf814f..fac2b278e666 100644 --- a/packages/replay/src/util/addGlobalListeners.ts +++ b/packages/replay/src/util/addGlobalListeners.ts @@ -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'; @@ -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(); diff --git a/packages/replay/test/integration/coreHandlers/handleBeforeSendEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleBeforeSendEvent.test.ts new file mode 100644 index 000000000000..92cdc5a88698 --- /dev/null +++ b/packages/replay/test/integration/coreHandlers/handleBeforeSendEvent.test.ts @@ -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, + }); + }); +});