Skip to content

feat(browser): Add unregisterOriginalCallbacks option to browserApiErrorsIntegration #16412

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

Merged
merged 4 commits into from
May 28, 2025
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,18 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

const btn = document.getElementById('btn');

const myClickListener = () => {
// eslint-disable-next-line no-console
console.log('clicked');
};

btn.addEventListener('click', myClickListener);

Sentry.init({
dsn: 'https://[email protected]/1337',
});

btn.addEventListener('click', myClickListener);
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="btn">Click me</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../utils/fixtures';

/**
* This test demonstrates an unfortunate edge case with our EventTarget.addEventListener instrumentation.
* If a listener is registered before Sentry.init() and then again, the same listener is added
* after Sentry.init(), our `browserApiErrorsIntegration`'s instrumentation causes the listener to be
* added twice, while without the integration it would only be added and invoked once.
*
* Real-life example of such an issue:
* https://github.com/getsentry/sentry-javascript/issues/16398
*/
sentryTest(
'causes listeners to be invoked twice if registered before and after Sentry initialization',
async ({ getLocalTestUrl, page }) => {
const consoleLogs: string[] = [];
page.on('console', msg => {
consoleLogs.push(msg.text());
});

await page.goto(await getLocalTestUrl({ testDir: __dirname }));

await page.waitForFunction('window.Sentry');

await page.locator('#btn').click();

expect(consoleLogs).toHaveLength(2);
expect(consoleLogs).toEqual(['clicked', 'clicked']);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

const btn = document.getElementById('btn');

const myClickListener = () => {
// eslint-disable-next-line no-console
console.log('clicked');
};

btn.addEventListener('click', myClickListener);

Sentry.init({
dsn: 'https://[email protected]/1337',
integrations: [
Sentry.browserApiErrorsIntegration({
unregisterOriginalCallbacks: true,
}),
],
});

btn.addEventListener('click', myClickListener);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { expect } from '@playwright/test';
import { sentryTest } from '../../../../utils/fixtures';

/**
* By setting `unregisterOriginalCallbacks` to `true`, we can avoid the issue of double-invocations
* (see other test for more details).
*/
sentryTest(
'causes listeners to be invoked twice if registered before and after Sentry initialization',
async ({ getLocalTestUrl, page }) => {
const consoleLogs: string[] = [];
page.on('console', msg => {
consoleLogs.push(msg.text());
});

await page.goto(await getLocalTestUrl({ testDir: __dirname }));

await page.waitForFunction('window.Sentry');

await page.locator('#btn').click();

expect(consoleLogs).toHaveLength(1);
expect(consoleLogs).toEqual(['clicked']);
},
);
29 changes: 27 additions & 2 deletions packages/browser/src/integrations/browserapierrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ interface BrowserApiErrorsOptions {
requestAnimationFrame: boolean;
XMLHttpRequest: boolean;
eventTarget: boolean | string[];

/**
* If you experience issues with this integration causing double-invocations of event listeners,
* try setting this option to `true`. It will unregister the original callbacks from the event targets
* before adding the instrumented callback.
*
* @default false
*/
unregisterOriginalCallbacks: boolean;
}

const _browserApiErrorsIntegration = ((options: Partial<BrowserApiErrorsOptions> = {}) => {
Expand All @@ -55,6 +64,7 @@ const _browserApiErrorsIntegration = ((options: Partial<BrowserApiErrorsOptions>
requestAnimationFrame: true,
setInterval: true,
setTimeout: true,
unregisterOriginalCallbacks: false,
...options,
};

Expand Down Expand Up @@ -82,7 +92,7 @@ const _browserApiErrorsIntegration = ((options: Partial<BrowserApiErrorsOptions>
const eventTargetOption = _options.eventTarget;
if (eventTargetOption) {
const eventTarget = Array.isArray(eventTargetOption) ? eventTargetOption : DEFAULT_EVENT_TARGET;
eventTarget.forEach(_wrapEventTarget);
eventTarget.forEach(target => _wrapEventTarget(target, _options));
}
},
};
Expand Down Expand Up @@ -160,7 +170,7 @@ function _wrapXHR(originalSend: () => void): () => void {
};
}

function _wrapEventTarget(target: string): void {
function _wrapEventTarget(target: string, integrationOptions: BrowserApiErrorsOptions): void {
const globalObject = WINDOW as unknown as Record<string, { prototype?: object }>;
const proto = globalObject[target]?.prototype;

Expand Down Expand Up @@ -197,6 +207,10 @@ function _wrapEventTarget(target: string): void {
// can sometimes get 'Permission denied to access property "handle Event'
}

if (integrationOptions.unregisterOriginalCallbacks) {
unregisterOriginalCallback(this, eventName, fn);
}

return original.apply(this, [
eventName,
wrap(fn, {
Expand Down Expand Up @@ -253,3 +267,14 @@ function _wrapEventTarget(target: string): void {
function isEventListenerObject(obj: unknown): obj is EventListenerObject {
return typeof (obj as EventListenerObject).handleEvent === 'function';
}

function unregisterOriginalCallback(target: unknown, eventName: string, fn: EventListenerOrEventListenerObject): void {
if (
target &&
typeof target === 'object' &&
'removeEventListener' in target &&
typeof target.removeEventListener === 'function'
) {
target.removeEventListener(eventName, fn);
}
}
Loading