Skip to content

Commit 0f1c5c8

Browse files
logaretmclaude
andcommitted
fix(node-core): Pass rejection reason instead of Promise as originalException
In the onUnhandledRejection handler, captureException was being called with originalException set to the rejected Promise object instead of the rejection reason. This meant hint.originalException in beforeSend and downstream integrations (localVariablesAsync, extraErrorData, zoderrors) received a Promise rather than the actual error, breaking any logic that inspects it. This aligns Node with the browser SDK and the onUncaughtException handler. Fixes #20325 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0ffc262 commit 0f1c5c8

File tree

2 files changed

+56
-2
lines changed

2 files changed

+56
-2
lines changed

packages/node-core/src/integrations/onunhandledrejection.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export function makeUnhandledPromiseHandler(
8585
client: Client,
8686
options: OnUnhandledRejectionOptions,
8787
): (reason: unknown, promise: unknown) => void {
88-
return function sendUnhandledPromise(reason: unknown, promise: unknown): void {
88+
return function sendUnhandledPromise(reason: unknown, _promise: unknown): void {
8989
// Only handle for the active client
9090
if (getClient() !== client) {
9191
return;
@@ -109,7 +109,7 @@ export function makeUnhandledPromiseHandler(
109109

110110
activeSpanWrapper(() => {
111111
captureException(reason, {
112-
originalException: promise,
112+
originalException: reason,
113113
captureContext: {
114114
extra: { unhandledPromiseRejection: true },
115115
level,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as SentryCore from '@sentry/core';
2+
import type { Client } from '@sentry/core';
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
4+
import {
5+
makeUnhandledPromiseHandler,
6+
onUnhandledRejectionIntegration,
7+
} from '../../src/integrations/onunhandledrejection';
8+
9+
// don't log the test errors we're going to throw, so at a quick glance it doesn't look like the test itself has failed
10+
global.console.warn = () => null;
11+
global.console.error = () => null;
12+
13+
describe('unhandled promises', () => {
14+
afterEach(() => {
15+
vi.restoreAllMocks();
16+
});
17+
18+
it('installs a global listener', () => {
19+
const client = { getOptions: () => ({}) } as unknown as Client;
20+
SentryCore.setCurrentClient(client);
21+
22+
const beforeListeners = process.listeners('unhandledRejection').length;
23+
24+
const integration = onUnhandledRejectionIntegration();
25+
integration.setup!(client);
26+
27+
expect(process.listeners('unhandledRejection').length).toBe(beforeListeners + 1);
28+
});
29+
30+
it('passes the rejection reason (not the promise) as originalException', () => {
31+
const client = { getOptions: () => ({}) } as unknown as Client;
32+
SentryCore.setCurrentClient(client);
33+
34+
const reason = new Error('boom');
35+
const promise = Promise.reject(reason);
36+
// swallow the rejection so it does not leak into the test runner
37+
promise.catch(() => {});
38+
39+
const captureException = vi.spyOn(SentryCore, 'captureException').mockImplementation(() => 'test');
40+
41+
const handler = makeUnhandledPromiseHandler(client, { mode: 'warn', ignore: [] });
42+
handler(reason, promise);
43+
44+
expect(captureException).toHaveBeenCalledTimes(1);
45+
const [capturedReason, hint] = captureException.mock.calls[0]!;
46+
expect(capturedReason).toBe(reason);
47+
expect(hint?.originalException).toBe(reason);
48+
expect(hint?.originalException).not.toBe(promise);
49+
expect(hint?.mechanism).toEqual({
50+
handled: false,
51+
type: 'auto.node.onunhandledrejection',
52+
});
53+
});
54+
});

0 commit comments

Comments
 (0)