Skip to content
Open
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
19 changes: 18 additions & 1 deletion packages/browser-utils/src/web-vitals/inp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ const LAST_INTERACTIONS: number[] = [];
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
/** What `htmlTreeAsString` returns when it cannot describe the target. */
const UNKNOWN_ELEMENT_NAME = '<unknown>';

const ELEMENT_NAME_TIMESTAMP_MAP = new Map<number, string>();

/**
Expand Down Expand Up @@ -84,6 +87,20 @@ export function registerInpInteractionListener(): void {
const elementName = htmlTreeAsString(target);
const timestamp = Math.round(event.timeStamp);

// Not every event of an interaction has a describable target, and one that doesn't would
// otherwise claim the timestamp and leave the span unnamed.
if (!elementName || elementName === UNKNOWN_ELEMENT_NAME) {
return;
}

// Every event of one interaction shares a timestamp, and so do the `pointerover`/`mouseover`
// the browser fires afterwards when a handler swaps out the element under the cursor. Those
// arrive last and describe the new DOM, so keeping the first usable name is what pins the entry
Comment on lines +96 to +98

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: Two separate interactions within the same millisecond can be assigned the same element name, as they round to the same timestamp key in ELEMENT_NAME_TIMESTAMP_MAP, causing incorrect attribution.
Severity: LOW

Suggested Fix

To prevent collisions between different interactions, the cache key should be more specific. Instead of relying solely on a rounded timestamp, consider a composite key that includes the interactionId or scope the element name cache on a per-interaction basis. This would ensure that element names from one interaction cannot be accidentally used for another.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/browser-utils/src/web-vitals/inp.ts#L96-L98

Potential issue: The code uses `Math.round(event.timeStamp)` as a key to cache element
names in `ELEMENT_NAME_TIMESTAMP_MAP`. If two distinct user interactions occur within
the same millisecond, they can round to the same integer key. The
`ELEMENT_NAME_TIMESTAMP_MAP.has(timestamp)` check prevents the second interaction's
element name from being stored. Consequently, when `resolveElementNameFromEntry` is
called for the second interaction, it incorrectly retrieves the cached element name from
the first interaction, leading to incorrect analytics data. While this scenario is rare,
it represents a logical flaw where separate interactions can have their data
misattributed.

Did we get this right? 👍 / 👎 to inform future reviews.

// to the element that was actually interacted with.
if (ELEMENT_NAME_TIMESTAMP_MAP.has(timestamp)) {
return;
}

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

Expand Down Expand Up @@ -114,7 +131,7 @@ export function registerInpInteractionListener(): void {
}
}

return elementName || '<unknown>';
return elementName || UNKNOWN_ELEMENT_NAME;
}

const handleEntries = ({ entries }: { entries: PerformanceEntry[] }): void => {
Expand Down
98 changes: 98 additions & 0 deletions packages/browser-utils/test/web-vitals/inp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
/**
* @vitest-environment jsdom
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const windowListeners = vi.hoisted(() => new Map<string, (event: unknown) => void>());
const performanceHandlers = vi.hoisted(() => new Map<string, (data: { entries: unknown[] }) => void>());

// `isBrowser()` is false under vitest even with the jsdom environment, and it gates the listeners.
vi.mock('@sentry/core', async () => {
const actual = await vi.importActual('@sentry/core');
return { ...actual, isBrowser: () => true };
});

vi.mock('../../src/types', () => ({
WINDOW: {
addEventListener: (type: string, listener: (event: unknown) => void) => windowListeners.set(type, listener),
},
}));

vi.mock('../../src/instrumentation/performanceObserver', async () => {
const actual = await vi.importActual('../../src/instrumentation/performanceObserver');
return {
...actual,
addPerformanceInstrumentationHandler: (type: string, callback: (data: { entries: unknown[] }) => void) => {
performanceHandlers.set(type, callback);
return () => undefined;
},
};
});

/** Each test needs a fresh module: the element name cache is per page, so it's module-level. */
async function loadInp() {
vi.resetModules();
return import('../../src/web-vitals/inp');
}

/** A target `htmlTreeAsString` can describe, versus one it cannot (yields `<unknown>`). */
function element(id: string): unknown {
return { tagName: 'DIV', id, nodeType: 1 };
}

/** Names the interaction by feeding an entry whose `target` is gone, forcing the timestamp lookup. */
function nameFor(interactionId: number, startTime: number): void {
performanceHandlers.get('event')?.({
entries: [{ entryType: 'event', name: 'click', interactionId, startTime, duration: 100, target: null }],
});
}

describe('INP element name cache', () => {
beforeEach(() => {
windowListeners.clear();
performanceHandlers.clear();
});

afterEach(() => {
vi.clearAllMocks();
});

it('keeps the first name when later events of the same interaction describe a different element', async () => {
const { getCachedInteractionContext, registerInpInteractionListener } = await loadInp();
registerInpInteractionListener();

// The whole sequence shares a timestamp. The trailing `pointerover` is what the browser fires
// once the click handler has swapped out the element under the cursor.
windowListeners.get('click')?.({ target: element('clicked'), timeStamp: 1000 });
windowListeners.get('pointerover')?.({ target: element('replacement'), timeStamp: 1000 });

nameFor(42, 1000);

expect(getCachedInteractionContext(42)?.elementName).toBe('div#clicked');
});

it('replaces an unresolvable name with a real one from the same interaction', async () => {
const { getCachedInteractionContext, registerInpInteractionListener } = await loadInp();
registerInpInteractionListener();

// Targets that are not elements cannot be described, and they can come first in the sequence.
windowListeners.get('pointerover')?.({ target: {}, timeStamp: 2000 });
windowListeners.get('click')?.({ target: element('clicked'), timeStamp: 2000 });

nameFor(43, 2000);

expect(getCachedInteractionContext(43)?.elementName).toBe('div#clicked');
});

it('reports an unresolvable name when nothing in the interaction resolves', async () => {
const { getCachedInteractionContext, registerInpInteractionListener } = await loadInp();
registerInpInteractionListener();

windowListeners.get('click')?.({ target: {}, timeStamp: 3000 });

nameFor(44, 3000);

expect(getCachedInteractionContext(44)?.elementName).toBe('<unknown>');
});
});
Loading