Skip to content

Commit 14ecd30

Browse files
committed
fix: cache element names as interactions happen
1 parent 98de756 commit 14ecd30

File tree

1 file changed

+74
-9
lines changed
  • packages/browser-utils/src/metrics

1 file changed

+74
-9
lines changed

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

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
1313
spanToJSON,
1414
} from '@sentry/core';
15+
import { WINDOW } from '../types';
1516
import type { InstrumentationHandlerCallback } from './instrument';
1617
import {
1718
addInpInstrumentationHandler,
@@ -20,8 +21,16 @@ import {
2021
} from './instrument';
2122
import { getBrowserPerformanceAPI, msToSec, startStandaloneWebVitalSpan } from './utils';
2223

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

2635
/**
2736
* 60 seconds is the maximum for a plausible INP value
@@ -111,17 +120,17 @@ export const _onInp: InstrumentationHandlerCallback = ({ metric }) => {
111120
const activeSpan = getActiveSpan();
112121
const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
113122

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;
123+
// We first try to lookup the interaction context from our INTERACTIONS_SPAN_MAP,
124+
// where we cache the route and element name per interactionId
125+
const cachedInteractionContext = interactionId != null ? INTERACTIONS_SPAN_MAP.get(interactionId) : undefined;
117126

118-
const spanToUse = cachedSpan || rootSpan;
127+
const spanToUse = cachedInteractionContext?.span || rootSpan;
119128

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

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

156216
entries.forEach(entry => {
157-
if (!isPerformanceEventTiming(entry) || !activeRootSpan) {
217+
if (!isPerformanceEventTiming(entry)) {
158218
return;
159219
}
160220

@@ -168,16 +228,21 @@ export function registerInpInteractionListener(): void {
168228
return;
169229
}
170230

231+
const elementName = entry.target ? htmlTreeAsString(entry.target) : resolveElementNameFromEntry(entry);
232+
171233
// We keep max. 10 interactions in the list, then remove the oldest one & clean up
172234
if (LAST_INTERACTIONS.length > 10) {
173235
const last = LAST_INTERACTIONS.shift() as number;
174236
INTERACTIONS_SPAN_MAP.delete(last);
175237
}
176238

177239
// We add the interaction to the list of recorded interactions
178-
// and store the span for this interaction
240+
// and store both the span and element name for this interaction
179241
LAST_INTERACTIONS.push(interactionId);
180-
INTERACTIONS_SPAN_MAP.set(interactionId, activeRootSpan);
242+
INTERACTIONS_SPAN_MAP.set(interactionId, {
243+
span: activeRootSpan,
244+
elementName,
245+
});
181246
});
182247
};
183248

0 commit comments

Comments
 (0)