Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ module.exports = [
path: 'packages/browser/build/npm/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '41 KB',
limit: '41.3 KB',
},
{
name: '@sentry/browser (incl. Tracing, Profiling)',
Expand Down Expand Up @@ -127,7 +127,7 @@ module.exports = [
import: createImport('init', 'ErrorBoundary', 'reactRouterV6BrowserTracingIntegration'),
ignore: ['react/jsx-runtime'],
gzip: true,
limit: '43 KB',
limit: '43.3 KB',
},
// Vue SDK (ESM)
{
Expand All @@ -142,7 +142,7 @@ module.exports = [
path: 'packages/vue/build/esm/index.js',
import: createImport('init', 'browserTracingIntegration'),
gzip: true,
limit: '43 KB',
limit: '43.1 KB',
},
// Svelte SDK (ESM)
{
Expand Down Expand Up @@ -190,7 +190,7 @@ module.exports = [
path: createCDNPath('bundle.tracing.min.js'),
gzip: false,
brotli: false,
limit: '124 KB',
limit: '124.1 KB',
},
{
name: 'CDN Bundle (incl. Tracing, Replay) - uncompressed',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ sentryTest(
value: inpValue,
},
},
description: '<unknown>', // FIXME: currently unable to get the target name when element is removed from DOM
description: 'body > nav#navigation > NavigationLink',
exclusive_time: inpValue,
op: 'ui.interaction.click',
origin: 'auto.http.browser.inp',
Expand Down
86 changes: 77 additions & 9 deletions packages/browser-utils/src/metrics/inp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {
getCurrentScope,
getRootSpan,
htmlTreeAsString,
isBrowser,
SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT,
SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
spanToJSON,
} from '@sentry/core';
import { WINDOW } from '../types';
import type { InstrumentationHandlerCallback } from './instrument';
import {
addInpInstrumentationHandler,
Expand All @@ -20,8 +22,16 @@ import {
} from './instrument';
import { getBrowserPerformanceAPI, msToSec, startStandaloneWebVitalSpan } from './utils';

interface InteractionContext {
span: Span | undefined;
elementName: string;
}

const LAST_INTERACTIONS: number[] = [];
const INTERACTIONS_SPAN_MAP = new Map<number, Span>();
const INTERACTIONS_SPAN_MAP = new Map<number, InteractionContext>();

// Map to store element names by timestamp, since we get the DOM event before the PerformanceObserver entry
const ELEMENT_NAME_TIMESTAMP_MAP = new Map<number, string>();

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

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

const spanToUse = cachedSpan || rootSpan;
const spanToUse = cachedInteractionContext?.span || rootSpan;

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

const name = htmlTreeAsString(entry.target);
const name = cachedInteractionContext?.elementName || htmlTreeAsString(entry.target);
const attributes: SpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.inp',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.interaction.${interactionType}`,
Expand Down Expand Up @@ -149,12 +159,65 @@ export const _onInp: InstrumentationHandlerCallback = ({ metric }) => {
* Register a listener to cache route information for INP interactions.
*/
export function registerInpInteractionListener(): void {
// Listen for all interaction events that could contribute to INP
const interactionEvents = Object.keys(INP_ENTRY_MAP);
if (isBrowser()) {
interactionEvents.forEach(eventType => {
WINDOW.addEventListener(eventType, captureElementFromEvent, { capture: true, passive: true });
});
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Event Listener Registration Causes Memory Leak

Event listeners are registered without any mechanism to clean them up or remove them. If registerInpInteractionListener() is called multiple times (e.g., during testing or if Sentry is re-initialized), duplicate event listeners will accumulate, causing a memory leak and potential performance degradation. The function should either return a cleanup/unsubscribe function or include a guard to prevent multiple registrations.

Fix in Cursor Fix in Web

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, but i don't think we clean up the other events at any case.


/**
* Captures the element name from a DOM event and stores it in the ELEMENT_NAME_TIMESTAMP_MAP.
*/
function captureElementFromEvent(event: Event): void {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}

const elementName = htmlTreeAsString(target);
const timestamp = Math.round(event.timeStamp);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a question: why is it rounded here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The event.timeStamp is a DOMHighResTimeStamp, so to increase the chances of a hit on the map I'm rounding it because the decimals might throw it off.


// Store the element name by timestamp so we can match it with the PerformanceEntry
ELEMENT_NAME_TIMESTAMP_MAP.set(timestamp, elementName);

// Clean up old
if (ELEMENT_NAME_TIMESTAMP_MAP.size > 50) {
const firstKey = ELEMENT_NAME_TIMESTAMP_MAP.keys().next().value;
if (firstKey !== undefined) {
ELEMENT_NAME_TIMESTAMP_MAP.delete(firstKey);
}
}
}

/**
* Tries to get the element name from the timestamp map.
*/
function resolveElementNameFromEntry(entry: PerformanceEntry): string {
const timestamp = Math.round(entry.startTime);
let elementName = ELEMENT_NAME_TIMESTAMP_MAP.get(timestamp);

// try nearby timestamps (±5ms)
if (!elementName) {
for (let offset = -5; offset <= 5; offset++) {
const nearbyName = ELEMENT_NAME_TIMESTAMP_MAP.get(timestamp + offset);
if (nearbyName) {
elementName = nearbyName;
break;
}
}
}

return elementName || '<unknown>';
}

const handleEntries = ({ entries }: { entries: PerformanceEntry[] }): void => {
const activeSpan = getActiveSpan();
const activeRootSpan = activeSpan && getRootSpan(activeSpan);

entries.forEach(entry => {
if (!isPerformanceEventTiming(entry) || !activeRootSpan) {
if (!isPerformanceEventTiming(entry)) {
return;
}

Expand All @@ -168,16 +231,21 @@ export function registerInpInteractionListener(): void {
return;
}

const elementName = entry.target ? htmlTreeAsString(entry.target) : resolveElementNameFromEntry(entry);

// We keep max. 10 interactions in the list, then remove the oldest one & clean up
if (LAST_INTERACTIONS.length > 10) {
const last = LAST_INTERACTIONS.shift() as number;
INTERACTIONS_SPAN_MAP.delete(last);
}

// We add the interaction to the list of recorded interactions
// and store the span for this interaction
// and store both the span and element name for this interaction
LAST_INTERACTIONS.push(interactionId);
INTERACTIONS_SPAN_MAP.set(interactionId, activeRootSpan);
INTERACTIONS_SPAN_MAP.set(interactionId, {
span: activeRootSpan,
elementName,
});
});
};

Expand Down
31 changes: 31 additions & 0 deletions packages/browser-utils/test/metrics/inpt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,35 @@ describe('_onInp', () => {
transaction: undefined,
});
});

it('uses <unknown> as element name when entry.target is null and no cached name exists', () => {
const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan');

const metric = {
value: 150,
entries: [
{
name: 'click',
duration: 150,
interactionId: 999,
target: null, // Element was removed from DOM
startTime: 1234567,
},
],
};
// @ts-expect-error - incomplete metric object
_onInp({ metric });

expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledTimes(1);
expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledWith({
attributes: {
'sentry.exclusive_time': 150,
'sentry.op': 'ui.interaction.click',
'sentry.origin': 'auto.http.browser.inp',
},
name: '<unknown>', // Should fall back to <unknown> when element cannot be determined
startTime: expect.any(Number),
transaction: undefined,
});
});
});
Loading