diff --git a/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx b/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx index f4e586f2e557..78e0e8940eb5 100644 --- a/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx +++ b/packages/e2e-tests/test-applications/create-remix-app-v2/app/entry.server.tsx @@ -24,9 +24,7 @@ Sentry.init({ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! }); -export function handleError(error: unknown, { request }: DataFunctionArgs): void { - Sentry.captureRemixServerException(error, 'remix.server', request); -} +export const handleError = Sentry.wrapRemixHandleError; export default function handleRequest( request: Request, diff --git a/packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx b/packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx index 3d618e754244..f0c451dcaf55 100644 --- a/packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx +++ b/packages/e2e-tests/test-applications/create-remix-app/app/entry.server.tsx @@ -21,9 +21,7 @@ Sentry.init({ tracesSampleRate: 1.0, // Capture 100% of the transactions, reduce in production! }); -export function handleError(error: unknown, { request }: DataFunctionArgs): void { - Sentry.captureRemixServerException(error, 'remix.server', request); -} +export const handleError = Sentry.wrapRemixHandleError; export default function handleRequest( request: Request, diff --git a/packages/remix/src/client/errors.tsx b/packages/remix/src/client/errors.tsx index ef56ce124c61..b46f5fde069a 100644 --- a/packages/remix/src/client/errors.tsx +++ b/packages/remix/src/client/errors.tsx @@ -1,8 +1,7 @@ import { captureException } from '@sentry/core'; -import { isNodeEnv, isString } from '@sentry/utils'; +import { isNodeEnv } from '@sentry/utils'; -import { isRouteErrorResponse } from '../utils/vendor/response'; -import type { ErrorResponse } from '../utils/vendor/types'; +import { isResponse } from '../utils/vendor/response'; /** * Captures an error that is thrown inside a Remix ErrorBoundary. @@ -11,57 +10,26 @@ import type { ErrorResponse } from '../utils/vendor/types'; * @returns void */ export function captureRemixErrorBoundaryError(error: unknown): string | undefined { - let eventId: string | undefined; - const isClientSideRuntimeError = !isNodeEnv() && error instanceof Error; - // We only capture `ErrorResponse`s that are 5xx errors. - const isRemixErrorResponse = isRouteErrorResponse(error) && error.status >= 500; - // Server-side errors apart from `ErrorResponse`s also appear here without their stacktraces. - // So, we only capture: - // 1. `ErrorResponse`s - // 2. Client-side runtime errors here, - // And other server-side errors captured in `handleError` function where stacktraces are available. - if (isRemixErrorResponse || isClientSideRuntimeError) { - const eventData = isRemixErrorResponse - ? { - function: 'ErrorResponse', - ...getErrorData(error), - } - : { - function: 'ReactError', - }; - - const actualError = isRemixErrorResponse ? getExceptionToCapture(error) : error; - - eventId = captureException(actualError, { - mechanism: { - type: 'instrument', - handled: false, - data: eventData, - }, - }); - } - - return eventId; -} - -function getErrorData(error: ErrorResponse): object { - if (isString(error.data)) { - return { - error: error.data, - }; + // Server-side errors also appear here without their stacktraces. + // So, we only capture client-side runtime errors here. + // ErrorResponses that are 5xx errors captured at loader / action level by `captureRemixRouteError` function, + // And other server-side errors captured in `handleError` function where stacktraces are available. + // + // We don't want to capture: + // - Response Errors / Objects [They are originated and handled on the server-side] + // - SSR Errors [They are originated and handled on the server-side] + // - Anything without a stacktrace [Remix trims the stacktrace of the errors that are thrown on the server-side] + if (isResponse(error) || isNodeEnv() || !(error instanceof Error)) { + return; } - return error.data; -} - -function getExceptionToCapture(error: ErrorResponse): string | ErrorResponse { - if (isString(error.data)) { - return error.data; - } - - if (error.statusText) { - return error.statusText; - } - - return error; + return captureException(error, { + mechanism: { + type: 'instrument', + handled: false, + data: { + function: 'ReactError', + }, + }, + }); } diff --git a/packages/remix/src/index.server.ts b/packages/remix/src/index.server.ts index 8a85ed16913f..12fc10b522cf 100644 --- a/packages/remix/src/index.server.ts +++ b/packages/remix/src/index.server.ts @@ -59,7 +59,7 @@ export { // Keeping the `*` exports for backwards compatibility and types export * from '@sentry/node'; -export { captureRemixServerException } from './utils/instrumentServer'; +export { captureRemixServerException, wrapRemixHandleError } from './utils/instrumentServer'; export { ErrorBoundary, withErrorBoundary } from '@sentry/react'; export { remixRouterInstrumentation, withSentry } from './client/performance'; export { captureRemixErrorBoundaryError } from './client/errors'; diff --git a/packages/remix/src/utils/instrumentServer.ts b/packages/remix/src/utils/instrumentServer.ts index 47d48f4a9d9c..7e98dc123858 100644 --- a/packages/remix/src/utils/instrumentServer.ts +++ b/packages/remix/src/utils/instrumentServer.ts @@ -8,8 +8,10 @@ import { dynamicSamplingContextToSentryBaggageHeader, fill, isNodeEnv, + isPrimitive, loadModule, logger, + objectify, tracingContextFromHeaders, } from '@sentry/utils'; @@ -70,6 +72,28 @@ async function extractResponseError(response: Response): Promise { return responseData; } +/** + * Sentry utility to be used in place of `handleError` function of Remix v2 + * Remix Docs: https://remix.run/docs/en/main/file-conventions/entry.server#handleerror + * + * Should be used in `entry.server` like: + * + * export const handleError = Sentry.wrapRemixHandleError + */ +export function wrapRemixHandleError(err: unknown, { request }: DataFunctionArgs): void { + // We are skipping thrown responses here as they are handled by + // `captureRemixServerException` at loader / action level + // We don't want to capture them twice. + // This function if only for capturing unhandled server-side exceptions. + // https://remix.run/docs/en/main/file-conventions/entry.server#thrown-responses + // https://remix.run/docs/en/v1/api/conventions#throwing-responses-in-loaders + if (isResponse(err) || isRouteErrorResponse(err)) { + return; + } + + void captureRemixServerException(err, 'remix.server.handleError', request); +} + /** * Captures an exception happened in the Remix server. * @@ -107,7 +131,9 @@ export async function captureRemixServerException(err: unknown, name: string, re DEBUG_BUILD && logger.warn('Failed to normalize Remix request'); } - captureException(isResponse(err) ? await extractResponseError(err) : err, scope => { + const objectifiedErr = objectify(err); + + captureException(isResponse(objectifiedErr) ? await extractResponseError(objectifiedErr) : objectifiedErr, scope => { const activeTransactionName = getActiveTransaction()?.name; scope.setSDKProcessingMetadata({ @@ -138,7 +164,7 @@ export async function captureRemixServerException(err: unknown, name: string, re }); } -function makeWrappedDocumentRequestFunction(remixVersion: number) { +function makeWrappedDocumentRequestFunction(remixVersion?: number) { return function (origDocumentRequestFunction: HandleDocumentRequestFunction): HandleDocumentRequestFunction { return async function ( this: unknown, @@ -149,7 +175,6 @@ function makeWrappedDocumentRequestFunction(remixVersion: number) { loadContext?: Record, ): Promise { let res: Response; - const activeTransaction = getActiveTransaction(); try { @@ -174,7 +199,12 @@ function makeWrappedDocumentRequestFunction(remixVersion: number) { span?.finish(); } catch (err) { - if (!FUTURE_FLAGS?.v2_errorBoundary && remixVersion !== 2) { + const isRemixV1 = !FUTURE_FLAGS?.v2_errorBoundary && remixVersion !== 2; + + // This exists to capture the server-side rendering errors on Remix v1 + // On Remix v2, we capture SSR errors at `handleError` + // We also skip primitives here, as we can't dedupe them, and also we don't expect any primitive SSR errors. + if (isRemixV1 && !isPrimitive(err)) { await captureRemixServerException(err, 'documentRequest', request); } @@ -217,7 +247,12 @@ function makeWrappedDataFunction( currentScope.setSpan(activeTransaction); span?.finish(); } catch (err) { - if (!FUTURE_FLAGS?.v2_errorBoundary && remixVersion !== 2) { + const isRemixV2 = FUTURE_FLAGS?.v2_errorBoundary || remixVersion === 2; + + // On Remix v2, we capture all unexpected errors (except the `Route Error Response`s / Thrown Responses) in `handleError` function. + // This is both for consistency and also avoid duplicates such as primitives like `string` or `number` being captured twice. + // Remix v1 does not have a `handleError` function, so we capture all errors here. + if (isRemixV2 ? isResponse(err) : true) { await captureRemixServerException(err, name, args.request); } @@ -240,7 +275,10 @@ const makeWrappedLoader = return makeWrappedDataFunction(origLoader, id, 'loader', remixVersion); }; -function getTraceAndBaggage(): { sentryTrace?: string; sentryBaggage?: string } { +function getTraceAndBaggage(): { + sentryTrace?: string; + sentryBaggage?: string; +} { const transaction = getActiveTransaction(); const currentScope = getCurrentHub().getScope(); @@ -287,7 +325,11 @@ function makeWrappedRootLoader(remixVersion: number) { if (typeof data === 'object') { return json( { ...data, ...traceAndBaggage, remixVersion }, - { headers: res.headers, statusText: res.statusText, status: res.status }, + { + headers: res.headers, + statusText: res.statusText, + status: res.status, + }, ); } else { DEBUG_BUILD && logger.warn('Skipping injection of trace and baggage as the response body is not an object'); @@ -498,7 +540,9 @@ function makeWrappedCreateRequestHandler( * which Remix Adapters (https://remix.run/docs/en/v1/api/remix) use underneath. */ export function instrumentServer(): void { - const pkg = loadModule<{ createRequestHandler: CreateRequestHandlerFunction }>('@remix-run/server-runtime'); + const pkg = loadModule<{ + createRequestHandler: CreateRequestHandlerFunction; + }>('@remix-run/server-runtime'); if (!pkg) { DEBUG_BUILD && logger.warn('Remix SDK was unable to require `@remix-run/server-runtime` package.'); diff --git a/packages/remix/src/utils/vendor/types.ts b/packages/remix/src/utils/vendor/types.ts index e92dbc0f99af..de1a20fcbff1 100644 --- a/packages/remix/src/utils/vendor/types.ts +++ b/packages/remix/src/utils/vendor/types.ts @@ -108,7 +108,12 @@ export type DeferredData = { }; export interface MetaFunction { - (args: { data: AppData; parentsData: RouteData; params: Params; location: Location }): HtmlMetaDescriptor; + (args: { + data: AppData; + parentsData: RouteData; + params: Params; + location: Location; + }): HtmlMetaDescriptor; } export interface HtmlMetaDescriptor { @@ -143,7 +148,11 @@ export interface LoaderFunction { } export interface HeadersFunction { - (args: { loaderHeaders: Headers; parentHeaders: Headers; actionHeaders: Headers }): Headers | HeadersInit; + (args: { + loaderHeaders: Headers; + parentHeaders: Headers; + actionHeaders: Headers; + }): Headers | HeadersInit; } export interface ServerRouteModule extends EntryRouteModule { diff --git a/packages/remix/test/integration/app_v1/routes/server-side-unexpected-errors/$id.tsx b/packages/remix/test/integration/app_v1/routes/server-side-unexpected-errors/$id.tsx new file mode 100644 index 000000000000..fc595b18c3b3 --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/server-side-unexpected-errors/$id.tsx @@ -0,0 +1,2 @@ +export * from '../../../common/routes/server-side-unexpected-errors.$id'; +export { default } from '../../../common/routes/server-side-unexpected-errors.$id'; diff --git a/packages/remix/test/integration/app_v1/routes/ssr-error.tsx b/packages/remix/test/integration/app_v1/routes/ssr-error.tsx new file mode 100644 index 000000000000..627f7e126871 --- /dev/null +++ b/packages/remix/test/integration/app_v1/routes/ssr-error.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/ssr-error'; +export { default } from '../../common/routes/ssr-error'; diff --git a/packages/remix/test/integration/app_v2/entry.server.tsx b/packages/remix/test/integration/app_v2/entry.server.tsx index 3b18492eb62b..f9205ecf89b2 100644 --- a/packages/remix/test/integration/app_v2/entry.server.tsx +++ b/packages/remix/test/integration/app_v2/entry.server.tsx @@ -11,9 +11,7 @@ Sentry.init({ autoSessionTracking: false, }); -export function handleError(error: unknown, { request }: DataFunctionArgs): void { - Sentry.captureRemixServerException(error, 'remix.server', request); -} +export const handleError = Sentry.wrapRemixHandleError; export default function handleRequest( request: Request, diff --git a/packages/remix/test/integration/app_v2/routes/server-side-unexpected-errors.$id.tsx b/packages/remix/test/integration/app_v2/routes/server-side-unexpected-errors.$id.tsx new file mode 100644 index 000000000000..d9571c68ddd5 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/server-side-unexpected-errors.$id.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/server-side-unexpected-errors.$id'; +export { default } from '../../common/routes/server-side-unexpected-errors.$id'; diff --git a/packages/remix/test/integration/app_v2/routes/ssr-error.tsx b/packages/remix/test/integration/app_v2/routes/ssr-error.tsx new file mode 100644 index 000000000000..627f7e126871 --- /dev/null +++ b/packages/remix/test/integration/app_v2/routes/ssr-error.tsx @@ -0,0 +1,2 @@ +export * from '../../common/routes/ssr-error'; +export { default } from '../../common/routes/ssr-error'; diff --git a/packages/remix/test/integration/common/routes/action-json-response.$id.tsx b/packages/remix/test/integration/common/routes/action-json-response.$id.tsx index 2bcc44588a0c..1cd4d65df54e 100644 --- a/packages/remix/test/integration/common/routes/action-json-response.$id.tsx +++ b/packages/remix/test/integration/common/routes/action-json-response.$id.tsx @@ -2,7 +2,7 @@ import { ActionFunction, LoaderFunction, json, redirect } from '@remix-run/node' import { useActionData } from '@remix-run/react'; export const loader: LoaderFunction = async ({ params: { id } }) => { - if (id === '-1') { + if (id === '-100') { throw new Error('Unexpected Server Error'); } @@ -16,7 +16,7 @@ export const action: ActionFunction = async ({ params: { id } }) => { if (id === '-2') { // Note: This GET request triggers the `Loader` of the URL, not the `Action`. - throw redirect('/action-json-response/-1'); + throw redirect('/action-json-response/-100'); } if (id === '-3') { diff --git a/packages/remix/test/integration/common/routes/server-side-unexpected-errors.$id.tsx b/packages/remix/test/integration/common/routes/server-side-unexpected-errors.$id.tsx new file mode 100644 index 000000000000..a7d73e29a4ff --- /dev/null +++ b/packages/remix/test/integration/common/routes/server-side-unexpected-errors.$id.tsx @@ -0,0 +1,27 @@ +import { ActionFunction, LoaderFunction, json, redirect } from '@remix-run/node'; +import { useActionData } from '@remix-run/react'; + +export const action: ActionFunction = async ({ params: { id } }) => { + // Throw string + if (id === '-1') { + throw 'Thrown String Error'; + } + + // Throw object + if (id === '-2') { + throw { + message: 'Thrown Object Error', + statusCode: 500, + }; + } +}; + +export default function ActionJSONResponse() { + const data = useActionData(); + + return ( +
+

{data && data.test ? data.test : 'Not Found'}

+
+ ); +} diff --git a/packages/remix/test/integration/common/routes/ssr-error.tsx b/packages/remix/test/integration/common/routes/ssr-error.tsx new file mode 100644 index 000000000000..d171f73bcd9c --- /dev/null +++ b/packages/remix/test/integration/common/routes/ssr-error.tsx @@ -0,0 +1,7 @@ +export default function SSRError() { + const data = ['err'].map(err => { + throw new Error('Sentry SSR Test Error'); + }); + + return
{data}
; +} diff --git a/packages/remix/test/integration/test/client/ssr-error.test.ts b/packages/remix/test/integration/test/client/ssr-error.test.ts new file mode 100644 index 000000000000..45d62a62a51f --- /dev/null +++ b/packages/remix/test/integration/test/client/ssr-error.test.ts @@ -0,0 +1,8 @@ +import { expect, test } from '@playwright/test'; +import { countEnvelopes } from './utils/helpers'; + +test('should not report an SSR error on client side.', async ({ page }) => { + const count = await countEnvelopes(page, { url: '/ssr-error', envelopeType: 'event' }); + + expect(count).toBe(0); +}); diff --git a/packages/remix/test/integration/test/server/action.test.ts b/packages/remix/test/integration/test/server/action.test.ts index 7cc99ded78d3..c20ba38f5658 100644 --- a/packages/remix/test/integration/test/server/action.test.ts +++ b/packages/remix/test/integration/test/server/action.test.ts @@ -81,7 +81,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'remix.server' : 'action', + function: useV2 ? 'remix.server.handleError' : 'action', }, handled: false, type: 'instrument', @@ -140,7 +140,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada }); }); - it('handles a thrown 500 response', async () => { + it('handles an error-throwing redirection target', async () => { const env = await RemixTestEnv.init(adapter); const url = `${env.url}/action-json-response/-2`; @@ -201,7 +201,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'remix.server' : 'loader', + function: useV2 ? 'remix.server.handleError' : 'loader', }, handled: false, type: 'instrument', @@ -254,7 +254,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'ErrorResponse' : 'action', + function: 'action', }, handled: false, type: 'instrument', @@ -303,13 +303,11 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada values: [ { type: 'Error', - value: useV2 - ? 'Object captured as exception with keys: data, internal, status, statusText' - : 'Object captured as exception with keys: data', + value: 'Object captured as exception with keys: data', stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'ErrorResponse' : 'action', + function: 'action', }, handled: false, type: 'instrument', @@ -362,7 +360,7 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'ErrorResponse' : 'action', + function: 'action', }, handled: false, type: 'instrument', @@ -411,13 +409,117 @@ describe.each(['builtin', 'express'])('Remix API Actions with adapter = %s', ada values: [ { type: 'Error', - value: useV2 - ? 'Object captured as exception with keys: data, internal, status, statusText' - : 'Object captured as exception with keys: [object has no keys]', + value: 'Object captured as exception with keys: [object has no keys]', stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'ErrorResponse' : 'action', + function: 'action', + }, + handled: false, + type: 'instrument', + }, + }, + ], + }, + }); + }); + + it('handles thrown string (primitive) from an action', async () => { + const env = await RemixTestEnv.init(adapter); + const url = `${env.url}/server-side-unexpected-errors/-1`; + + const envelopes = await env.getMultipleEnvelopeRequest({ + url, + count: 2, + method: 'post', + envelopeType: ['event', 'transaction'], + }); + + const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction'); + const [event] = envelopes.filter(envelope => envelope[1].type === 'event'); + + assertSentryTransaction(transaction[2], { + contexts: { + trace: { + op: 'http.server', + status: 'internal_error', + tags: { + method: 'POST', + 'http.status_code': '500', + }, + data: { + 'http.response.status_code': 500, + }, + }, + }, + tags: { + transaction: `routes/server-side-unexpected-errors${useV2 ? '.' : '/'}$id`, + }, + }); + + assertSentryEvent(event[2], { + exception: { + values: [ + { + type: 'Error', + value: 'Thrown String Error', + stacktrace: expect.any(Object), + mechanism: { + data: { + function: useV2 ? 'remix.server.handleError' : 'action', + }, + handled: false, + type: 'instrument', + }, + }, + ], + }, + }); + }); + + it('handles thrown object from an action', async () => { + const env = await RemixTestEnv.init(adapter); + const url = `${env.url}/server-side-unexpected-errors/-2`; + + const envelopes = await env.getMultipleEnvelopeRequest({ + url, + count: 2, + method: 'post', + envelopeType: ['event', 'transaction'], + }); + + const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction'); + const [event] = envelopes.filter(envelope => envelope[1].type === 'event'); + + assertSentryTransaction(transaction[2], { + contexts: { + trace: { + op: 'http.server', + status: 'internal_error', + tags: { + method: 'POST', + 'http.status_code': '500', + }, + data: { + 'http.response.status_code': 500, + }, + }, + }, + tags: { + transaction: `routes/server-side-unexpected-errors${useV2 ? '.' : '/'}$id`, + }, + }); + + assertSentryEvent(event[2], { + exception: { + values: [ + { + type: 'Error', + value: 'Thrown Object Error', + stacktrace: expect.any(Object), + mechanism: { + data: { + function: useV2 ? 'remix.server.handleError' : 'action', }, handled: false, type: 'instrument', diff --git a/packages/remix/test/integration/test/server/loader.test.ts b/packages/remix/test/integration/test/server/loader.test.ts index c3f60dcb66ee..b6d4fc402ea7 100644 --- a/packages/remix/test/integration/test/server/loader.test.ts +++ b/packages/remix/test/integration/test/server/loader.test.ts @@ -39,7 +39,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'remix.server' : 'loader', + function: useV2 ? 'remix.server.handleError' : 'loader', }, handled: false, type: 'instrument', @@ -78,7 +78,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada }); }); - it('handles a thrown 500 response', async () => { + it('handles an error-throwing redirection target', async () => { const env = await RemixTestEnv.init(adapter); const url = `${env.url}/loader-json-response/-1`; @@ -138,7 +138,7 @@ describe.each(['builtin', 'express'])('Remix API Loaders with adapter = %s', ada stacktrace: expect.any(Object), mechanism: { data: { - function: useV2 ? 'remix.server' : 'loader', + function: useV2 ? 'remix.server.handleError' : 'loader', }, handled: false, type: 'instrument', diff --git a/packages/remix/test/integration/test/server/ssr.test.ts b/packages/remix/test/integration/test/server/ssr.test.ts new file mode 100644 index 000000000000..46c345cc5a73 --- /dev/null +++ b/packages/remix/test/integration/test/server/ssr.test.ts @@ -0,0 +1,46 @@ +import { RemixTestEnv, assertSentryEvent, assertSentryTransaction } from './utils/helpers'; + +const useV2 = process.env.REMIX_VERSION === '2'; + +describe('Server Side Rendering', () => { + it('correctly reports a server side rendering error', async () => { + const env = await RemixTestEnv.init('builtin'); + const url = `${env.url}/ssr-error`; + const envelopes = await env.getMultipleEnvelopeRequest({ url, count: 2, envelopeType: ['transaction', 'event'] }); + const [transaction] = envelopes.filter(envelope => envelope[1].type === 'transaction'); + const [event] = envelopes.filter(envelope => envelope[1].type === 'event'); + + assertSentryTransaction(transaction[2], { + contexts: { + trace: { + status: 'internal_error', + tags: { + 'http.status_code': '500', + }, + data: { + 'http.response.status_code': 500, + }, + }, + }, + }); + + assertSentryEvent(event[2], { + exception: { + values: [ + { + type: 'Error', + value: 'Sentry SSR Test Error', + stacktrace: expect.any(Object), + mechanism: { + data: { + function: useV2 ? 'remix.server.handleError' : 'documentRequest', + }, + handled: false, + type: 'instrument', + }, + }, + ], + }, + }); + }); +});