Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(utils): Try-catch monkeypatching to handle frozen objects/functions #9031

Merged
merged 3 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
function callback() {
throw new Error('setTimeout_error');
}

setTimeout(callback, 0);
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { expect } from '@playwright/test';
import type { Event } from '@sentry/types';

import { sentryTest } from '../../../../utils/fixtures';
import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers';

sentryTest('Instrumentation should capture errors in setTimeout', async ({ getLocalTestPath, page }) => {
const url = await getLocalTestPath({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);

expect(eventData.exception?.values).toHaveLength(1);
expect(eventData.exception?.values?.[0]).toMatchObject({
type: 'Error',
value: 'setTimeout_error',
mechanism: {
type: 'instrument',
handled: false,
},
stacktrace: {
frames: expect.any(Array),
},
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://[email protected]/1337',
debug: true,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
function callback() {
throw new Error('setTimeout_error');
}

setTimeout(Object.freeze(callback), 0);
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { expect } from '@playwright/test';
import type { Event } from '@sentry/types';

import { sentryTest } from '../../../../utils/fixtures';
import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers';

sentryTest(
'Instrumentation does not fail when using frozen callback for setTimeout',
async ({ getLocalTestPath, page }) => {
const bundleKey = process.env.PW_BUNDLE || '';
const hasDebug = !bundleKey.includes('_min');

const url = await getLocalTestPath({ testDir: __dirname });

const logMessages: string[] = [];

page.on('console', msg => {
logMessages.push(msg.text());
});

const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);

// It still captures the error
expect(eventData.exception?.values).toHaveLength(1);
expect(eventData.exception?.values?.[0]).toMatchObject({
type: 'Error',
value: 'setTimeout_error',
mechanism: {
type: 'instrument',
handled: false,
},
stacktrace: {
frames: expect.any(Array),
},
});

// We only care about the message when debug is enabled
if (hasDebug) {
expect(logMessages).toEqual(
expect.arrayContaining([
expect.stringContaining(
'Sentry Logger [log]: Failed to add non-enumerable property "__sentry_wrapped__" to object function callback()',
),
]),
);
}
},
);
11 changes: 11 additions & 0 deletions packages/browser/test/unit/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getReportDialogEndpoint, SDK_VERSION } from '@sentry/core';
import type { WrappedFunction } from '@sentry/types';
import * as utils from '@sentry/utils';

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

expect(result2).toBe(42);
});

it('should ignore frozen functions', () => {
const func = Object.freeze(() => 42);

// eslint-disable-next-line deprecation/deprecation
wrap(func);

expect(func()).toBe(42);
expect((func as WrappedFunction).__sentry_wrapped__).toBeUndefined();
});
});
32 changes: 17 additions & 15 deletions packages/utils/src/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { WrappedFunction } from '@sentry/types';

import { htmlTreeAsString } from './browser';
import { isElement, isError, isEvent, isInstanceOf, isPlainObject, isPrimitive } from './is';
import { logger } from './logger';
import { truncate } from './string';

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

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

/**
Expand All @@ -63,9 +63,11 @@ export function addNonEnumerableProperty(obj: { [key: string]: unknown }, name:
* @param original the original function that gets wrapped
*/
export function markFunctionWrapped(wrapped: WrappedFunction, original: WrappedFunction): void {
const proto = original.prototype || {};
wrapped.prototype = original.prototype = proto;
addNonEnumerableProperty(wrapped, '__sentry_original__', original);
try {
const proto = original.prototype || {};
wrapped.prototype = original.prototype = proto;
addNonEnumerableProperty(wrapped, '__sentry_original__', original);
} catch (o_O) {} // eslint-disable-line no-empty
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

h: I would add a log here (maybe even non-debug!). I can already see myself debugging an issue that some instrumentation isn't working. This is a very opaque way to fail.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can add a debug log, but wouldn't do a log. Because it is really not a user problem, the linked issue:

function callback() {
    console.log("Hello");
}
setTimeout(callback, 50); // OK
Object.freeze(callback);
setTimeout(callback, 50); // BOOM!

Is generally totally OK to write but would result in this. the user shouldn't see a log because of this IMHO.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Debug log sounds good!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added a debug log & integration tests to actually make sure the issue raised is fixed. Interestingly, if the function if frozen the actual monkey patching still works, only the setting of the wrapped property fails. So the error is still captured even if the callback is frozen, which is good I guess.

}

/**
Expand Down
104 changes: 103 additions & 1 deletion packages/utils/test/object.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@
* @jest-environment jsdom
*/

import { dropUndefinedKeys, extractExceptionKeysForMessage, fill, objectify, urlEncode } from '../src/object';
import type { WrappedFunction } from '@sentry/types';

import {
addNonEnumerableProperty,
dropUndefinedKeys,
extractExceptionKeysForMessage,
fill,
markFunctionWrapped,
objectify,
urlEncode,
} from '../src/object';
import { testOnlyIfNodeVersionAtLeast } from './testutils';

describe('fill()', () => {
Expand Down Expand Up @@ -315,3 +325,95 @@ describe('objectify()', () => {
expect(objectifiedNonPrimtive).toBe(notAPrimitive);
});
});

describe('addNonEnumerableProperty', () => {
it('works with a plain object', () => {
const obj: { foo?: string } = {};
addNonEnumerableProperty(obj, 'foo', 'bar');
expect(obj.foo).toBe('bar');
});

it('works with a class', () => {
class MyClass {
public foo?: string;
}
const obj = new MyClass();
addNonEnumerableProperty(obj as any, 'foo', 'bar');
expect(obj.foo).toBe('bar');
});

it('works with a function', () => {
const func = jest.fn();
addNonEnumerableProperty(func as any, 'foo', 'bar');
expect((func as any).foo).toBe('bar');
func();
expect(func).toHaveBeenCalledTimes(1);
});

it('works with an existing property object', () => {
const obj = { foo: 'before' };
addNonEnumerableProperty(obj, 'foo', 'bar');
expect(obj.foo).toBe('bar');
});

it('works with an existing readonly property object', () => {
const obj = { foo: 'before' };

Object.defineProperty(obj, 'foo', {
value: 'defined',
writable: false,
});

addNonEnumerableProperty(obj, 'foo', 'bar');
expect(obj.foo).toBe('bar');
});

it('does not error with a frozen object', () => {
const obj = Object.freeze({ foo: 'before' });

addNonEnumerableProperty(obj, 'foo', 'bar');
expect(obj.foo).toBe('before');
});
});

describe('markFunctionWrapped', () => {
it('works with a function', () => {
const originalFunc = jest.fn();
const wrappedFunc = jest.fn();
markFunctionWrapped(wrappedFunc, originalFunc);

expect((wrappedFunc as WrappedFunction).__sentry_original__).toBe(originalFunc);

wrappedFunc();

expect(wrappedFunc).toHaveBeenCalledTimes(1);
expect(originalFunc).not.toHaveBeenCalled();
});

it('works with a frozen original function', () => {
const originalFunc = Object.freeze(jest.fn());
const wrappedFunc = jest.fn();
markFunctionWrapped(wrappedFunc, originalFunc);

expect((wrappedFunc as WrappedFunction).__sentry_original__).toBe(originalFunc);

wrappedFunc();

expect(wrappedFunc).toHaveBeenCalledTimes(1);
expect(originalFunc).not.toHaveBeenCalled();
});

it('works with a frozen wrapped function', () => {
const originalFunc = Object.freeze(jest.fn());
const wrappedFunc = Object.freeze(jest.fn());
markFunctionWrapped(wrappedFunc, originalFunc);

// Skips adding the property, but also doesn't error
expect((wrappedFunc as WrappedFunction).__sentry_original__).toBe(undefined);

wrappedFunc();

expect(wrappedFunc).toHaveBeenCalledTimes(1);
expect(originalFunc).not.toHaveBeenCalled();
});
});