Skip to content

Commit 3f15aab

Browse files
committed
fix(utils): Avoid keeping a reference of last used event
1 parent a5e8424 commit 3f15aab

File tree

6 files changed

+172
-8
lines changed

6 files changed

+172
-8
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: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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, url);
23+
24+
await page.click('#button1');
25+
// not debounced because other target
26+
await page.click('#button2');
27+
// This should be debounced
28+
await page.click('#button2');
29+
30+
// Wait a second for the debounce to finish
31+
await page.waitForTimeout(1000);
32+
await page.click('#button2');
33+
34+
await page.evaluate('Sentry.captureException("test exception")');
35+
36+
const eventData = await promise;
37+
38+
expect(eventData.exception?.values).toHaveLength(1);
39+
40+
expect(eventData.breadcrumbs).toEqual([
41+
{
42+
timestamp: expect.any(Number),
43+
category: 'ui.click',
44+
message: 'body > button#button1[type="button"]',
45+
},
46+
{
47+
timestamp: expect.any(Number),
48+
category: 'ui.click',
49+
message: 'body > button#button2[type="button"]',
50+
},
51+
{
52+
timestamp: expect.any(Number),
53+
category: 'ui.click',
54+
message: 'body > button#button2[type="button"]',
55+
},
56+
]);
57+
});
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://[email protected]/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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
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, url);
23+
24+
await page.click('#input1');
25+
// Not debounced because other event type
26+
await page.type('#input1', 'John');
27+
// This should be debounced
28+
await page.type('#input1', 'Abby');
29+
// not debounced because other target
30+
await page.type('#input2', 'Anne');
31+
32+
// Wait a second for the debounce to finish
33+
await page.waitForTimeout(1000);
34+
await page.type('#input2', 'John');
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 > input#input1[type="text"]',
47+
},
48+
{
49+
timestamp: expect.any(Number),
50+
category: 'ui.input',
51+
message: 'body > input#input1[type="text"]',
52+
},
53+
{
54+
timestamp: expect.any(Number),
55+
category: 'ui.input',
56+
message: 'body > input#input2[type="text"]',
57+
},
58+
{
59+
timestamp: expect.any(Number),
60+
category: 'ui.input',
61+
message: 'body > input#input2[type="text"]',
62+
},
63+
]);
64+
});

packages/utils/src/instrument.ts

Lines changed: 19 additions & 8 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 = EventTarget & { _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) {
@@ -486,24 +490,31 @@ function makeDOMEventHandler(handler: Function, globalListener: boolean = false)
486490
// Mark event as "seen"
487491
addNonEnumerableProperty(event, '_sentryCaptured', true);
488492

493+
if (event.target && !(event.target as SentryWrappedTarget)._sentryId) {
494+
// Add UUID to event target so we can identify if
495+
addNonEnumerableProperty(event.target, '_sentryId', uuid4());
496+
}
497+
489498
const name = event.type === 'keypress' ? 'input' : event.type;
490499

491500
// If there is no last captured event, it means that we can safely capture the new event and store it for future comparisons.
492501
// If there is a last captured event, see if the new event is different enough to treat it as a unique one.
493502
// If that's the case, emit the previous event and store locally the newly-captured DOM event.
494-
if (lastCapturedEvent === undefined || !areSimilarDomEvents(lastCapturedEvent, event)) {
503+
if (!isSimilarToLastCapturedEvent(event)) {
495504
handler({
496505
event: event,
497506
name,
498507
global: globalListener,
499508
});
500-
lastCapturedEvent = event;
509+
lastCapturedEventType = event.type;
510+
lastCapturedEventTargetId = event.target ? (event.target as SentryWrappedTarget)._sentryId : undefined;
501511
}
502512

503513
// Start a new debounce timer that will prevent us from capturing multiple events that should be grouped together.
504514
clearTimeout(debounceTimerID);
505515
debounceTimerID = WINDOW.setTimeout(() => {
506-
lastCapturedEvent = undefined;
516+
lastCapturedEventTargetId = undefined;
517+
lastCapturedEventType = undefined;
507518
}, DEBOUNCE_DURATION);
508519
};
509520
}

0 commit comments

Comments
 (0)