Skip to content

fix(browser): Ignore unrealistically long INP values #16484

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jun 5, 2025
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
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ module.exports = [
import: createImport('init'),
ignore: ['next/router', 'next/constants'],
gzip: true,
limit: '42 KB',
limit: '43 KB',
},
// SvelteKit SDK (ESM)
{
Expand Down
109 changes: 65 additions & 44 deletions packages/browser-utils/src/metrics/inp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
spanToJSON,
} from '@sentry/core';
import type { InstrumentationHandlerCallback } from './instrument';
import {
addInpInstrumentationHandler,
addPerformanceInstrumentationHandler,
Expand All @@ -22,6 +23,11 @@ import { getBrowserPerformanceAPI, msToSec, startStandaloneWebVitalSpan } from '
const LAST_INTERACTIONS: number[] = [];
const INTERACTIONS_SPAN_MAP = new Map<number, Span>();

/**
* 60 seconds is the maximum for a plausible INP value
* (source: Me)
Copy link
Member

Choose a reason for hiding this comment

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

😂
I hope the git blame stays with your name

*/
const MAX_PLAUSIBLE_INP_DURATION = 60;
/**
* Start tracking INP webvital events.
*/
Expand Down Expand Up @@ -67,62 +73,77 @@ const INP_ENTRY_MAP: Record<string, 'click' | 'hover' | 'drag' | 'press'> = {
input: 'press',
};

/** Starts tracking the Interaction to Next Paint on the current page. */
function _trackINP(): () => void {
return addInpInstrumentationHandler(({ metric }) => {
if (metric.value == undefined) {
return;
}
/** Starts tracking the Interaction to Next Paint on the current page. #
* exported only for testing
*/
export function _trackINP(): () => void {
return addInpInstrumentationHandler(_onInp);
}

/**
* exported only for testing
*/
export const _onInp: InstrumentationHandlerCallback = ({ metric }) => {
if (metric.value == undefined) {
return;
}

const entry = metric.entries.find(entry => entry.duration === metric.value && INP_ENTRY_MAP[entry.name]);
const duration = msToSec(metric.value);

if (!entry) {
return;
}
// We received occasional reports of hour-long INP values.
// Therefore, we add a sanity check to avoid creating spans for
// unrealistically long INP durations.
if (duration > MAX_PLAUSIBLE_INP_DURATION) {
return;
}

const { interactionId } = entry;
const interactionType = INP_ENTRY_MAP[entry.name];
const entry = metric.entries.find(entry => entry.duration === metric.value && INP_ENTRY_MAP[entry.name]);

/** Build the INP span, create an envelope from the span, and then send the envelope */
const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime);
const duration = msToSec(metric.value);
const activeSpan = getActiveSpan();
const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;
if (!entry) {
return;
}

// 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;
const { interactionId } = entry;
const interactionType = INP_ENTRY_MAP[entry.name];

const spanToUse = cachedSpan || rootSpan;
/** Build the INP span, create an envelope from the span, and then send the envelope */
const startTime = msToSec((browserPerformanceTimeOrigin() as number) + entry.startTime);
const activeSpan = getActiveSpan();
const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined;

// 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;
// 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;

const name = htmlTreeAsString(entry.target);
const attributes: SpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.inp',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.interaction.${interactionType}`,
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration,
};
const spanToUse = cachedSpan || rootSpan;

const span = startStandaloneWebVitalSpan({
name,
transaction: routeName,
attributes,
startTime,
});
// 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;

if (span) {
span.addEvent('inp', {
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond',
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: metric.value,
});
const name = htmlTreeAsString(entry.target);
const attributes: SpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.http.browser.inp',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: `ui.interaction.${interactionType}`,
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry.duration,
};

span.end(startTime + duration);
}
const span = startStandaloneWebVitalSpan({
name,
transaction: routeName,
attributes,
startTime,
});
}

if (span) {
span.addEvent('inp', {
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond',
[SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: metric.value,
});

span.end(startTime + duration);
}
};

/**
* Register a listener to cache route information for INP interactions.
Expand Down
10 changes: 7 additions & 3 deletions packages/browser-utils/src/metrics/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,17 @@ export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric
return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb);
}

export type InstrumentationHandlerCallback = (data: {
metric: Omit<Metric, 'entries'> & {
entries: PerformanceEventTiming[];
};
}) => void;

/**
* Add a callback that will be triggered when a INP metric is available.
* Returns a cleanup callback which can be called to remove the instrumentation handler.
*/
export function addInpInstrumentationHandler(
callback: (data: { metric: Omit<Metric, 'entries'> & { entries: PerformanceEventTiming[] } }) => void,
): CleanupHandlerCallback {
export function addInpInstrumentationHandler(callback: InstrumentationHandlerCallback): CleanupHandlerCallback {
return addMetricObserver('inp', callback, instrumentInp, _previousInp);
}

Expand Down
116 changes: 116 additions & 0 deletions packages/browser-utils/test/instrument/metrics/inpt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { afterEach } from 'node:test';
import { describe, expect, it, vi } from 'vitest';
import { _onInp, _trackINP } from '../../../src/metrics/inp';
import * as instrument from '../../../src/metrics/instrument';
import * as utils from '../../../src/metrics/utils';

describe('_trackINP', () => {
const addInpInstrumentationHandler = vi.spyOn(instrument, 'addInpInstrumentationHandler');

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

it('adds an instrumentation handler', () => {
_trackINP();
expect(addInpInstrumentationHandler).toHaveBeenCalledOnce();
});

it('returns an unsubscribe dunction', () => {
const handler = _trackINP();
expect(typeof handler).toBe('function');
});
});

describe('_onInp', () => {
it('early-returns if the INP metric entry has no value', () => {
const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan');

const metric = {
value: undefined,
entries: [],
};
// @ts-expect-error - incomplete metric object
_onInp({ metric });

expect(startStandaloneWebVitalSpanSpy).not.toHaveBeenCalled();
});

it('early-returns if the INP metric value is greater than 60 seconds', () => {
const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan');

const metric = {
value: 60_001,
entries: [
{ name: 'click', duration: 60_001, interactionId: 1 },
{ name: 'click', duration: 60_000, interactionId: 2 },
],
};
// @ts-expect-error - incomplete metric object
_onInp({ metric });

expect(startStandaloneWebVitalSpanSpy).not.toHaveBeenCalled();
});

it('early-returns if the inp metric has an unknown interaction type', () => {
const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan');

const metric = {
value: 10,
entries: [{ name: 'unknown', duration: 10, interactionId: 1 }],
};
// @ts-expect-error - incomplete metric object
_onInp({ metric });

expect(startStandaloneWebVitalSpanSpy).not.toHaveBeenCalled();
});

it('starts a span for a valid INP metric entry', () => {
const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan');

const metric = {
value: 10,
entries: [{ name: 'click', duration: 10, interactionId: 1 }],
};
// @ts-expect-error - incomplete metric object
_onInp({ metric });

expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledTimes(1);
expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledWith({
attributes: {
'sentry.exclusive_time': 10,
'sentry.op': 'ui.interaction.click',
'sentry.origin': 'auto.http.browser.inp',
},
name: '<unknown>',
startTime: NaN,
transaction: undefined,
});
});

it('takes the correct entry based on the metric value', () => {
const startStandaloneWebVitalSpanSpy = vi.spyOn(utils, 'startStandaloneWebVitalSpan');

const metric = {
value: 10,
entries: [
{ name: 'click', duration: 10, interactionId: 1 },
{ name: 'click', duration: 9, interactionId: 2 },
],
};
// @ts-expect-error - incomplete metric object
_onInp({ metric });

expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledTimes(1);
expect(startStandaloneWebVitalSpanSpy).toHaveBeenCalledWith({
attributes: {
'sentry.exclusive_time': 10,
'sentry.op': 'ui.interaction.click',
'sentry.origin': 'auto.http.browser.inp',
},
name: '<unknown>',
startTime: NaN,
transaction: undefined,
});
});
});
Loading