Skip to content

Commit d1515b9

Browse files
authored
fix(utils): Avoid keeping a reference of last used event (#9387)
As discussed some time ago, this changes the dom instrumentation to avoid keeping any reference to an event in the module scope. Instead, we put a `_sentryId` non-enumerable property on the event target (if possible) and check based on this. I also added some tests to verify the expected behavior, also for future changes here. Fixes #9204
1 parent a5e8424 commit d1515b9

File tree

7 files changed

+199
-23
lines changed

7 files changed

+199
-23
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title></title>
6+
</head>
7+
<body>
8+
<button id="button1" type="button">Button 1</button>
9+
<button id="button2" type="button">Button 2</button>
10+
</body>
11+
</html>
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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('captures Breadcrumb for clicks & debounces them for a second', async ({ getLocalTestUrl, page }) => {
8+
const url = await getLocalTestUrl({ testDir: __dirname });
9+
10+
await page.route('**/foo', route => {
11+
return route.fulfill({
12+
status: 200,
13+
body: JSON.stringify({
14+
userNames: ['John', 'Jane'],
15+
}),
16+
headers: {
17+
'Content-Type': 'application/json',
18+
},
19+
});
20+
});
21+
22+
const promise = getFirstSentryEnvelopeRequest<Event>(page);
23+
24+
await page.goto(url);
25+
26+
await page.click('#button1');
27+
// not debounced because other target
28+
await page.click('#button2');
29+
// This should be debounced
30+
await page.click('#button2');
31+
32+
// Wait a second for the debounce to finish
33+
await page.waitForTimeout(1000);
34+
await page.click('#button2');
35+
36+
await page.evaluate('Sentry.captureException("test exception")');
37+
38+
const eventData = await promise;
39+
40+
expect(eventData.exception?.values).toHaveLength(1);
41+
42+
expect(eventData.breadcrumbs).toEqual([
43+
{
44+
timestamp: expect.any(Number),
45+
category: 'ui.click',
46+
message: 'body > button#button1[type="button"]',
47+
},
48+
{
49+
timestamp: expect.any(Number),
50+
category: 'ui.click',
51+
message: 'body > button#button2[type="button"]',
52+
},
53+
{
54+
timestamp: expect.any(Number),
55+
category: 'ui.click',
56+
message: 'body > button#button2[type="button"]',
57+
},
58+
]);
59+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://public@dsn.ingest.sentry.io/1337',
7+
defaultIntegrations: false,
8+
integrations: [new Sentry.Integrations.Breadcrumbs()],
9+
sampleRate: 1,
10+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title></title>
6+
</head>
7+
<body>
8+
<input id="input1" type="text" />
9+
<input id="input2" type="text" />
10+
</body>
11+
</html>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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('captures Breadcrumb for events on inputs & debounced them', async ({ getLocalTestUrl, page }) => {
8+
const url = await getLocalTestUrl({ testDir: __dirname });
9+
10+
await page.route('**/foo', route => {
11+
return route.fulfill({
12+
status: 200,
13+
body: JSON.stringify({
14+
userNames: ['John', 'Jane'],
15+
}),
16+
headers: {
17+
'Content-Type': 'application/json',
18+
},
19+
});
20+
});
21+
22+
const promise = getFirstSentryEnvelopeRequest<Event>(page);
23+
24+
await page.goto(url);
25+
26+
await page.click('#input1');
27+
// Not debounced because other event type
28+
await page.type('#input1', 'John', { delay: 1 });
29+
// This should be debounced
30+
await page.type('#input1', 'Abby', { delay: 1 });
31+
// not debounced because other target
32+
await page.type('#input2', 'Anne', { delay: 1 });
33+
34+
// Wait a second for the debounce to finish
35+
await page.waitForTimeout(1000);
36+
await page.type('#input2', 'John', { delay: 1 });
37+
38+
await page.evaluate('Sentry.captureException("test exception")');
39+
40+
const eventData = await promise;
41+
42+
expect(eventData.exception?.values).toHaveLength(1);
43+
44+
expect(eventData.breadcrumbs).toEqual([
45+
{
46+
timestamp: expect.any(Number),
47+
category: 'ui.click',
48+
message: 'body > input#input1[type="text"]',
49+
},
50+
{
51+
timestamp: expect.any(Number),
52+
category: 'ui.input',
53+
message: 'body > input#input1[type="text"]',
54+
},
55+
{
56+
timestamp: expect.any(Number),
57+
category: 'ui.input',
58+
message: 'body > input#input2[type="text"]',
59+
},
60+
{
61+
timestamp: expect.any(Number),
62+
category: 'ui.input',
63+
message: 'body > input#input2[type="text"]',
64+
},
65+
]);
66+
});

packages/utils/src/instrument.ts

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
import { isString } from './is';
1313
import type { ConsoleLevel } from './logger';
1414
import { CONSOLE_LEVELS, logger, originalConsoleMethods } from './logger';
15+
import { uuid4 } from './misc';
1516
import { addNonEnumerableProperty, fill } from './object';
1617
import { getFunctionName } from './stacktrace';
1718
import { supportsHistory, supportsNativeFetch } from './supports';
@@ -404,21 +405,24 @@ function instrumentHistory(): void {
404405

405406
const DEBOUNCE_DURATION = 1000;
406407
let debounceTimerID: number | undefined;
407-
let lastCapturedEvent: Event | undefined;
408+
let lastCapturedEventType: string | undefined;
409+
let lastCapturedEventTargetId: string | undefined;
410+
411+
type SentryWrappedTarget = HTMLElement & { _sentryId?: string };
408412

409413
/**
410-
* Check whether two DOM events are similar to eachother. For example, two click events on the same button.
414+
* Check whether the event is similar to the last captured one. For example, two click events on the same button.
411415
*/
412-
function areSimilarDomEvents(a: Event, b: Event): boolean {
416+
function isSimilarToLastCapturedEvent(event: Event): boolean {
413417
// If both events have different type, then user definitely performed two separate actions. e.g. click + keypress.
414-
if (a.type !== b.type) {
418+
if (event.type !== lastCapturedEventType) {
415419
return false;
416420
}
417421

418422
try {
419423
// If both events have the same type, it's still possible that actions were performed on different targets.
420424
// e.g. 2 clicks on different buttons.
421-
if (a.target !== b.target) {
425+
if (!event.target || (event.target as SentryWrappedTarget)._sentryId !== lastCapturedEventTargetId) {
422426
return false;
423427
}
424428
} catch (e) {
@@ -436,30 +440,33 @@ function areSimilarDomEvents(a: Event, b: Event): boolean {
436440
* Decide whether an event should be captured.
437441
* @param event event to be captured
438442
*/
439-
function shouldSkipDOMEvent(event: Event): boolean {
443+
function shouldSkipDOMEvent(eventType: string, target: SentryWrappedTarget | null): boolean {
440444
// We are only interested in filtering `keypress` events for now.
441-
if (event.type !== 'keypress') {
445+
if (eventType !== 'keypress') {
442446
return false;
443447
}
444448

445-
try {
446-
const target = event.target as HTMLElement;
449+
if (!target || !target.tagName) {
450+
return true;
451+
}
447452

448-
if (!target || !target.tagName) {
449-
return true;
450-
}
453+
// Only consider keypress events on actual input elements. This will disregard keypresses targeting body
454+
// e.g.tabbing through elements, hotkeys, etc.
455+
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
456+
return false;
457+
}
451458

452-
// Only consider keypress events on actual input elements. This will disregard keypresses targeting body
453-
// e.g.tabbing through elements, hotkeys, etc.
454-
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
455-
return false;
456-
}
459+
return true;
460+
}
461+
462+
function getEventTarget(event: Event): SentryWrappedTarget | null {
463+
try {
464+
return event.target as SentryWrappedTarget | null;
457465
} catch (e) {
458466
// just accessing `target` property can throw an exception in some rare circumstances
459467
// see: https://github.com/getsentry/sentry-javascript/issues/838
468+
return null;
460469
}
461-
462-
return true;
463470
}
464471

465472
/**
@@ -478,32 +485,41 @@ function makeDOMEventHandler(handler: Function, globalListener: boolean = false)
478485
return;
479486
}
480487

488+
const target = getEventTarget(event);
489+
481490
// We always want to skip _some_ events.
482-
if (shouldSkipDOMEvent(event)) {
491+
if (shouldSkipDOMEvent(event.type, target)) {
483492
return;
484493
}
485494

486495
// Mark event as "seen"
487496
addNonEnumerableProperty(event, '_sentryCaptured', true);
488497

498+
if (target && !target._sentryId) {
499+
// Add UUID to event target so we can identify if
500+
addNonEnumerableProperty(target, '_sentryId', uuid4());
501+
}
502+
489503
const name = event.type === 'keypress' ? 'input' : event.type;
490504

491505
// If there is no last captured event, it means that we can safely capture the new event and store it for future comparisons.
492506
// If there is a last captured event, see if the new event is different enough to treat it as a unique one.
493507
// If that's the case, emit the previous event and store locally the newly-captured DOM event.
494-
if (lastCapturedEvent === undefined || !areSimilarDomEvents(lastCapturedEvent, event)) {
508+
if (!isSimilarToLastCapturedEvent(event)) {
495509
handler({
496510
event: event,
497511
name,
498512
global: globalListener,
499513
});
500-
lastCapturedEvent = event;
514+
lastCapturedEventType = event.type;
515+
lastCapturedEventTargetId = target ? target._sentryId : undefined;
501516
}
502517

503518
// Start a new debounce timer that will prevent us from capturing multiple events that should be grouped together.
504519
clearTimeout(debounceTimerID);
505520
debounceTimerID = WINDOW.setTimeout(() => {
506-
lastCapturedEvent = undefined;
521+
lastCapturedEventTargetId = undefined;
522+
lastCapturedEventType = undefined;
507523
}, DEBOUNCE_DURATION);
508524
};
509525
}

rollup/plugins/bundlePlugins.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ export function makeTerserPlugin() {
131131
'_meta',
132132
// Object we inject debug IDs into with bundler plugins
133133
'_sentryDebugIds',
134+
// These are used by instrument.ts in utils for identifying HTML elements & events
135+
'_sentryCaptured',
136+
'_sentryId',
134137
],
135138
},
136139
},

0 commit comments

Comments
 (0)