Skip to content

Commit 042e7ce

Browse files
authored
fix(utils): Try-catch monkeypatching to handle frozen objects/functions (#9031)
This try-catches any monkeypatching we do on objects, to avoid us throwing e.g. if trying to patch a frozen object. Obv. the monkey patching will _not_ actually work then, but I guess it's better to not wrap stuff than to error out in userland. I also added some tests for this! Fixes #9030
1 parent d8b879a commit 042e7ce

File tree

8 files changed

+221
-16
lines changed

8 files changed

+221
-16
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
function callback() {
2+
throw new Error('setTimeout_error');
3+
}
4+
5+
setTimeout(callback, 0);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers';
6+
7+
sentryTest('Instrumentation should capture errors in setTimeout', async ({ getLocalTestPath, page }) => {
8+
const url = await getLocalTestPath({ testDir: __dirname });
9+
10+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
11+
12+
expect(eventData.exception?.values).toHaveLength(1);
13+
expect(eventData.exception?.values?.[0]).toMatchObject({
14+
type: 'Error',
15+
value: 'setTimeout_error',
16+
mechanism: {
17+
type: 'instrument',
18+
handled: false,
19+
},
20+
stacktrace: {
21+
frames: expect.any(Array),
22+
},
23+
});
24+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
debug: true,
8+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
function callback() {
2+
throw new Error('setTimeout_error');
3+
}
4+
5+
setTimeout(Object.freeze(callback), 0);
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { expect } from '@playwright/test';
2+
import type { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers';
6+
7+
sentryTest(
8+
'Instrumentation does not fail when using frozen callback for setTimeout',
9+
async ({ getLocalTestPath, page }) => {
10+
const bundleKey = process.env.PW_BUNDLE || '';
11+
const hasDebug = !bundleKey.includes('_min');
12+
13+
const url = await getLocalTestPath({ testDir: __dirname });
14+
15+
const logMessages: string[] = [];
16+
17+
page.on('console', msg => {
18+
logMessages.push(msg.text());
19+
});
20+
21+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
22+
23+
// It still captures the error
24+
expect(eventData.exception?.values).toHaveLength(1);
25+
expect(eventData.exception?.values?.[0]).toMatchObject({
26+
type: 'Error',
27+
value: 'setTimeout_error',
28+
mechanism: {
29+
type: 'instrument',
30+
handled: false,
31+
},
32+
stacktrace: {
33+
frames: expect.any(Array),
34+
},
35+
});
36+
37+
// We only care about the message when debug is enabled
38+
if (hasDebug) {
39+
expect(logMessages).toEqual(
40+
expect.arrayContaining([
41+
expect.stringContaining(
42+
'Sentry Logger [log]: Failed to add non-enumerable property "__sentry_wrapped__" to object function callback()',
43+
),
44+
]),
45+
);
46+
}
47+
},
48+
);

packages/browser/test/unit/index.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getReportDialogEndpoint, SDK_VERSION } from '@sentry/core';
2+
import type { WrappedFunction } from '@sentry/types';
23
import * as utils from '@sentry/utils';
34

45
import type { Event } from '../../src';
@@ -391,4 +392,14 @@ describe('wrap()', () => {
391392

392393
expect(result2).toBe(42);
393394
});
395+
396+
it('should ignore frozen functions', () => {
397+
const func = Object.freeze(() => 42);
398+
399+
// eslint-disable-next-line deprecation/deprecation
400+
wrap(func);
401+
402+
expect(func()).toBe(42);
403+
expect((func as WrappedFunction).__sentry_wrapped__).toBeUndefined();
404+
});
394405
});

packages/utils/src/object.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { WrappedFunction } from '@sentry/types';
44

55
import { htmlTreeAsString } from './browser';
66
import { isElement, isError, isEvent, isInstanceOf, isPlainObject, isPrimitive } from './is';
7+
import { logger } from './logger';
78
import { truncate } from './string';
89

910
/**
@@ -28,12 +29,7 @@ export function fill(source: { [key: string]: any }, name: string, replacementFa
2829
// Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work
2930
// otherwise it'll throw "TypeError: Object.defineProperties called on non-object"
3031
if (typeof wrapped === 'function') {
31-
try {
32-
markFunctionWrapped(wrapped, original);
33-
} catch (_Oo) {
34-
// This can throw if multiple fill happens on a global object like XMLHttpRequest
35-
// Fixes https://github.com/getsentry/sentry-javascript/issues/2043
36-
}
32+
markFunctionWrapped(wrapped, original);
3733
}
3834

3935
source[name] = wrapped;
@@ -47,12 +43,16 @@ export function fill(source: { [key: string]: any }, name: string, replacementFa
4743
* @param value The value to which to set the property
4844
*/
4945
export function addNonEnumerableProperty(obj: { [key: string]: unknown }, name: string, value: unknown): void {
50-
Object.defineProperty(obj, name, {
51-
// enumerable: false, // the default, so we can save on bundle size by not explicitly setting it
52-
value: value,
53-
writable: true,
54-
configurable: true,
55-
});
46+
try {
47+
Object.defineProperty(obj, name, {
48+
// enumerable: false, // the default, so we can save on bundle size by not explicitly setting it
49+
value: value,
50+
writable: true,
51+
configurable: true,
52+
});
53+
} catch (o_O) {
54+
__DEBUG_BUILD__ && logger.log(`Failed to add non-enumerable property "${name}" to object`, obj);
55+
}
5656
}
5757

5858
/**
@@ -63,9 +63,11 @@ export function addNonEnumerableProperty(obj: { [key: string]: unknown }, name:
6363
* @param original the original function that gets wrapped
6464
*/
6565
export function markFunctionWrapped(wrapped: WrappedFunction, original: WrappedFunction): void {
66-
const proto = original.prototype || {};
67-
wrapped.prototype = original.prototype = proto;
68-
addNonEnumerableProperty(wrapped, '__sentry_original__', original);
66+
try {
67+
const proto = original.prototype || {};
68+
wrapped.prototype = original.prototype = proto;
69+
addNonEnumerableProperty(wrapped, '__sentry_original__', original);
70+
} catch (o_O) {} // eslint-disable-line no-empty
6971
}
7072

7173
/**

packages/utils/test/object.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,17 @@
22
* @jest-environment jsdom
33
*/
44

5-
import { dropUndefinedKeys, extractExceptionKeysForMessage, fill, objectify, urlEncode } from '../src/object';
5+
import type { WrappedFunction } from '@sentry/types';
6+
7+
import {
8+
addNonEnumerableProperty,
9+
dropUndefinedKeys,
10+
extractExceptionKeysForMessage,
11+
fill,
12+
markFunctionWrapped,
13+
objectify,
14+
urlEncode,
15+
} from '../src/object';
616
import { testOnlyIfNodeVersionAtLeast } from './testutils';
717

818
describe('fill()', () => {
@@ -315,3 +325,95 @@ describe('objectify()', () => {
315325
expect(objectifiedNonPrimtive).toBe(notAPrimitive);
316326
});
317327
});
328+
329+
describe('addNonEnumerableProperty', () => {
330+
it('works with a plain object', () => {
331+
const obj: { foo?: string } = {};
332+
addNonEnumerableProperty(obj, 'foo', 'bar');
333+
expect(obj.foo).toBe('bar');
334+
});
335+
336+
it('works with a class', () => {
337+
class MyClass {
338+
public foo?: string;
339+
}
340+
const obj = new MyClass();
341+
addNonEnumerableProperty(obj as any, 'foo', 'bar');
342+
expect(obj.foo).toBe('bar');
343+
});
344+
345+
it('works with a function', () => {
346+
const func = jest.fn();
347+
addNonEnumerableProperty(func as any, 'foo', 'bar');
348+
expect((func as any).foo).toBe('bar');
349+
func();
350+
expect(func).toHaveBeenCalledTimes(1);
351+
});
352+
353+
it('works with an existing property object', () => {
354+
const obj = { foo: 'before' };
355+
addNonEnumerableProperty(obj, 'foo', 'bar');
356+
expect(obj.foo).toBe('bar');
357+
});
358+
359+
it('works with an existing readonly property object', () => {
360+
const obj = { foo: 'before' };
361+
362+
Object.defineProperty(obj, 'foo', {
363+
value: 'defined',
364+
writable: false,
365+
});
366+
367+
addNonEnumerableProperty(obj, 'foo', 'bar');
368+
expect(obj.foo).toBe('bar');
369+
});
370+
371+
it('does not error with a frozen object', () => {
372+
const obj = Object.freeze({ foo: 'before' });
373+
374+
addNonEnumerableProperty(obj, 'foo', 'bar');
375+
expect(obj.foo).toBe('before');
376+
});
377+
});
378+
379+
describe('markFunctionWrapped', () => {
380+
it('works with a function', () => {
381+
const originalFunc = jest.fn();
382+
const wrappedFunc = jest.fn();
383+
markFunctionWrapped(wrappedFunc, originalFunc);
384+
385+
expect((wrappedFunc as WrappedFunction).__sentry_original__).toBe(originalFunc);
386+
387+
wrappedFunc();
388+
389+
expect(wrappedFunc).toHaveBeenCalledTimes(1);
390+
expect(originalFunc).not.toHaveBeenCalled();
391+
});
392+
393+
it('works with a frozen original function', () => {
394+
const originalFunc = Object.freeze(jest.fn());
395+
const wrappedFunc = jest.fn();
396+
markFunctionWrapped(wrappedFunc, originalFunc);
397+
398+
expect((wrappedFunc as WrappedFunction).__sentry_original__).toBe(originalFunc);
399+
400+
wrappedFunc();
401+
402+
expect(wrappedFunc).toHaveBeenCalledTimes(1);
403+
expect(originalFunc).not.toHaveBeenCalled();
404+
});
405+
406+
it('works with a frozen wrapped function', () => {
407+
const originalFunc = Object.freeze(jest.fn());
408+
const wrappedFunc = Object.freeze(jest.fn());
409+
markFunctionWrapped(wrappedFunc, originalFunc);
410+
411+
// Skips adding the property, but also doesn't error
412+
expect((wrappedFunc as WrappedFunction).__sentry_original__).toBe(undefined);
413+
414+
wrappedFunc();
415+
416+
expect(wrappedFunc).toHaveBeenCalledTimes(1);
417+
expect(originalFunc).not.toHaveBeenCalled();
418+
});
419+
});

0 commit comments

Comments
 (0)