From 7edea79b6c9cc09b5a88486f72959b7281eef4a1 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:23:27 +0200 Subject: [PATCH 1/2] fix(v10/core): Apply capture mechanism to originally captured exception Backport of: #23320 --- .../aggregateError-custom/subject.js | 14 ++- .../aggregateError-custom/test.ts | 41 +++++++++ .../suites/d1/test.ts | 8 +- .../hono-4/tests/errors.test.ts | 8 +- .../nextjs-15-t3/tests/trpc-error.test.ts | 15 +++- .../tests/errors.server.test.ts | 22 +++-- .../nuxt-3-min/tests/errors.server.test.ts | 22 +++-- .../tests/errors.server.test.ts | 22 +++-- .../nuxt-3/tests/errors.server.test.ts | 18 ++-- .../nuxt-4/tests/errors.server.test.ts | 22 +++-- .../nuxt-5/tests/errors.server.test.ts | 22 +++-- .../react-19/tests/errors.test.ts | 12 +-- packages/core/src/utils/misc.ts | 29 ++++++- packages/core/src/utils/prepareEvent.ts | 4 +- packages/core/test/lib/client.test.ts | 56 ++++++++++++ .../test/lib/utils/aggregate-errors.test.ts | 85 +++++++++++++++++++ packages/core/test/lib/utils/misc.test.ts | 58 +++++++++++++ 17 files changed, 388 insertions(+), 70 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/public-api/captureException/aggregateError-custom/subject.js b/dev-packages/browser-integration-tests/suites/public-api/captureException/aggregateError-custom/subject.js index 5c666691cfeb..72d1507ddff3 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/captureException/aggregateError-custom/subject.js +++ b/dev-packages/browser-integration-tests/suites/public-api/captureException/aggregateError-custom/subject.js @@ -13,4 +13,16 @@ const aggregateError = new CustomAggregateError( }, ); -Sentry.captureException(aggregateError); +const shouldUseCustomMechanism = window.location.hash === '#custom-mechanism'; + +if (shouldUseCustomMechanism) { + const cause = new Error('Failure 1'); + const errorCause = new Error('Failure 2', { cause }); + const error = new Error('Failure 3', { cause: errorCause }); + + Sentry.captureException(error, { + mechanism: { handled: false, type: 'auto.http.example' }, + }); +} else { + Sentry.captureException(aggregateError); +} diff --git a/dev-packages/browser-integration-tests/suites/public-api/captureException/aggregateError-custom/test.ts b/dev-packages/browser-integration-tests/suites/public-api/captureException/aggregateError-custom/test.ts index 5ad930baa37d..fa8076266f23 100644 --- a/dev-packages/browser-integration-tests/suites/public-api/captureException/aggregateError-custom/test.ts +++ b/dev-packages/browser-integration-tests/suites/public-api/captureException/aggregateError-custom/test.ts @@ -38,3 +38,44 @@ sentryTest('captures custom AggregateErrors', async ({ getLocalTestUrl, page }) }), ]); }); + +sentryTest( + 'keeps a custom capture mechanism on the captured error instead of its causes', + async ({ getLocalTestUrl, page }) => { + const url = await getLocalTestUrl({ testDir: __dirname }); + const req = await waitForErrorRequestOnUrl(page, `${url}#custom-mechanism`); + const eventData = envelopeRequestParser(req); + + expect(eventData.exception?.values).toHaveLength(3); + expect(eventData.exception?.values).toEqual([ + expect.objectContaining({ + value: 'Failure 1', + mechanism: { + exception_id: 2, + handled: true, + parent_id: 1, + source: 'cause', + type: 'chained', + }, + }), + expect.objectContaining({ + value: 'Failure 2', + mechanism: { + exception_id: 1, + handled: true, + parent_id: 0, + source: 'cause', + type: 'chained', + }, + }), + expect.objectContaining({ + value: 'Failure 3', + mechanism: { + exception_id: 0, + handled: false, + type: 'auto.http.example', + }, + }), + ]); + }, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts index 98338d910b64..8548673c6be6 100644 --- a/dev-packages/cloudflare-integration-tests/suites/d1/test.ts +++ b/dev-packages/cloudflare-integration-tests/suites/d1/test.ts @@ -71,8 +71,8 @@ it('captures error event when a D1 query references a non-existent table', async value: 'no such table: non_existent_table: SQLITE_ERROR', stacktrace: expect.any(Object), mechanism: { - type: 'auto.http.cloudflare', - handled: false, + type: 'chained', + handled: true, source: 'cause', exception_id: 1, parent_id: 0, @@ -83,8 +83,8 @@ it('captures error event when a D1 query references a non-existent table', async value: 'D1_ERROR: no such table: non_existent_table: SQLITE_ERROR', stacktrace: expect.any(Object), mechanism: { - type: 'generic', - handled: true, + type: 'auto.http.cloudflare', + handled: false, exception_id: 0, }, }, diff --git a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts index ad4148fa35c2..29e6c5f57bac 100644 --- a/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/hono-4/tests/errors.test.ts @@ -52,10 +52,10 @@ test.describe('route handler errors', () => { expect(firstCause?.value).toBe('Failure 1'); expect(firstCause?.mechanism).toEqual({ exception_id: 2, - handled: false, + handled: true, parent_id: 1, source: 'cause', - type: 'auto.http.hono.context_error', // should be 'chained' (general issue with LinkedErrors) + type: 'chained', }); const secondCause = errorEvent.exception?.values?.[1]; @@ -72,8 +72,8 @@ test.describe('route handler errors', () => { expect(capturedError?.value).toBe('Failure 3'); expect(capturedError?.mechanism).toEqual({ exception_id: 0, - handled: true, - type: 'generic', // should be 'auto.http.hono.context_error' (general issue with LinkedErrors) + handled: false, + type: 'auto.http.hono.context_error', }); expect(errorEvent.transaction).toBe('GET /linked-error'); diff --git a/dev-packages/e2e-tests/test-applications/nextjs-15-t3/tests/trpc-error.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-15-t3/tests/trpc-error.test.ts index 5960c0f6af8f..0e2b2769df36 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-15-t3/tests/trpc-error.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-15-t3/tests/trpc-error.test.ts @@ -17,13 +17,22 @@ test('should capture error with trpc context', async ({ page }) => { expect(trpcError.contexts?.trpc?.procedure_path).toBe('post.throwError'); expect(trpcError.contexts?.trpc?.input).toEqual({ name: 'I love dogs' }); - expect(trpcError.exception?.values?.[0]?.mechanism).toEqual({ - handled: false, - type: 'auto.rpc.trpc.middleware', + const exceptionValues = trpcError.exception?.values; + expect(exceptionValues).toHaveLength(2); + expect(exceptionValues?.[0]?.type).toBe('Error'); + expect(exceptionValues?.[0]?.value).toBe('Error thrown in trpc router'); + expect(exceptionValues?.[0]?.mechanism).toEqual({ + handled: true, + type: 'chained', exception_id: 1, parent_id: 0, source: 'cause', }); + expect(exceptionValues?.[1]?.mechanism).toEqual({ + handled: false, + type: 'auto.rpc.trpc.middleware', + exception_id: 0, + }); }); test('should create transaction with trpc input for error', async ({ page }) => { diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts index 53d89e5b937f..90771e4174ad 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-dynamic-import/tests/errors.server.test.ts @@ -20,8 +20,8 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 3 Server error'); expect(exception0.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', + handled: true, + type: 'chained', exception_id: 1, parent_id: 0, source: 'cause', @@ -29,8 +29,11 @@ test.describe('server-side errors', async () => { expect(exception1.type).toEqual('Error'); expect(exception1.value).toEqual('Nuxt 3 Server error'); - // TODO: This isn't correct but requires adjustment in the core SDK - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); }); test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { @@ -51,8 +54,8 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 3 Param Server error'); expect(exception0.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', + handled: true, + type: 'chained', exception_id: 1, parent_id: 0, source: 'cause', @@ -60,7 +63,10 @@ test.describe('server-side errors', async () => { expect(exception1.type).toEqual('Error'); expect(exception1.value).toEqual('Nuxt 3 Param Server error'); - // TODO: This isn't correct but requires adjustment in the core SDK - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts index 8b182fec69c7..e108a25ea9b1 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-min/tests/errors.server.test.ts @@ -20,17 +20,20 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 3 Server error'); expect(exception0.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', + handled: true, + type: 'chained', exception_id: 1, parent_id: 0, source: 'cause', }); - // TODO: This isn't correct but requires adjustment in the core SDK expect(exception1.type).toEqual('Error'); expect(exception1.value).toEqual('Nuxt 3 Server error'); - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); }); test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { @@ -51,16 +54,19 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 3 Param Server error'); expect(exception0.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', + handled: true, + type: 'chained', exception_id: 1, parent_id: 0, source: 'cause', }); - // TODO: This isn't correct but requires adjustment in the core SDK expect(exception1.type).toEqual('Error'); expect(exception1.value).toEqual('Nuxt 3 Param Server error'); - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts index d7aafde49d55..b6558f7c87ee 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3-top-level-import/tests/errors.server.test.ts @@ -25,8 +25,8 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 3 Server error'); expect(exception0.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', + handled: true, + type: 'chained', exception_id: 1, parent_id: 0, source: 'cause', @@ -34,8 +34,11 @@ test.describe('server-side errors', async () => { expect(exception1.type).toEqual('Error'); expect(exception1.value).toEqual('Nuxt 3 Server error'); - // TODO: This isn't correct but requires adjustment in the core SDK - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); expect(error.tags?.['my-isolated-tag']).toBe(true); expect(error.tags?.['my-global-scope-isolated-tag']).not.toBeDefined(); @@ -82,8 +85,8 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 3 Param Server error'); expect(exception0.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', + handled: true, + type: 'chained', exception_id: 1, parent_id: 0, source: 'cause', @@ -91,7 +94,10 @@ test.describe('server-side errors', async () => { expect(exception1.type).toEqual('Error'); expect(exception1.value).toEqual('Nuxt 3 Param Server error'); - // TODO: This isn't correct but requires adjustment in the core SDK - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts index 338ee6e3f78a..a360209ec1ce 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-3/tests/errors.server.test.ts @@ -20,7 +20,7 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 3 Server error'); expect(exception0.mechanism).toEqual({ - handled: false, + handled: true, type: 'chained', exception_id: 1, parent_id: 0, @@ -29,8 +29,11 @@ test.describe('server-side errors', async () => { expect(exception1.type).toEqual('Error'); expect(exception1.value).toEqual('Nuxt 3 Server error'); - // TODO: This isn't correct but requires adjustment in the core SDK - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'generic', + exception_id: 0, + }); }); test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { @@ -51,7 +54,7 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 3 Param Server error'); expect(exception0.mechanism).toEqual({ - handled: false, + handled: true, type: 'chained', exception_id: 1, parent_id: 0, @@ -60,7 +63,10 @@ test.describe('server-side errors', async () => { expect(exception1.type).toEqual('Error'); expect(exception1.value).toEqual('Nuxt 3 Param Server error'); - // TODO: This isn't correct but requires adjustment in the core SDK - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'generic', + exception_id: 0, + }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts index e0873c1f496e..8f7bf451a1f6 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-4/tests/errors.server.test.ts @@ -20,8 +20,8 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 4 Server error'); expect(exception0.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', + handled: true, + type: 'chained', exception_id: 1, parent_id: 0, source: 'cause', @@ -29,8 +29,11 @@ test.describe('server-side errors', async () => { expect(exception1.type).toEqual('Error'); expect(exception1.value).toEqual('Nuxt 4 Server error'); - // TODO: This isn't correct but requires adjustment in the core SDK - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); }); test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { @@ -51,8 +54,8 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 4 Param Server error'); expect(exception0.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', + handled: true, + type: 'chained', exception_id: 1, parent_id: 0, source: 'cause', @@ -60,7 +63,10 @@ test.describe('server-side errors', async () => { expect(exception1.type).toEqual('Error'); expect(exception1.value).toEqual('Nuxt 4 Param Server error'); - // TODO: This isn't correct but requires adjustment in the core SDK - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts index 163dfd28c80a..fe17f262b0ae 100644 --- a/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts +++ b/dev-packages/e2e-tests/test-applications/nuxt-5/tests/errors.server.test.ts @@ -20,8 +20,8 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 4 Server error'); expect(exception0.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', + handled: true, + type: 'chained', exception_id: 1, parent_id: 0, source: 'cause', @@ -29,8 +29,11 @@ test.describe('server-side errors', async () => { expect(exception1.type).toEqual('HTTPError'); expect(exception1.value).toEqual('Nuxt 4 Server error'); - // TODO: This isn't correct but requires adjustment in the core SDK - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); }); test('captures api fetch error (fetched on click) with parametrized route', async ({ page }) => { @@ -51,8 +54,8 @@ test.describe('server-side errors', async () => { expect(exception0.type).toEqual('Error'); expect(exception0.value).toEqual('Nuxt 4 Param Server error'); expect(exception0.mechanism).toEqual({ - handled: false, - type: 'auto.function.nuxt.nitro', + handled: true, + type: 'chained', exception_id: 1, parent_id: 0, source: 'cause', @@ -60,7 +63,10 @@ test.describe('server-side errors', async () => { expect(exception1.type).toEqual('HTTPError'); expect(exception1.value).toEqual('Nuxt 4 Param Server error'); - // TODO: This isn't correct but requires adjustment in the core SDK - expect(exception1.mechanism).toEqual({ handled: true, type: 'generic', exception_id: 0 }); + expect(exception1.mechanism).toEqual({ + handled: false, + type: 'auto.function.nuxt.nitro', + exception_id: 0, + }); }); }); diff --git a/dev-packages/e2e-tests/test-applications/react-19/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-19/tests/errors.test.ts index 496c47e417d2..1750611761b0 100644 --- a/dev-packages/e2e-tests/test-applications/react-19/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-19/tests/errors.test.ts @@ -20,8 +20,8 @@ test('Catches errors caught by error boundary', async ({ page }) => { expect(errorEvent.exception?.values).toHaveLength(2); expect(errorEvent.exception?.values?.[0]?.value).toBe('caught error'); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ - type: 'auto.function.react.error_handler', - handled: true, // true because a callback was provided + type: 'chained', + handled: true, exception_id: 1, parent_id: 0, source: 'cause', @@ -29,7 +29,7 @@ test('Catches errors caught by error boundary', async ({ page }) => { expect(errorEvent.exception?.values?.[1]?.value).toBe('caught error'); expect(errorEvent.exception?.values?.[1]?.mechanism).toEqual({ - type: 'generic', + type: 'auto.function.react.error_handler', handled: true, // true because a callback was provided exception_id: 0, }); @@ -54,8 +54,8 @@ test('Catches errors uncaught by error boundary', async ({ page }) => { expect(errorEvent.exception?.values).toHaveLength(2); expect(errorEvent.exception?.values?.[0]?.value).toBe('uncaught error'); expect(errorEvent.exception?.values?.[0]?.mechanism).toEqual({ - type: 'auto.function.react.error_handler', - handled: true, // true because a callback was provided + type: 'chained', + handled: true, exception_id: 1, parent_id: 0, source: 'cause', @@ -63,7 +63,7 @@ test('Catches errors uncaught by error boundary', async ({ page }) => { expect(errorEvent.exception?.values?.[1]?.value).toBe('uncaught error'); expect(errorEvent.exception?.values?.[1]?.mechanism).toEqual({ - type: 'generic', + type: 'auto.function.react.error_handler', handled: true, // true because a callback was provided exception_id: 0, }); diff --git a/packages/core/src/utils/misc.ts b/packages/core/src/utils/misc.ts index 818f2cd9e0e8..445fe1b12e10 100644 --- a/packages/core/src/utils/misc.ts +++ b/packages/core/src/utils/misc.ts @@ -112,13 +112,34 @@ export function addExceptionMechanism(event: Event, newMechanism?: Partial): void { + const exceptions = event.exception?.values; + + // Exception groups identify the originally captured (root) exception with exception_id 0. + const capturedException = exceptions?.find(exception => exception.mechanism?.exception_id === 0) ?? exceptions?.[0]; + + if (!capturedException) { + return; + } + + applyExceptionMechanism(capturedException, newMechanism); +} + +function applyExceptionMechanism(exception: Exception, newMechanism?: Partial): void { const defaultMechanism = { type: 'generic', handled: true }; - const currentMechanism = firstException.mechanism; - firstException.mechanism = { ...defaultMechanism, ...currentMechanism, ...newMechanism }; + const currentMechanism = exception.mechanism; + exception.mechanism = { ...defaultMechanism, ...currentMechanism, ...newMechanism }; if (newMechanism && 'data' in newMechanism) { - const mergedData = { ...currentMechanism?.data, ...newMechanism.data }; - firstException.mechanism.data = mergedData; + exception.mechanism.data = { ...currentMechanism?.data, ...newMechanism.data }; } } diff --git a/packages/core/src/utils/prepareEvent.ts b/packages/core/src/utils/prepareEvent.ts index 78c5b336daac..a7dbdae9e08d 100644 --- a/packages/core/src/utils/prepareEvent.ts +++ b/packages/core/src/utils/prepareEvent.ts @@ -8,7 +8,7 @@ import type { ClientOptions } from '../types/options'; import type { StackParser } from '../types/stacktrace'; import { getFilenameToDebugIdMap } from './debug-ids'; import { isInternalException } from './eventUtils'; -import { addExceptionMechanism, uuid4 } from './misc'; +import { addExceptionMechanismToCapturedException, uuid4 } from './misc'; import { normalize } from './normalize'; import { applyScopeDataToEvent, getCombinedScopeData } from './scopeData'; import { truncate } from './string'; @@ -72,7 +72,7 @@ export function prepareEvent( const finalScope = getFinalScope(scope, hint.captureContext); if (hint.mechanism) { - addExceptionMechanism(prepared, hint.mechanism); + addExceptionMechanismToCapturedException(prepared, hint.mechanism); } const clientEventProcessors = client ? client.getEventProcessors() : []; diff --git a/packages/core/test/lib/client.test.ts b/packages/core/test/lib/client.test.ts index 35cb6a24311d..ac2b07761c79 100644 --- a/packages/core/test/lib/client.test.ts +++ b/packages/core/test/lib/client.test.ts @@ -7,6 +7,7 @@ import { getGlobalScope, getIsolationScope, lastEventId, + linkedErrorsIntegration, makeSession, Scope, setCurrentClient, @@ -253,6 +254,61 @@ describe('Client', () => { ); }); + test('keeps a custom mechanism on the captured error when linked errors are prepended', () => { + const options = getDefaultTestClientOptions({ + dsn: PUBLIC_DSN, + integrations: [linkedErrorsIntegration()], + }); + const client = new TestClient(options); + client.init(); + const session = makeSession(); + getCurrentScope().setSession(session); + + const cause = new Error('Failure 1'); + const errorCause = Object.assign(new Error('Failure 2'), { cause }); + const error = Object.assign(new Error('Failure 3'), { cause: errorCause }); + + client.captureException(error, { + originalException: error, + mechanism: { type: 'auto.http.example', handled: false }, + }); + + expect(client.event?.exception?.values).toEqual([ + expect.objectContaining({ + type: 'Error', + value: 'Failure 1', + mechanism: { + exception_id: 2, + handled: true, + parent_id: 1, + source: 'cause', + type: 'chained', + }, + }), + expect.objectContaining({ + type: 'Error', + value: 'Failure 2', + mechanism: { + exception_id: 1, + handled: true, + parent_id: 0, + source: 'cause', + type: 'chained', + }, + }), + expect.objectContaining({ + type: 'Error', + value: 'Failure 3', + mechanism: { + exception_id: 0, + handled: false, + type: 'auto.http.example', + }, + }), + ]); + expect(client.session?.status).toBe('crashed'); + }); + test('does not truncate exception values by default', () => { const exceptionMessageLength = 10_000; const options = getDefaultTestClientOptions({ dsn: PUBLIC_DSN }); diff --git a/packages/core/test/lib/utils/aggregate-errors.test.ts b/packages/core/test/lib/utils/aggregate-errors.test.ts index 3d51bffe520d..86f796d2d8e0 100644 --- a/packages/core/test/lib/utils/aggregate-errors.test.ts +++ b/packages/core/test/lib/utils/aggregate-errors.test.ts @@ -5,6 +5,7 @@ import type { Event, EventHint } from '../../../src/types/event'; import type { Exception } from '../../../src/types/exception'; import type { StackParser } from '../../../src/types/stacktrace'; import { applyAggregateErrorsToEvent } from '../../../src/utils/aggregate-errors'; +import { addExceptionMechanismToCapturedException } from '../../../src/utils/misc'; import { createStackParser } from '../../../src/utils/stacktrace'; const stackParser = createStackParser([0, line => ({ filename: line })]); @@ -116,6 +117,90 @@ describe('applyAggregateErrorsToEvent()', () => { }); }); + test('keeps a capture mechanism on the captured error instead of its causes', () => { + const cause = new Error('Failure 1'); + const errorCause = Object.assign(new Error('Failure 2'), { cause }); + const error = Object.assign(new Error('Failure 3'), { cause: errorCause }); + const event: Event = { exception: { values: [exceptionFromError(stackParser, error)] } }; + const eventHint: EventHint = { + originalException: error, + mechanism: { handled: false, type: 'auto.http.example' }, + }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, eventHint); + addExceptionMechanismToCapturedException(event, eventHint.mechanism); + + expect(event.exception?.values).toStrictEqual([ + { + type: 'Error', + value: 'Failure 1', + mechanism: { + exception_id: 2, + handled: true, + parent_id: 1, + source: 'cause', + type: 'chained', + }, + }, + { + type: 'Error', + value: 'Failure 2', + mechanism: { + exception_id: 1, + handled: true, + parent_id: 0, + source: 'cause', + type: 'chained', + }, + }, + { + type: 'Error', + value: 'Failure 3', + mechanism: { + exception_id: 0, + handled: false, + type: 'auto.http.example', + }, + }, + ]); + }); + + test('keeps exception group metadata when applying a capture mechanism to an AggregateError', () => { + const error = new FakeAggregateError([new Error('Child Error')], 'Aggregate Error'); + const event: Event = { exception: { values: [exceptionFromError(stackParser, error)] } }; + const eventHint: EventHint = { + originalException: error, + mechanism: { handled: false, type: 'auto.http.example' }, + }; + + applyAggregateErrorsToEvent(exceptionFromError, stackParser, 'cause', 100, event, eventHint); + addExceptionMechanismToCapturedException(event, eventHint.mechanism); + + expect(event.exception?.values).toStrictEqual([ + { + type: 'Error', + value: 'Child Error', + mechanism: { + exception_id: 1, + handled: true, + parent_id: 0, + source: 'errors[0]', + type: 'chained', + }, + }, + { + type: 'AggregateError', + value: 'Aggregate Error', + mechanism: { + exception_id: 0, + handled: false, + is_exception_group: true, + type: 'auto.http.example', + }, + }, + ]); + }); + test('recursively walks errors created in another realm', () => { const originalException = runInNewContext( `new AggregateError([new Error('Aggregate child')], 'Root Error', { cause: new Error('Cause') })`, diff --git a/packages/core/test/lib/utils/misc.test.ts b/packages/core/test/lib/utils/misc.test.ts index 62b37e8a520a..9099e4d2a1da 100644 --- a/packages/core/test/lib/utils/misc.test.ts +++ b/packages/core/test/lib/utils/misc.test.ts @@ -5,6 +5,7 @@ import type { StackFrame } from '../../../src/types/stackframe'; import { addContextToFrame, addExceptionMechanism, + addExceptionMechanismToCapturedException, checkOrSetAlreadyCaught, getEventDescription, uuid4, @@ -243,6 +244,63 @@ describe('addExceptionMechanism', () => { expect(event.exception.values[0].mechanism).toEqual({ type: 'instrument', handled: true, synthetic: true }); }); + it('adds the mechanism to the captured exception in an exception group', () => { + const event: Event = { + exception: { + values: [ + { + value: 'Cause', + mechanism: { type: 'chained', handled: true, exception_id: 1, parent_id: 0 }, + }, + { + value: 'Captured Error', + mechanism: { type: 'generic', handled: true, exception_id: 0, data: { source: 'existing' } }, + }, + ], + }, + }; + + addExceptionMechanismToCapturedException(event, { + type: 'auto.test.capture', + handled: false, + data: { handler: 'request' }, + }); + + expect(event.exception?.values).toEqual([ + { + value: 'Cause', + mechanism: { type: 'chained', handled: true, exception_id: 1, parent_id: 0 }, + }, + { + value: 'Captured Error', + mechanism: { + type: 'auto.test.capture', + handled: false, + exception_id: 0, + data: { source: 'existing', handler: 'request' }, + }, + }, + ]); + }); + + it('falls back to the first exception without exception group metadata', () => { + const event: Event = { + exception: { + values: [ + { value: 'First', mechanism: { type: 'generic', handled: true } }, + { value: 'Second', mechanism: { type: 'generic', handled: true } }, + ], + }, + }; + + addExceptionMechanismToCapturedException(event, { type: 'auto.test.capture', handled: false }); + + expect(event.exception?.values).toEqual([ + { value: 'First', mechanism: { type: 'auto.test.capture', handled: false } }, + { value: 'Second', mechanism: { type: 'generic', handled: true } }, + ]); + }); + it('merges data values', () => { const event = { ...baseEvent }; From 6356761a204e94de7cb57c14f21b6d95a662212a Mon Sep 17 00:00:00 2001 From: Andrei Borza Date: Wed, 4 Nov 2026 17:38:25 +0100 Subject: [PATCH 2/2] Update nitro-3 error test for chained capture mechanism --- .../test-applications/nitro-3/tests/errors.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts index 5939fbca6f95..87e2274a9ec4 100644 --- a/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts +++ b/dev-packages/e2e-tests/test-applications/nitro-3/tests/errors.test.ts @@ -19,13 +19,17 @@ test('Sends an error event to Sentry', async ({ request }) => { type: 'Error', value: 'This is a test error', mechanism: expect.objectContaining({ - handled: false, - type: 'auto.function.nitro.captureErrorHook', + handled: true, + type: 'chained', }), }), expect.objectContaining({ type: 'HTTPError', value: 'This is a test error', + mechanism: expect.objectContaining({ + handled: false, + type: 'auto.function.nitro.captureErrorHook', + }), }), ]); });