11/* eslint-disable max-lines */
22import type { IdleTransaction } from '@sentry/core' ;
3- import { getActiveSpan } from '@sentry/core' ;
3+ import { getActiveSpan , getClient , getCurrentScope } from '@sentry/core' ;
44import { getCurrentHub } from '@sentry/core' ;
55import {
66 SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ,
@@ -12,6 +12,7 @@ import {
1212} from '@sentry/core' ;
1313import type {
1414 Client ,
15+ Integration ,
1516 IntegrationFn ,
1617 StartSpanOptions ,
1718 Transaction ,
@@ -29,15 +30,18 @@ import {
2930
3031import { DEBUG_BUILD } from '../common/debug-build' ;
3132import { registerBackgroundTabDetection } from './backgroundtab' ;
33+ import { addPerformanceInstrumentationHandler } from './instrument' ;
3234import {
3335 addPerformanceEntries ,
36+ startTrackingINP ,
3437 startTrackingInteractions ,
3538 startTrackingLongTasks ,
3639 startTrackingWebVitals ,
3740} from './metrics' ;
3841import type { RequestInstrumentationOptions } from './request' ;
3942import { defaultRequestInstrumentationOptions , instrumentOutgoingRequests } from './request' ;
4043import { WINDOW } from './types' ;
44+ import type { InteractionRouteNameMapping } from './web-vitals/types' ;
4145
4246export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing' ;
4347
@@ -103,6 +107,13 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions {
103107 */
104108 enableLongTask : boolean ;
105109
110+ /**
111+ * If true, Sentry will capture INP web vitals as standalone spans .
112+ *
113+ * Default: false
114+ */
115+ enableInp : boolean ;
116+
106117 /**
107118 * _metricOptions allows the user to send options to change how metrics are collected.
108119 *
@@ -142,6 +153,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
142153 instrumentPageLoad : true ,
143154 markBackgroundSpan : true ,
144155 enableLongTask : true ,
156+ enableInp : false ,
145157 _experiments : { } ,
146158 ...defaultRequestInstrumentationOptions ,
147159} ;
@@ -181,16 +193,25 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
181193
182194 const _collectWebVitals = startTrackingWebVitals ( ) ;
183195
196+ /** Stores a mapping of interactionIds from PerformanceEventTimings to the origin interaction path */
197+ const interactionIdtoRouteNameMapping : InteractionRouteNameMapping = { } ;
198+ if ( options . enableInp ) {
199+ startTrackingINP ( interactionIdtoRouteNameMapping ) ;
200+ }
201+
184202 if ( options . enableLongTask ) {
185203 startTrackingLongTasks ( ) ;
186204 }
187205 if ( options . _experiments . enableInteractions ) {
188206 startTrackingInteractions ( ) ;
189207 }
190208
191- const latestRoute : { name : string | undefined ; source : TransactionSource | undefined } = {
209+ const latestRoute : {
210+ name : string | undefined ;
211+ context : TransactionContext | undefined ;
212+ } = {
192213 name : undefined ,
193- source : undefined ,
214+ context : undefined ,
194215 } ;
195216
196217 /** Create routing idle transaction. */
@@ -238,7 +259,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
238259 finalContext . metadata ;
239260
240261 latestRoute . name = finalContext . name ;
241- latestRoute . source = getSource ( finalContext ) ;
262+ latestRoute . context = finalContext ;
242263
243264 if ( finalContext . sampled === false ) {
244265 DEBUG_BUILD && logger . log ( `[Tracing] Will not send ${ finalContext . op } transaction because of beforeNavigate.` ) ;
@@ -389,6 +410,10 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
389410 registerInteractionListener ( options , latestRoute ) ;
390411 }
391412
413+ if ( options . enableInp ) {
414+ registerInpInteractionListener ( interactionIdtoRouteNameMapping , latestRoute ) ;
415+ }
416+
392417 instrumentOutgoingRequests ( {
393418 traceFetch,
394419 traceXHR,
@@ -448,7 +473,10 @@ export function getMetaContent(metaName: string): string | undefined {
448473/** Start listener for interaction transactions */
449474function registerInteractionListener (
450475 options : BrowserTracingOptions ,
451- latestRoute : { name : string | undefined ; source : TransactionSource | undefined } ,
476+ latestRoute : {
477+ name : string | undefined ;
478+ context : TransactionContext | undefined ;
479+ } ,
452480) : void {
453481 let inflightInteractionTransaction : IdleTransaction | undefined ;
454482 const registerInteractionTransaction = ( ) : void => {
@@ -483,7 +511,7 @@ function registerInteractionListener(
483511 op,
484512 trimEnd : true ,
485513 data : {
486- [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] : latestRoute . source || 'url' ,
514+ [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] : latestRoute . context ? getSource ( latestRoute . context ) : undefined || 'url' ,
487515 } ,
488516 } ;
489517
@@ -504,6 +532,70 @@ function registerInteractionListener(
504532 } ) ;
505533}
506534
535+ function isPerformanceEventTiming ( entry : PerformanceEntry ) : entry is PerformanceEventTiming {
536+ return 'duration' in entry ;
537+ }
538+
539+ /** We store up to 10 interaction candidates max to cap memory usage. This is the same cap as getINP from web-vitals */
540+ const MAX_INTERACTIONS = 10 ;
541+
542+ /** Creates a listener on interaction entries, and maps interactionIds to the origin path of the interaction */
543+ function registerInpInteractionListener (
544+ interactionIdtoRouteNameMapping : InteractionRouteNameMapping ,
545+ latestRoute : {
546+ name : string | undefined ;
547+ context : TransactionContext | undefined ;
548+ } ,
549+ ) : void {
550+ addPerformanceInstrumentationHandler ( 'event' , ( { entries } ) => {
551+ const client = getClient ( ) ;
552+ // We need to get the replay, user, and activeTransaction from the current scope
553+ // so that we can associate replay id, profile id, and a user display to the span
554+ const replay =
555+ client !== undefined && client . getIntegrationByName !== undefined
556+ ? ( client . getIntegrationByName ( 'Replay' ) as Integration & { getReplayId : ( ) => string } )
557+ : undefined ;
558+ const replayId = replay !== undefined ? replay . getReplayId ( ) : undefined ;
559+ // eslint-disable-next-line deprecation/deprecation
560+ const activeTransaction = getActiveTransaction ( ) ;
561+ const currentScope = getCurrentScope ( ) ;
562+ const user = currentScope !== undefined ? currentScope . getUser ( ) : undefined ;
563+ for ( const entry of entries ) {
564+ if ( isPerformanceEventTiming ( entry ) ) {
565+ const duration = entry . duration ;
566+ const keys = Object . keys ( interactionIdtoRouteNameMapping ) ;
567+ const minInteractionId =
568+ keys . length > 0
569+ ? keys . reduce ( ( a , b ) => {
570+ return interactionIdtoRouteNameMapping [ a ] . duration < interactionIdtoRouteNameMapping [ b ] . duration
571+ ? a
572+ : b ;
573+ } )
574+ : undefined ;
575+ if ( minInteractionId === undefined || duration > interactionIdtoRouteNameMapping [ minInteractionId ] . duration ) {
576+ const interactionId = entry . interactionId ;
577+ const routeName = latestRoute . name ;
578+ const parentContext = latestRoute . context ;
579+ if ( interactionId && routeName && parentContext ) {
580+ if ( minInteractionId && Object . keys ( interactionIdtoRouteNameMapping ) . length >= MAX_INTERACTIONS ) {
581+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
582+ delete interactionIdtoRouteNameMapping [ minInteractionId ] ;
583+ }
584+ interactionIdtoRouteNameMapping [ interactionId ] = {
585+ routeName,
586+ duration,
587+ parentContext,
588+ user,
589+ activeTransaction,
590+ replayId,
591+ } ;
592+ }
593+ }
594+ }
595+ }
596+ } ) ;
597+ }
598+
507599function getSource ( context : TransactionContext ) : TransactionSource | undefined {
508600 const sourceFromAttributes = context . attributes && context . attributes [ SEMANTIC_ATTRIBUTE_SENTRY_SOURCE ] ;
509601 // eslint-disable-next-line deprecation/deprecation
0 commit comments