Skip to content

Commit 3a1f7a1

Browse files
authored
fix(core): Prevent exception recapturing (#4067)
When we instrument code to catch exceptions, once we've recorded the error we rethrow it, so that whatever would have happened without us still happens. Sometimes, that means that we'll see the same exception (as in, literally the same instance of `Error`) again, because it will hit some other code we've instrumented. The clearest example of this is us catching an exception in, say, an event handler, recording it, rethrowing it, and it then hitting our wrapped global `onerror`. We already handle that particular case, but the nextjs SDK has introduced other places where this happens, which our current solution doesn't handle. (Specifically, because we instrument the code in different (but overlapping) ways to compensate for differences between Vercel and non-Vercel environments, in non-Vercel environments we can end up seeing errors get caught twice.) This prevents us from capturing the same error twice by flagging the object as seen and bailing if we encounter an error with the flag set. Since people do all sorts of crazy things, and throw primitives (especially strings), it also accounts for that case, by wrapping any primitive it encounters with its corresponding wrapper class. Finally, it takes advantage of this change in `@sentry/nextjs`'s `withSentry` wrapper, to prevent the cases mentioned above. (Note: This is a different case than the `Dedupe` integration is meant to handle, which is two copies of the same error in a row. In this case, though the thing which gets thrown is the same the first and second times we see it, the stacktrace to get to it being captured is different, since different instrumentation is involved each time.)
1 parent f3ab44b commit 3a1f7a1

File tree

7 files changed

+259
-6
lines changed

7 files changed

+259
-6
lines changed

packages/core/src/baseclient.ts

+15
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Transport,
1414
} from '@sentry/types';
1515
import {
16+
checkOrSetAlreadyCaught,
1617
dateTimestampInSeconds,
1718
Dsn,
1819
isPlainObject,
@@ -29,6 +30,8 @@ import {
2930
import { Backend, BackendClass } from './basebackend';
3031
import { IntegrationIndex, setupIntegrations } from './integration';
3132

33+
const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
34+
3235
/**
3336
* Base implementation for all JavaScript SDK clients.
3437
*
@@ -101,6 +104,12 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
101104
*/
102105
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
103106
public captureException(exception: any, hint?: EventHint, scope?: Scope): string | undefined {
107+
// ensure we haven't captured this very object before
108+
if (checkOrSetAlreadyCaught(exception)) {
109+
logger.log(ALREADY_SEEN_ERROR);
110+
return;
111+
}
112+
104113
let eventId: string | undefined = hint && hint.event_id;
105114

106115
this._process(
@@ -140,6 +149,12 @@ export abstract class BaseClient<B extends Backend, O extends Options> implement
140149
* @inheritDoc
141150
*/
142151
public captureEvent(event: Event, hint?: EventHint, scope?: Scope): string | undefined {
152+
// ensure we haven't captured this very object before
153+
if (hint?.originalException && checkOrSetAlreadyCaught(hint.originalException)) {
154+
logger.log(ALREADY_SEEN_ERROR);
155+
return;
156+
}
157+
143158
let eventId: string | undefined = hint && hint.event_id;
144159

145160
this._process(

packages/core/test/lib/base.test.ts

+58-2
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ const PUBLIC_DSN = 'https://username@domain/123';
1212
// eslint-disable-next-line no-var
1313
declare var global: any;
1414

15+
const backendEventFromException = jest.spyOn(TestBackend.prototype, 'eventFromException');
16+
const clientProcess = jest.spyOn(TestClient.prototype as any, '_process');
17+
1518
jest.mock('@sentry/utils', () => {
1619
const original = jest.requireActual('@sentry/utils');
1720
return {
@@ -57,7 +60,7 @@ describe('BaseClient', () => {
5760
});
5861

5962
afterEach(() => {
60-
jest.restoreAllMocks();
63+
jest.clearAllMocks();
6164
});
6265

6366
describe('constructor() / getDsn()', () => {
@@ -249,6 +252,30 @@ describe('BaseClient', () => {
249252
}),
250253
);
251254
});
255+
256+
test.each([
257+
['`Error` instance', new Error('Will I get caught twice?')],
258+
['plain object', { 'Will I': 'get caught twice?' }],
259+
['primitive wrapper', new String('Will I get caught twice?')],
260+
// Primitives aren't tested directly here because they need to be wrapped with `objectify` *before* being passed
261+
// to `captureException` (which is how we'd end up with a primitive wrapper as tested above) in order for the
262+
// already-seen check to work . Any primitive which is passed without being wrapped will be captured each time it
263+
// is encountered, so this test doesn't apply.
264+
])("doesn't capture the same exception twice - %s", (_name: string, thrown: any) => {
265+
const client = new TestClient({ dsn: PUBLIC_DSN });
266+
267+
expect(thrown.__sentry_captured__).toBeUndefined();
268+
269+
client.captureException(thrown);
270+
271+
expect(thrown.__sentry_captured__).toBe(true);
272+
expect(backendEventFromException).toHaveBeenCalledTimes(1);
273+
274+
client.captureException(thrown);
275+
276+
// `captureException` should bail right away this second time around and not get as far as calling this again
277+
expect(backendEventFromException).toHaveBeenCalledTimes(1);
278+
});
252279
});
253280

254281
describe('captureMessage', () => {
@@ -325,6 +352,35 @@ describe('BaseClient', () => {
325352
expect(TestBackend.instance!.event).toBeUndefined();
326353
});
327354

355+
test.each([
356+
['`Error` instance', new Error('Will I get caught twice?')],
357+
['plain object', { 'Will I': 'get caught twice?' }],
358+
['primitive wrapper', new String('Will I get caught twice?')],
359+
// Primitives aren't tested directly here because they need to be wrapped with `objectify` *before* being passed
360+
// to `captureEvent` (which is how we'd end up with a primitive wrapper as tested above) in order for the
361+
// already-seen check to work . Any primitive which is passed without being wrapped will be captured each time it
362+
// is encountered, so this test doesn't apply.
363+
])("doesn't capture an event wrapping the same exception twice - %s", (_name: string, thrown: any) => {
364+
// Note: this is the same test as in `describe(captureException)`, except with the exception already wrapped in a
365+
// hint and accompanying an event. Duplicated here because some methods skip `captureException` and go straight to
366+
// `captureEvent`.
367+
const client = new TestClient({ dsn: PUBLIC_DSN });
368+
const event = { exception: { values: [{ type: 'Error', message: 'Will I get caught twice?' }] } };
369+
const hint = { originalException: thrown };
370+
371+
expect(thrown.__sentry_captured__).toBeUndefined();
372+
373+
client.captureEvent(event, hint);
374+
375+
expect(thrown.__sentry_captured__).toBe(true);
376+
expect(clientProcess).toHaveBeenCalledTimes(1);
377+
378+
client.captureEvent(event, hint);
379+
380+
// `captureEvent` should bail right away this second time around and not get as far as calling this again
381+
expect(clientProcess).toHaveBeenCalledTimes(1);
382+
});
383+
328384
test('sends an event', () => {
329385
expect.assertions(2);
330386
const client = new TestClient({ dsn: PUBLIC_DSN });
@@ -798,7 +854,7 @@ describe('BaseClient', () => {
798854
expect(TestBackend.instance!.event).toBeUndefined();
799855
});
800856

801-
test('calls beforeSend gets an access to a hint as a second argument', () => {
857+
test('beforeSend gets access to a hint as a second argument', () => {
802858
expect.assertions(2);
803859
const beforeSend = jest.fn((event, hint) => ({ ...event, data: hint.data }));
804860
const client = new TestClient({ dsn: PUBLIC_DSN, beforeSend });

packages/nextjs/src/utils/withSentry.ts

+14-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { captureException, flush, getCurrentHub, Handlers, startTransaction } from '@sentry/node';
22
import { extractTraceparentData, hasTracingEnabled } from '@sentry/tracing';
33
import { Transaction } from '@sentry/types';
4-
import { addExceptionMechanism, isString, logger, stripUrlQueryAndFragment } from '@sentry/utils';
4+
import { addExceptionMechanism, isString, logger, objectify, stripUrlQueryAndFragment } from '@sentry/utils';
55
import * as domain from 'domain';
66
import { NextApiHandler, NextApiResponse } from 'next';
77

@@ -76,6 +76,12 @@ export const withSentry = (origHandler: NextApiHandler): WrappedNextApiHandler =
7676
try {
7777
return await origHandler(req, res);
7878
} catch (e) {
79+
// In case we have a primitive, wrap it in the equivalent wrapper class (string -> String, etc.) so that we can
80+
// store a seen flag on it. (Because of the one-way-on-Vercel-one-way-off-of-Vercel approach we've been forced
81+
// to take, it can happen that the same thrown object gets caught in two different ways, and flagging it is a
82+
// way to prevent it from actually being reported twice.)
83+
const objectifiedErr = objectify(e);
84+
7985
if (currentScope) {
8086
currentScope.addEventProcessor(event => {
8187
addExceptionMechanism(event, {
@@ -88,9 +94,14 @@ export const withSentry = (origHandler: NextApiHandler): WrappedNextApiHandler =
8894
});
8995
return event;
9096
});
91-
captureException(e);
97+
98+
captureException(objectifiedErr);
9299
}
93-
throw e;
100+
101+
// We rethrow here so that nextjs can do with the error whatever it would normally do. (Sometimes "whatever it
102+
// would normally do" is to allow the error to bubble up to the global handlers - another reason we need to mark
103+
// the error as already having been captured.)
104+
throw objectifiedErr;
94105
}
95106
});
96107

packages/utils/src/misc.ts

+40
Original file line numberDiff line numberDiff line change
@@ -237,3 +237,43 @@ export function stripUrlQueryAndFragment(urlPath: string): string {
237237
// eslint-disable-next-line no-useless-escape
238238
return urlPath.split(/[\?#]/, 1)[0];
239239
}
240+
241+
/**
242+
* Checks whether or not we've already captured the given exception (note: not an identical exception - the very object
243+
* in question), and marks it captured if not.
244+
*
245+
* This is useful because it's possible for an error to get captured by more than one mechanism. After we intercept and
246+
* record an error, we rethrow it (assuming we've intercepted it before it's reached the top-level global handlers), so
247+
* that we don't interfere with whatever effects the error might have had were the SDK not there. At that point, because
248+
* the error has been rethrown, it's possible for it to bubble up to some other code we've instrumented. If it's not
249+
* caught after that, it will bubble all the way up to the global handlers (which of course we also instrument). This
250+
* function helps us ensure that even if we encounter the same error more than once, we only record it the first time we
251+
* see it.
252+
*
253+
* Note: It will ignore primitives (always return `false` and not mark them as seen), as properties can't be set on
254+
* them. {@link: Object.objectify} can be used on exceptions to convert any that are primitives into their equivalent
255+
* object wrapper forms so that this check will always work. However, because we need to flag the exact object which
256+
* will get rethrown, and because that rethrowing happens outside of the event processing pipeline, the objectification
257+
* must be done before the exception captured.
258+
*
259+
* @param A thrown exception to check or flag as having been seen
260+
* @returns `true` if the exception has already been captured, `false` if not (with the side effect of marking it seen)
261+
*/
262+
export function checkOrSetAlreadyCaught(exception: unknown): boolean {
263+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
264+
if ((exception as any)?.__sentry_captured__) {
265+
return true;
266+
}
267+
268+
try {
269+
// set it this way rather than by assignment so that it's not ennumerable and therefore isn't recorded by the
270+
// `ExtraErrorData` integration
271+
Object.defineProperty(exception, '__sentry_captured__', {
272+
value: true,
273+
});
274+
} catch (err) {
275+
// `exception` is a primitive, so we can't mark it seen
276+
}
277+
278+
return false;
279+
}

packages/utils/src/object.ts

+37
Original file line numberDiff line numberDiff line change
@@ -398,3 +398,40 @@ export function dropUndefinedKeys<T>(val: T): T {
398398

399399
return val;
400400
}
401+
402+
/**
403+
* Ensure that something is an object.
404+
*
405+
* Turns `undefined` and `null` into `String`s and all other primitives into instances of their respective wrapper
406+
* classes (String, Boolean, Number, etc.). Acts as the identity function on non-primitives.
407+
*
408+
* @param wat The subject of the objectification
409+
* @returns A version of `wat` which can safely be used with `Object` class methods
410+
*/
411+
export function objectify(wat: unknown): typeof Object {
412+
let objectified;
413+
switch (true) {
414+
case wat === undefined || wat === null:
415+
objectified = new String(wat);
416+
break;
417+
418+
// Though symbols and bigints do have wrapper classes (`Symbol` and `BigInt`, respectively), for whatever reason
419+
// those classes don't have constructors which can be used with the `new` keyword. We therefore need to cast each as
420+
// an object in order to wrap it.
421+
case typeof wat === 'symbol' || typeof wat === 'bigint':
422+
objectified = Object(wat);
423+
break;
424+
425+
// this will catch the remaining primitives: `String`, `Number`, and `Boolean`
426+
case isPrimitive(wat):
427+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
428+
objectified = new (wat as any).constructor(wat);
429+
break;
430+
431+
// by process of elimination, at this point we know that `wat` must already be an object
432+
default:
433+
objectified = wat;
434+
break;
435+
}
436+
return objectified;
437+
}

packages/utils/test/misc.test.ts

+31
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Event, Mechanism, StackFrame } from '@sentry/types';
33
import {
44
addContextToFrame,
55
addExceptionMechanism,
6+
checkOrSetAlreadyCaught,
67
getEventDescription,
78
parseRetryAfterHeader,
89
stripUrlQueryAndFragment,
@@ -288,3 +289,33 @@ describe('addExceptionMechanism', () => {
288289
});
289290
});
290291
});
292+
293+
describe('checkOrSetAlreadyCaught()', () => {
294+
describe('ignores primitives', () => {
295+
it.each([
296+
['undefined', undefined],
297+
['null', null],
298+
['number', 1231],
299+
['boolean', true],
300+
['string', 'Dogs are great!'],
301+
])('%s', (_case: string, exception: unknown): void => {
302+
// in this case, "ignore" just means reporting them as unseen without actually doing anything to them (which of
303+
// course it can't anyway, because primitives are immutable)
304+
expect(checkOrSetAlreadyCaught(exception)).toBe(false);
305+
});
306+
});
307+
308+
it("recognizes exceptions it's seen before", () => {
309+
// `exception` can be any object - an `Error`, a class instance, or a plain object
310+
const exception = { message: 'Oh, no! Charlie ate the flip-flops! :-(', __sentry_captured__: true };
311+
312+
expect(checkOrSetAlreadyCaught(exception)).toBe(true);
313+
});
314+
315+
it('recognizes new exceptions as new and marks them as seen', () => {
316+
const exception = { message: 'Oh, no! Charlie ate the flip-flops! :-(' };
317+
318+
expect(checkOrSetAlreadyCaught(exception)).toBe(false);
319+
expect((exception as any).__sentry_captured__).toBe(true);
320+
});
321+
});

packages/utils/test/object.test.ts

+64-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@
33
*/
44

55
import * as isModule from '../src/is';
6-
import { dropUndefinedKeys, extractExceptionKeysForMessage, fill, normalize, urlEncode } from '../src/object';
6+
import {
7+
dropUndefinedKeys,
8+
extractExceptionKeysForMessage,
9+
fill,
10+
normalize,
11+
objectify,
12+
urlEncode,
13+
} from '../src/object';
714
import { testOnlyIfNodeVersionAtLeast } from './testutils';
815

916
describe('fill()', () => {
@@ -638,3 +645,59 @@ describe('dropUndefinedKeys()', () => {
638645
});
639646
});
640647
});
648+
649+
describe('objectify()', () => {
650+
describe('stringifies nullish values', () => {
651+
it.each([
652+
['undefined', undefined],
653+
['null', null],
654+
])('%s', (stringifiedValue, origValue): void => {
655+
const objectifiedNullish = objectify(origValue);
656+
657+
expect(objectifiedNullish).toEqual(expect.any(String));
658+
expect(objectifiedNullish.valueOf()).toEqual(stringifiedValue);
659+
});
660+
});
661+
662+
describe('wraps other primitives with their respective object wrapper classes', () => {
663+
// TODO: There's currently a bug in Jest - if you give it the `Boolean` class, it runs `typeof received ===
664+
// 'boolean'` but not `received instanceof Boolean` (the way it correctly does for other primitive wrappers, like
665+
// `Number` and `String). (See https://github.com/facebook/jest/pull/11976.) Once that is fixed and we upgrade jest,
666+
// we can comment the test below back in. (The tests for symbols and bigints are working only because our current
667+
// version of jest is sufficiently old that they're not even considered in the relevant check and just fall to the
668+
// default `instanceof` check jest uses for all unknown classes.)
669+
670+
it.each([
671+
['number', Number, 1121],
672+
['string', String, 'Dogs are great!'],
673+
// ["boolean", Boolean, true],
674+
['symbol', Symbol, Symbol('Maisey')],
675+
])('%s', (_caseName, wrapperClass, primitive) => {
676+
const objectifiedPrimitive = objectify(primitive);
677+
678+
expect(objectifiedPrimitive).toEqual(expect.any(wrapperClass));
679+
expect(objectifiedPrimitive.valueOf()).toEqual(primitive);
680+
});
681+
682+
// `BigInt` doesn't exist in Node < 10, so we test it separately here.
683+
testOnlyIfNodeVersionAtLeast(10)('bigint', () => {
684+
// Hack to get around the fact that literal bigints cause a syntax error in older versions of Node, so the
685+
// assignment needs to not even be parsed as code in those versions
686+
let bigintPrimitive;
687+
eval('bigintPrimitive = 1231n;');
688+
689+
const objectifiedBigInt = objectify(bigintPrimitive);
690+
691+
expect(objectifiedBigInt).toEqual(expect.any(BigInt));
692+
expect(objectifiedBigInt.valueOf()).toEqual(bigintPrimitive);
693+
});
694+
});
695+
696+
it('leaves objects alone', () => {
697+
const notAPrimitive = new Object();
698+
const objectifiedNonPrimtive = objectify(notAPrimitive);
699+
700+
// `.toBe()` tests on identity, so this shows no wrapping has occurred
701+
expect(objectifiedNonPrimtive).toBe(notAPrimitive);
702+
});
703+
});

0 commit comments

Comments
 (0)