Skip to content

Commit bbc9d3d

Browse files
authored
fix(browser): Fix memory leak in addEventListener instrumentation (#5147)
1 parent 2808c6e commit bbc9d3d

File tree

11 files changed

+203
-4
lines changed

11 files changed

+203
-4
lines changed

packages/browser/src/helpers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ export function ignoreNextOnError(): void {
3232
* Instruments the given function and sends an event to Sentry every time the
3333
* function throws an exception.
3434
*
35-
* @param fn A function to wrap.
35+
* @param fn A function to wrap. It is generally safe to pass an unbound function, because the returned wrapper always
36+
* has a correct `this` context.
3637
* @returns The wrapped function.
3738
* @hidden
3839
*/
@@ -75,8 +76,8 @@ export function wrap(
7576
}
7677

7778
/* eslint-disable prefer-rest-params */
78-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
79-
const sentryWrapped: WrappedFunction = function (this: any): void {
79+
// It is important that `sentryWrapped` is not an arrow function to preserve the context of `this`
80+
const sentryWrapped: WrappedFunction = function (this: unknown): void {
8081
const args = Array.prototype.slice.call(arguments);
8182

8283
try {

packages/browser/src/integrations/trycatch.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,13 @@ function _wrapEventTarget(target: string): void {
208208
): (eventName: string, fn: EventListenerObject, capture?: boolean, secure?: boolean) => void {
209209
try {
210210
if (typeof fn.handleEvent === 'function') {
211-
fn.handleEvent = wrap(fn.handleEvent.bind(fn), {
211+
// ESlint disable explanation:
212+
// First, it is generally safe to call `wrap` with an unbound function. Furthermore, using `.bind()` would
213+
// introduce a bug here, because bind returns a new function that doesn't have our
214+
// flags(like __sentry_original__) attached. `wrap` checks for those flags to avoid unnecessary wrapping.
215+
// Without those flags, every call to addEventListener wraps the function again, causing a memory leak.
216+
// eslint-disable-next-line @typescript-eslint/unbound-method
217+
fn.handleEvent = wrap(fn.handleEvent, {
212218
mechanism: {
213219
data: {
214220
function: 'handleEvent',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Simple function event listener
2+
const functionListener = () => {
3+
functionListenerCallback();
4+
};
5+
6+
// Attach event listener twice
7+
window.addEventListener('click', functionListener);
8+
window.addEventListener('click', functionListener);
9+
10+
// Event listener that has handleEvent() method: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#listener
11+
class EventHandlerClass {
12+
handleEvent() {
13+
objectListenerCallback();
14+
}
15+
}
16+
17+
const objectListener = new EventHandlerClass();
18+
19+
// Attach event listener twice
20+
window.addEventListener('click', objectListener);
21+
window.addEventListener('click', objectListener);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
5+
sentryTest(
6+
'Event listener instrumentation should attach the same event listener only once',
7+
async ({ getLocalTestPath, page }) => {
8+
const url = await getLocalTestPath({ testDir: __dirname });
9+
await page.goto(url);
10+
11+
let functionListenerCalls = 0;
12+
await page.exposeFunction('functionListenerCallback', () => {
13+
functionListenerCalls = functionListenerCalls + 1;
14+
});
15+
16+
let objectListenerCalls = 0;
17+
await page.exposeFunction('objectListenerCallback', () => {
18+
objectListenerCalls = objectListenerCalls + 1;
19+
});
20+
21+
// Trigger event listeners twice
22+
await page.evaluate('document.body.click()');
23+
await page.evaluate('document.body.click()');
24+
25+
expect(functionListenerCalls).toBe(2);
26+
expect(objectListenerCalls).toBe(2);
27+
},
28+
);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const btn = document.createElement('button');
2+
btn.id = 'btn';
3+
document.body.appendChild(btn);
4+
5+
const functionListener = function () {
6+
functionCallback(this.constructor.name);
7+
};
8+
9+
class EventHandlerClass {
10+
handleEvent() {
11+
classInstanceCallback(this.constructor.name);
12+
}
13+
}
14+
const objectListener = new EventHandlerClass();
15+
16+
// Attach event listeners a few times for good measure
17+
18+
btn.addEventListener('click', functionListener);
19+
btn.addEventListener('click', functionListener);
20+
btn.addEventListener('click', functionListener);
21+
22+
btn.addEventListener('click', objectListener);
23+
btn.addEventListener('click', objectListener);
24+
btn.addEventListener('click', objectListener);
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+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
5+
sentryTest('Event listener instrumentation preserves "this" context', async ({ getLocalTestPath, page }) => {
6+
const url = await getLocalTestPath({ testDir: __dirname });
7+
await page.goto(url);
8+
9+
let assertions = 0;
10+
11+
await page.exposeFunction('functionCallback', (thisInstanceName: unknown) => {
12+
expect(thisInstanceName).toBe('HTMLButtonElement');
13+
assertions = assertions + 1;
14+
});
15+
16+
await page.exposeFunction('classInstanceCallback', (thisInstanceName: unknown) => {
17+
expect(thisInstanceName).toBe('EventHandlerClass');
18+
assertions = assertions + 1;
19+
});
20+
21+
await page.evaluate('document.getElementById("btn").click()');
22+
23+
expect(assertions).toBe(2);
24+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Simple function event listener
2+
const functionListener = () => {
3+
reportFunctionListenerStackHeight(new Error().stack.split('\n').length);
4+
};
5+
6+
// Event listener that has handleEvent() method: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#listener
7+
class EventHandlerClass {
8+
handleEvent() {
9+
reportObjectListenerStackHeight(new Error().stack.split('\n').length);
10+
}
11+
}
12+
13+
const objectListener = new EventHandlerClass();
14+
15+
window.attachListeners = function () {
16+
window.addEventListener('click', functionListener);
17+
window.addEventListener('click', objectListener);
18+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
5+
sentryTest(
6+
'Event listener instrumentation should not wrap event listeners multiple times',
7+
async ({ getLocalTestPath, page }) => {
8+
const url = await getLocalTestPath({ testDir: __dirname });
9+
await page.goto(url);
10+
11+
const functionListenerStackHeights: number[] = [];
12+
const objectListenerStackHeights: number[] = [];
13+
14+
await page.exposeFunction('reportFunctionListenerStackHeight', (height: number) => {
15+
functionListenerStackHeights.push(height);
16+
});
17+
18+
await page.exposeFunction('reportObjectListenerStackHeight', (height: number) => {
19+
objectListenerStackHeights.push(height);
20+
});
21+
22+
// Attach initial listeners
23+
await page.evaluate('window.attachListeners()');
24+
await page.evaluate('document.body.click()');
25+
26+
await page.evaluate('window.attachListeners()');
27+
await page.evaluate('window.attachListeners()');
28+
await page.evaluate('window.attachListeners()');
29+
await page.evaluate('document.body.click()');
30+
31+
expect(functionListenerStackHeights).toHaveLength(2);
32+
expect(objectListenerStackHeights).toHaveLength(2);
33+
34+
// check if all error stack traces are the same height
35+
expect(functionListenerStackHeights.every((val, _i, arr) => val === arr[0])).toBeTruthy();
36+
expect(objectListenerStackHeights.every((val, _i, arr) => val === arr[0])).toBeTruthy();
37+
},
38+
);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
window.addEventListener('click', () => {
2+
throw new Error('event_listener_error');
3+
});
4+
5+
document.body.click();
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { expect } from '@playwright/test';
2+
import { Event } from '@sentry/types';
3+
4+
import { sentryTest } from '../../../../utils/fixtures';
5+
import { getFirstSentryEnvelopeRequest } from '../../../../utils/helpers';
6+
7+
sentryTest(
8+
'Event listener instrumentation should capture an error thrown in an event handler',
9+
async ({ getLocalTestPath, page }) => {
10+
const url = await getLocalTestPath({ testDir: __dirname });
11+
12+
const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
13+
14+
expect(eventData.exception?.values).toHaveLength(1);
15+
expect(eventData.exception?.values?.[0]).toMatchObject({
16+
type: 'Error',
17+
value: 'event_listener_error',
18+
mechanism: {
19+
type: 'instrument',
20+
handled: true,
21+
},
22+
stacktrace: {
23+
frames: expect.any(Array),
24+
},
25+
});
26+
},
27+
);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
});

0 commit comments

Comments
 (0)