diff --git a/.size-limit.js b/.size-limit.js index 10efb849a582..c3105a772987 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -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) { diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts index 05b7d7ed17a8..30a628b5997f 100644 --- a/packages/browser-utils/src/metrics/inp.ts +++ b/packages/browser-utils/src/metrics/inp.ts @@ -12,6 +12,7 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, } from '@sentry/core'; +import type { InstrumentationHandlerCallback } from './instrument'; import { addInpInstrumentationHandler, addPerformanceInstrumentationHandler, @@ -22,6 +23,11 @@ import { getBrowserPerformanceAPI, msToSec, startStandaloneWebVitalSpan } from ' const LAST_INTERACTIONS: number[] = []; const INTERACTIONS_SPAN_MAP = new Map(); +/** + * 60 seconds is the maximum for a plausible INP value + * (source: Me) + */ +const MAX_PLAUSIBLE_INP_DURATION = 60; /** * Start tracking INP webvital events. */ @@ -67,62 +73,77 @@ const INP_ENTRY_MAP: Record = { 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. diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 7b9d7e562f37..cb84908ce55b 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -158,13 +158,17 @@ export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb); } +export type InstrumentationHandlerCallback = (data: { + metric: Omit & { + 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 & { entries: PerformanceEventTiming[] } }) => void, -): CleanupHandlerCallback { +export function addInpInstrumentationHandler(callback: InstrumentationHandlerCallback): CleanupHandlerCallback { return addMetricObserver('inp', callback, instrumentInp, _previousInp); } diff --git a/packages/browser-utils/test/instrument/metrics/inpt.test.ts b/packages/browser-utils/test/instrument/metrics/inpt.test.ts new file mode 100644 index 000000000000..437ae650d0fe --- /dev/null +++ b/packages/browser-utils/test/instrument/metrics/inpt.test.ts @@ -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: '', + 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: '', + startTime: NaN, + transaction: undefined, + }); + }); +});