Skip to content

Commit 94190f8

Browse files
authored
fix(browser-utils): cache element names for INP (#18052)
Fixes interaction elements showing up as `<unknown>` in case they get removed from the DOM. The implementation adds global listeners to listen for interaction events and store the most recent 50 events in an LRU-like cache then matches that with the performance entry timestamp.
1 parent 9bdd19f commit 94190f8

File tree

4 files changed

+113
-14
lines changed

4 files changed

+113
-14
lines changed

.size-limit.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ module.exports = [
3838
path: 'packages/browser/build/npm/esm/index.js',
3939
import: createImport('init', 'browserTracingIntegration'),
4040
gzip: true,
41-
limit: '41 KB',
41+
limit: '41.3 KB',
4242
},
4343
{
4444
name: '@sentry/browser (incl. Tracing, Profiling)',
@@ -127,7 +127,7 @@ module.exports = [
127127
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
128128
ignore: ['react/jsx-runtime'],
129129
gzip: true,
130-
limit: '43 KB',
130+
limit: '43.3 KB',
131131
},
132132
// Vue SDK (ESM)
133133
{
@@ -142,7 +142,7 @@ module.exports = [
142142
path: 'packages/vue/build/esm/index.js',
143143
import: createImport('init', 'browserTracingIntegration'),
144144
gzip: true,
145-
limit: '43 KB',
145+
limit: '43.1 KB',
146146
},
147147
// Svelte SDK (ESM)
148148
{
@@ -190,7 +190,7 @@ module.exports = [
190190
path: createCDNPath('bundle.tracing.min.js'),
191191
gzip: false,
192192
brotli: false,
193-
limit: '124 KB',
193+
limit: '124.1 KB',
194194
},
195195
{
196196
name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed',

dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ sentryTest(
159159
value: inpValue,
160160
},
161161
},
162-
description: '<unknown>', // FIXME: currently unable to get the target name when element is removed from DOM
162+
description: 'body > nav#navigation > NavigationLink',
163163
exclusive_time: inpValue,
164164
op: 'ui.interaction.click',
165165
origin: 'auto.http.browser.inp',

packages/browser-utils/src/metrics/inp.ts

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ import {
55
getCurrentScope,
66
getRootSpan,
77
htmlTreeAsString,
8+
isBrowser,
89
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
910
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
1011
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
1112
SEMANTIC_ATTRIBUTE_SENTRY_OP,
1213
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1314
spanToJSON,
1415
} from '@sentry/core';
16+
import { WINDOW } from '../types';
1517
import type { InstrumentationHandlerCallback } from './instrument';
1618
import {
1719
addInpInstrumentationHandler,
@@ -20,8 +22,16 @@ import {
2022
} from './instrument';
2123
import { getBrowserPerformanceAPI, msToSec, startStandaloneWebVitalSpan } from './utils';
2224

25+
interface InteractionContext {
26+
span: Span | undefined;
27+
elementName: string;
28+
}
29+
2330
const LAST_INTERACTIONS: number[] = [];
24-
const INTERACTIONS_SPAN_MAP = new Map<number, Span>();
31+
const INTERACTIONS_SPAN_MAP = new Map<number, InteractionContext>();
32+
33+
// Map to store element names by timestamp, since we get the DOM event before the PerformanceObserver entry
34+
const ELEMENT_NAME_TIMESTAMP_MAP = new Map<number, string>();
2535

2636
/**
2737
* 60 seconds is the maximum for a plausible INP value
@@ -111,17 +121,17 @@ export const _onInp: InstrumentationHandlerCallback = ({ metric }) => {
111121
const activeSpan = getActiveSpan();
112122
const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
113123

114-
// We first try to lookup the span from our INTERACTIONS_SPAN_MAP,
115-
// where we cache the route per interactionId
116-
const cachedSpan = interactionId != null ? INTERACTIONS_SPAN_MAP.get(interactionId) : undefined;
124+
// We first try to lookup the interaction context from our INTERACTIONS_SPAN_MAP,
125+
// where we cache the route and element name per interactionId
126+
const cachedInteractionContext = interactionId != null ? INTERACTIONS_SPAN_MAP.get(interactionId) : undefined;
117127

118-
const spanToUse = cachedSpan || rootSpan;
128+
const spanToUse = cachedInteractionContext?.span || rootSpan;
119129

120130
// Else, we try to use the active span.
121131
// Finally, we fall back to look at the transactionName on the scope
122132
const routeName = spanToUse ? spanToJSON(spanToUse).description : getCurrentScope().getScopeData().transactionName;
123133

124-
const name = htmlTreeAsString(entry.target);
134+
const name = cachedInteractionContext?.elementName || htmlTreeAsString(entry.target);
125135
const attributes: SpanAttributes = {
126136
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.inp',
127137
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.interaction.${interactionType}`,
@@ -149,12 +159,65 @@ export const _onInp: InstrumentationHandlerCallback = ({ metric }) => {
149159
* Register a listener to cache route information for INP interactions.
150160
*/
151161
export function registerInpInteractionListener(): void {
162+
// Listen for all interaction events that could contribute to INP
163+
const interactionEvents = Object.keys(INP_ENTRY_MAP);
164+
if (isBrowser()) {
165+
interactionEvents.forEach(eventType => {
166+
WINDOW.addEventListener(eventType, captureElementFromEvent, { capture: true, passive: true });
167+
});
168+
}
169+
170+
/**
171+
* Captures the element name from a DOM event and stores it in the ELEMENT_NAME_TIMESTAMP_MAP.
172+
*/
173+
function captureElementFromEvent(event: Event): void {
174+
const target = event.target as HTMLElement | null;
175+
if (!target) {
176+
return;
177+
}
178+
179+
const elementName = htmlTreeAsString(target);
180+
const timestamp = Math.round(event.timeStamp);
181+
182+
// Store the element name by timestamp so we can match it with the PerformanceEntry
183+
ELEMENT_NAME_TIMESTAMP_MAP.set(timestamp, elementName);
184+
185+
// Clean up old
186+
if (ELEMENT_NAME_TIMESTAMP_MAP.size > 50) {
187+
const firstKey = ELEMENT_NAME_TIMESTAMP_MAP.keys().next().value;
188+
if (firstKey !== undefined) {
189+
ELEMENT_NAME_TIMESTAMP_MAP.delete(firstKey);
190+
}
191+
}
192+
}
193+
194+
/**
195+
* Tries to get the element name from the timestamp map.
196+
*/
197+
function resolveElementNameFromEntry(entry: PerformanceEntry): string {
198+
const timestamp = Math.round(entry.startTime);
199+
let elementName = ELEMENT_NAME_TIMESTAMP_MAP.get(timestamp);
200+
201+
// try nearby timestamps (±5ms)
202+
if (!elementName) {
203+
for (let offset = -5; offset <= 5; offset++) {
204+
const nearbyName = ELEMENT_NAME_TIMESTAMP_MAP.get(timestamp + offset);
205+
if (nearbyName) {
206+
elementName = nearbyName;
207+
break;
208+
}
209+
}
210+
}
211+
212+
return elementName || '<unknown>';
213+
}
214+
152215
const handleEntries = ({ entries }: { entries: PerformanceEntry[] }): void => {
153216
const activeSpan = getActiveSpan();
154217
const activeRootSpan = activeSpan && getRootSpan(activeSpan);
155218

156219
entries.forEach(entry => {
157-
if (!isPerformanceEventTiming(entry) || !activeRootSpan) {
220+
if (!isPerformanceEventTiming(entry)) {
158221
return;
159222
}
160223

@@ -168,16 +231,21 @@ export function registerInpInteractionListener(): void {
168231
return;
169232
}
170233

234+
const elementName = entry.target ? htmlTreeAsString(entry.target) : resolveElementNameFromEntry(entry);
235+
171236
// We keep max. 10 interactions in the list, then remove the oldest one & clean up
172237
if (LAST_INTERACTIONS.length > 10) {
173238
const last = LAST_INTERACTIONS.shift() as number;
174239
INTERACTIONS_SPAN_MAP.delete(last);
175240
}
176241

177242
// We add the interaction to the list of recorded interactions
178-
// and store the span for this interaction
243+
// and store both the span and element name for this interaction
179244
LAST_INTERACTIONS.push(interactionId);
180-
INTERACTIONS_SPAN_MAP.set(interactionId, activeRootSpan);
245+
INTERACTIONS_SPAN_MAP.set(interactionId, {
246+
span: activeRootSpan,
247+
elementName,
248+
});
181249
});
182250
};
183251

packages/browser-utils/test/metrics/inpt.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,35 @@ describe('_onInp', () => {
113113
transaction: undefined,
114114
});
115115
});
116+
117+
it('uses <unknown> as element name when entry.target is null and no cached name exists', () => {
118+
const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan');
119+
120+
const metric = {
121+
value: 150,
122+
entries: [
123+
{
124+
name: 'click',
125+
duration: 150,
126+
interactionId: 999,
127+
target: null, // Element was removed from DOM
128+
startTime: 1234567,
129+
},
130+
],
131+
};
132+
// @ts-expect-error - incomplete metric object
133+
_onInp({ metric });
134+
135+
expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledTimes(1);
136+
expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledWith({
137+
attributes: {
138+
'sentry.exclusive_time': 150,
139+
'sentry.op': 'ui.interaction.click',
140+
'sentry.origin': 'auto.http.browser.inp',
141+
},
142+
name: '<unknown>', // Should fall back to <unknown> when element cannot be determined
143+
startTime: expect.any(Number),
144+
transaction: undefined,
145+
});
146+
});
116147
});

0 commit comments

Comments
 (0)