Skip to content

Commit 2bff2ea

Browse files
authored
feat(browser): Report the INP target and interaction type as attributes (#24573)
The element and interaction type an INP was reported on only existed as the span's name and op, which always have a value and so mislabel an INP reported without an interaction as a click, fixed by reporting them as `browser.web_vital.inp.target` and `browser.web_vital.inp.interaction_type`, set only for what was actually observed. I added unit tests for an unresolved element and for an INP without an entry, and the INP browser integration tests now expect both attributes. Question for the review: should the "unknown" case be reported as `<unknown>` or as `unknown` without the brackets, or omit it entirely? I chose to omit it for now. ref Conventions PR: getsentry/sentry-conventions#641
1 parent b633c81 commit 2bff2ea

9 files changed

Lines changed: 59 additions & 6 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Work in this release was contributed by @psh4607, @thijsw, @trinitiwowka, @nehap
99
- ref(browser)!: LCP and CLS spans no longer set `browser.web_vital.lcp.report_event` and `browser.web_vital.cls.report_event`. With per-navigation web vitals (the default) the attribute was already never set; it is now also gone when `softNavigations` and `bfcacheNavigations` are turned off. When the values are finalized is unchanged.
1010
- feat(browser)!: `browser.navigation.type` on web vital and bfcache navigation spans now carries the navigation type exactly as web-vitals reports it. `bfcache` is now `back-forward-cache`, and a back/forward navigation that missed the bfcache (`back-forward`) or a discarded-tab restore (`restore`) is no longer folded into `navigate`. Update any dashboards or alerts filtering on `bfcache`.
1111
- feat(browser): The pageload span now carries `browser.navigation.type`, the navigation type web-vitals reported TTFB and FCP for. Those vitals, and FP, are attributes of the pageload span rather than spans of their own, so until now there was no navigation type to read them against.
12+
- feat(browser): INP spans now carry `browser.web_vital.inp.target`, the element the user interacted with, and `browser.web_vital.inp.interaction_type` (`click`, `hover`, `drag` or `press`). Both were only available as the span's name and op, which always have a value. The attributes are left out when there is nothing to describe: an INP that web-vitals reports without an interaction gets neither, and an element that could not be resolved gets no target.
1213
- feat(core): Add `createFetchIntegration`, the shared implementation behind the global-`fetch` integrations in `@sentry/bun`, `@sentry/cloudflare`, `@sentry/deno` and `@sentry/vercel-edge`. Those four packages carried four copies of it; they now share one. Two changes come out of that:
1314
- All four gain a `tracePropagation` option (default `true`). Turn it off to stop injecting `sentry-trace` and `baggage` without also turning off spans. To scope propagation to specific URLs, keep using `tracePropagationTargets` in the client options.
1415
- Integration options now follow the client. Previously a second `Sentry.init()` in the same process silently reused the options of the first one.

‎dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-late/test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ sentryTest(
7777
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
7878
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
7979
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
80+
'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' },
81+
'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' },
8082
'browser.navigation.type': { value: 'navigate', type: 'string' },
8183
'sentry.transaction': { value: 'test-url', type: 'string' },
8284
'sentry.segment.name': { value: 'test-url', type: 'string' },

‎dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-navigate/test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ sentryTest(
7777
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
7878
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
7979
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
80+
'browser.web_vital.inp.target': { value: 'body > nav#navigation > NavigationLink', type: 'string' },
81+
'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' },
8082
'browser.navigation.type': { value: 'navigate', type: 'string' },
8183
'sentry.transaction': { value: 'test-url', type: 'string' },
8284
'sentry.segment.name': { value: 'test-url', type: 'string' },
@@ -163,6 +165,8 @@ sentryTest(
163165
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
164166
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
165167
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
168+
'browser.web_vital.inp.target': { value: 'body > nav#navigation > NavigationLink', type: 'string' },
169+
'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' },
166170
'browser.navigation.type': { value: 'navigate', type: 'string' },
167171
'sentry.transaction': { value: 'test-url', type: 'string' },
168172
'sentry.segment.name': { value: 'test-url', type: 'string' },

‎dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized-late/test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ sentryTest(
7878
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
7979
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
8080
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
81+
'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' },
82+
'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' },
8183
'browser.navigation.type': { value: 'navigate', type: 'string' },
8284
// the parametrized route name flows onto the INP span
8385
'sentry.transaction': { value: 'test-route', type: 'string' },

‎dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp-parametrized/test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,8 @@ sentryTest(
7878
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
7979
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
8080
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
81+
'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' },
82+
'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' },
8183
'browser.navigation.type': { value: 'navigate', type: 'string' },
8284
// the parametrized route name flows onto the INP span
8385
'sentry.transaction': { value: 'test-route', type: 'string' },

‎dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ sentryTest(
8181
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
8282
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
8383
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
84+
'browser.web_vital.inp.target': { value: 'body > NormalButton', type: 'string' },
85+
'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' },
8486
'browser.navigation.type': { value: 'navigate', type: 'string' },
8587
'sentry.transaction': { value: 'test-url', type: 'string' },
8688
'sentry.segment.name': { value: 'test-url', type: 'string' },
@@ -148,6 +150,8 @@ sentryTest(
148150
'sentry.op': { value: 'ui.interaction.click', type: 'string' },
149151
'sentry.exclusive_time': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
150152
'browser.web_vital.inp.value': { value: inpValue, type: expect.stringMatching(/^(integer)|(double)$/) },
153+
'browser.web_vital.inp.target': { value: 'body > SlowButton', type: 'string' },
154+
'browser.web_vital.inp.interaction_type': { value: 'click', type: 'string' },
151155
'browser.navigation.type': { value: 'navigate', type: 'string' },
152156
'sentry.transaction': { value: 'test-url', type: 'string' },
153157
'sentry.segment.name': { value: 'test-url', type: 'string' },

‎packages/browser-utils/src/web-vitals/inp.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const INTERACTIONS_SPAN_MAP = new Map<number, InteractionContext>();
1414

1515
// Map to store element names by timestamp, since we get the DOM event before the PerformanceObserver entry
1616
/** What `htmlTreeAsString` returns when it cannot describe the target. */
17-
const UNKNOWN_ELEMENT_NAME = '<unknown>';
17+
export const UNKNOWN_ELEMENT_NAME = '<unknown>';
1818

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

‎packages/browser-utils/src/web-vitals/spans.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import { DEBUG_BUILD } from '../debug-build';
1414
import { htmlTreeAsString } from '../htmlTreeAsString';
1515
import type { InteractionType } from './inp';
16-
import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION } from './inp';
16+
import { getCachedInteractionContext, INP_ENTRY_MAP, MAX_PLAUSIBLE_INP_DURATION, UNKNOWN_ELEMENT_NAME } from './inp';
1717
import type { InstrumentationHandlerCallback, MetricNavigationType } from '../instrumentation/performanceObserver';
1818
import {
1919
addClsInstrumentationHandler,
@@ -374,7 +374,8 @@ export function _sendInpSpan(
374374
// `ui.interaction.*` family, because falling outside it would hide exactly the fast navigations
375375
// that web-vitals synthesizes these values for (GoogleChrome/web-vitals#724), reintroducing the
376376
// reporting bias they were added to remove.
377-
const interactionType = (entry && INP_ENTRY_MAP[entry.name]) || 'click';
377+
const entryInteractionType = entry && INP_ENTRY_MAP[entry.name];
378+
const interactionType = entryInteractionType || 'click';
378379

379380
const cachedContext = entry && getCachedInteractionContext(entry.interactionId);
380381
const activeSpan = getActiveSpan();
@@ -385,15 +386,24 @@ export function _sendInpSpan(
385386
const spanToUse = attributedSpan || cachedContext?.span || rootSpan;
386387
const name = cachedContext?.elementName || (entry ? htmlTreeAsString(entry.target) : 'Interaction to next paint');
387388

389+
const attributes: SpanAttributes = {
390+
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration ?? inpValue,
391+
};
392+
393+
// The span's name and op always have a value, even for an INP without an entry, so they can't
394+
// say whether there was an interaction to describe. These attributes can: they are only set for
395+
// what was actually observed.
396+
// TODO: use the `@sentry/conventions` constants once getsentry/sentry-conventions#641 is released.
397+
entry && name !== UNKNOWN_ELEMENT_NAME && (attributes['browser.web_vital.inp.target'] = name);
398+
entryInteractionType && (attributes['browser.web_vital.inp.interaction_type'] = entryInteractionType);
399+
388400
_emitWebVitalSpan({
389401
name,
390402
op: INTERACTION_TYPE_TO_SPAN_OP[interactionType],
391403
origin: 'auto.http.browser.inp',
392404
metricName: 'inp',
393405
value: inpValue,
394-
attributes: {
395-
[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: entry?.duration ?? inpValue,
396-
},
406+
attributes,
397407
startTime,
398408
endTime: startTime + duration,
399409
navigationType: metric?.navigationType,

‎packages/browser-utils/test/web-vitals/spans.test.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,8 @@ describe('_sendInpSpan', () => {
605605
'sentry.exclusive_time': 120,
606606
'sentry.transaction': 'test-route',
607607
'sentry.segment.name': 'test-route',
608+
'browser.web_vital.inp.target': '<button>',
609+
'browser.web_vital.inp.interaction_type': 'click',
608610
}),
609611
}),
610612
);
@@ -669,6 +671,32 @@ describe('_sendInpSpan', () => {
669671
}),
670672
);
671673
});
674+
675+
it('leaves out the target when the element could not be resolved', () => {
676+
vi.spyOn(inpModule, 'getCachedInteractionContext').mockReturnValue(undefined);
677+
vi.mocked(htmlTreeAsString).mockReturnValue('<unknown>');
678+
679+
_sendInpSpan(80, { name: 'keydown', startTime: 600, duration: 80, interactionId: 2, target: null } as any);
680+
681+
const attributes = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls[0]![0].attributes!;
682+
expect(attributes).not.toHaveProperty('browser.web_vital.inp.target');
683+
expect(attributes['browser.web_vital.inp.interaction_type']).toBe('press');
684+
});
685+
686+
it('leaves out the target and interaction type for an INP without an entry', () => {
687+
_sendInpSpan(40, undefined);
688+
689+
// The name and op still have a value, which is why they can't stand in for these attributes.
690+
expect(SentryCoreBrowser.startInactiveSpan).toHaveBeenCalledWith(
691+
expect.objectContaining({
692+
name: 'Interaction to next paint',
693+
attributes: expect.objectContaining({ 'sentry.op': 'ui.interaction.click' }),
694+
}),
695+
);
696+
const attributes = vi.mocked(SentryCoreBrowser.startInactiveSpan).mock.calls[0]![0].attributes!;
697+
expect(attributes).not.toHaveProperty('browser.web_vital.inp.target');
698+
expect(attributes).not.toHaveProperty('browser.web_vital.inp.interaction_type');
699+
});
672700
});
673701

674702
describe('trackInpAsSpan', () => {

0 commit comments

Comments
 (0)