diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 849e34f6c92b..53db1e1743fe 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -79,6 +79,7 @@ export { export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent'; export { prepareEvent } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; +export { createSpanEnvelope } from './span'; export { hasTracingEnabled } from './utils/hasTracingEnabled'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; diff --git a/packages/core/src/span.ts b/packages/core/src/span.ts new file mode 100644 index 000000000000..a14298fa9fe4 --- /dev/null +++ b/packages/core/src/span.ts @@ -0,0 +1,22 @@ +import type { SpanEnvelope, SpanItem } from '@sentry/types'; +import type { Span } from '@sentry/types/build/types/span'; +import { createEnvelope } from '@sentry/utils'; + +/** + * Create envelope from Span item. + */ +export function createSpanEnvelope(span: Span): SpanEnvelope { + const headers: SpanEnvelope[0] = { + sent_at: new Date().toISOString(), + }; + + const item = createSpanItem(span); + return createEnvelope(headers, [item]); +} + +function createSpanItem(span: Span): SpanItem { + const spanHeaders: SpanItem[0] = { + type: 'span', + }; + return [spanHeaders, span]; +} diff --git a/packages/tracing-internal/src/browser/browserTracingIntegration.ts b/packages/tracing-internal/src/browser/browserTracingIntegration.ts index 31660eff00a7..31c8e284ae6c 100644 --- a/packages/tracing-internal/src/browser/browserTracingIntegration.ts +++ b/packages/tracing-internal/src/browser/browserTracingIntegration.ts @@ -28,8 +28,10 @@ import { import { DEBUG_BUILD } from '../common/debug-build'; import { registerBackgroundTabDetection } from './backgroundtab'; +import { addPerformanceInstrumentationHandler } from './instrument'; import { addPerformanceEntries, + startTrackingINP, startTrackingInteractions, startTrackingLongTasks, startTrackingWebVitals, @@ -37,6 +39,7 @@ import { import type { RequestInstrumentationOptions } from './request'; import { defaultRequestInstrumentationOptions, instrumentOutgoingRequests } from './request'; import { WINDOW } from './types'; +import type { InteractionRouteNameMapping } from './web-vitals/types'; export const BROWSER_TRACING_INTEGRATION_ID = 'BrowserTracing'; @@ -126,6 +129,7 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions { */ _experiments: Partial<{ enableInteractions: boolean; + enableInp: boolean; }>; /** @@ -180,6 +184,12 @@ export const browserTracingIntegration = ((_options: Partial { + for (const entry of entries) { + if (isPerformanceEventTiming(entry)) { + const duration = entry.duration; + const keys = Object.keys(interactionIdtoRouteNameMapping); + const minInteractionId = + keys.length > 0 + ? keys.reduce((a, b) => { + return interactionIdtoRouteNameMapping[a].duration < interactionIdtoRouteNameMapping[b].duration + ? a + : b; + }) + : undefined; + if (minInteractionId === undefined || duration > interactionIdtoRouteNameMapping[minInteractionId].duration) { + const interactionId = entry.interactionId; + const route = entry.target?.baseURI; + const path = route ? new URL(route).pathname : undefined; + if (interactionId && path) { + if (minInteractionId && Object.keys(interactionIdtoRouteNameMapping).length >= MAX_INTERACTIONS) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete interactionIdtoRouteNameMapping[minInteractionId]; + } + interactionIdtoRouteNameMapping[interactionId] = { routeName: path, duration }; + } + } + } + } + }); +} + function getSource(context: TransactionContext): TransactionSource | undefined { const sourceFromAttributes = context.attributes && context.attributes[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]; // eslint-disable-next-line deprecation/deprecation diff --git a/packages/tracing-internal/src/browser/instrument.ts b/packages/tracing-internal/src/browser/instrument.ts index 2a4e7acaf3b1..085db9ca3a5d 100644 --- a/packages/tracing-internal/src/browser/instrument.ts +++ b/packages/tracing-internal/src/browser/instrument.ts @@ -3,12 +3,13 @@ import { getFunctionName, logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../common/debug-build'; import { onCLS } from './web-vitals/getCLS'; import { onFID } from './web-vitals/getFID'; +import { onINP } from './web-vitals/getINP'; import { onLCP } from './web-vitals/getLCP'; import { observe } from './web-vitals/lib/observe'; type InstrumentHandlerTypePerformanceObserver = 'longtask' | 'event' | 'navigation' | 'paint' | 'resource'; -type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid'; +type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'inp'; // We provide this here manually instead of relying on a global, as this is not available in non-browser environements // And we do not want to expose such types @@ -86,6 +87,7 @@ const instrumented: { [key in InstrumentHandlerType]?: boolean } = {}; let _previousCls: Metric | undefined; let _previousFid: Metric | undefined; let _previousLcp: Metric | undefined; +let _previousInp: Metric | undefined; /** * Add a callback that will be triggered when a CLS metric is available. @@ -123,9 +125,19 @@ export function addFidInstrumentationHandler(callback: (data: { metric: Metric } return addMetricObserver('fid', callback, instrumentFid, _previousFid); } +/** + * 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 { + return addMetricObserver('inp', callback, instrumentInp, _previousInp); +} + export function addPerformanceInstrumentationHandler( type: 'event', - callback: (data: { entries: (PerformanceEntry & { target?: unknown | null })[] }) => void, + callback: (data: { entries: ((PerformanceEntry & { target?: unknown | null }) | PerformanceEventTiming)[] }) => void, ): CleanupHandlerCallback; export function addPerformanceInstrumentationHandler( type: InstrumentHandlerTypePerformanceObserver, @@ -199,6 +211,15 @@ function instrumentLcp(): StopListening { }); } +function instrumentInp(): void { + return onINP(metric => { + triggerHandlers('inp', { + metric, + }); + _previousInp = metric; + }); +} + function addMetricObserver( type: InstrumentHandlerTypeMetric, callback: InstrumentHandlerCallback, diff --git a/packages/tracing-internal/src/browser/metrics/index.ts b/packages/tracing-internal/src/browser/metrics/index.ts index 4c9c25111e11..9733e0b91f9f 100644 --- a/packages/tracing-internal/src/browser/metrics/index.ts +++ b/packages/tracing-internal/src/browser/metrics/index.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import type { IdleTransaction, Transaction } from '@sentry/core'; -import { getActiveTransaction, setMeasurement } from '@sentry/core'; +import { Span, getActiveTransaction, getClient, setMeasurement } from '@sentry/core'; import type { Measurements, SpanContext } from '@sentry/types'; import { browserPerformanceTimeOrigin, getComponentName, htmlTreeAsString, logger, parseUrl } from '@sentry/utils'; @@ -9,14 +9,21 @@ import { DEBUG_BUILD } from '../../common/debug-build'; import { addClsInstrumentationHandler, addFidInstrumentationHandler, + addInpInstrumentationHandler, addLcpInstrumentationHandler, addPerformanceInstrumentationHandler, } from '../instrument'; import { WINDOW } from '../types'; import { getVisibilityWatcher } from '../web-vitals/lib/getVisibilityWatcher'; -import type { NavigatorDeviceMemory, NavigatorNetworkInformation } from '../web-vitals/types'; +import type { + InteractionRouteNameMapping, + NavigatorDeviceMemory, + NavigatorNetworkInformation, +} from '../web-vitals/types'; import { _startChild, isMeasurementValue } from './utils'; +import { createSpanEnvelope } from '@sentry/core'; + const MAX_INT_AS_BYTES = 2147483647; /** @@ -127,6 +134,22 @@ export function startTrackingInteractions(): void { }); } +/** + * Start tracking INP webvital events. + */ +export function startTrackingINP(interactionIdtoRouteNameMapping: InteractionRouteNameMapping): () => void { + const performance = getBrowserPerformanceAPI(); + if (performance && browserPerformanceTimeOrigin) { + const inpCallback = _trackINP(interactionIdtoRouteNameMapping); + + return (): void => { + inpCallback(); + }; + } + + return () => undefined; +} + /** Starts tracking the Cumulative Layout Shift on the current page. */ function _trackCLS(): () => void { return addClsInstrumentationHandler(({ metric }) => { @@ -171,6 +194,43 @@ function _trackFID(): () => void { }); } +/** Starts tracking the Interaction to Next Paint on the current page. */ +function _trackINP(interactionIdtoRouteNameMapping: InteractionRouteNameMapping): () => void { + return addInpInstrumentationHandler(({ metric }) => { + const entry = metric.entries.find(e => e.name === 'click'); + const client = getClient(); + if (!entry || !client) { + return; + } + const { release, environment } = client.getOptions(); + /** 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 routeName = + entry.interactionId !== undefined ? interactionIdtoRouteNameMapping[entry.interactionId].routeName : undefined; + const span = new Span({ + startTimestamp: startTime, + endTimestamp: startTime + duration, + op: 'ui.interaction.click', + name: routeName, + attributes: { + measurements: { + inp: { value: metric.value, unit: 'millisecond' }, + }, + release, + environment, + }, + }); + const envelope = span ? createSpanEnvelope(span) : undefined; + const transport = client && client.getTransport(); + if (transport && envelope) { + transport.send(envelope).then(null, reason => { + DEBUG_BUILD && logger.error('Error while sending interaction:', reason); + }); + } + }); +} + /** Add performance related spans to a transaction */ export function addPerformanceEntries(transaction: Transaction): void { const performance = getBrowserPerformanceAPI(); diff --git a/packages/tracing-internal/src/browser/web-vitals/types.ts b/packages/tracing-internal/src/browser/web-vitals/types.ts index b4096b2678f6..fd4a31311074 100644 --- a/packages/tracing-internal/src/browser/web-vitals/types.ts +++ b/packages/tracing-internal/src/browser/web-vitals/types.ts @@ -162,3 +162,5 @@ declare global { element?: Element; } } + +export type InteractionRouteNameMapping = { [key: string]: { routeName: string; duration: number } }; diff --git a/packages/types/src/datacategory.ts b/packages/types/src/datacategory.ts index ca2acd29e235..b86c1bafdc9e 100644 --- a/packages/types/src/datacategory.ts +++ b/packages/types/src/datacategory.ts @@ -27,4 +27,6 @@ export type DataCategory = // Feedback type event (v2) | 'feedback' // Unknown data category - | 'unknown'; + | 'unknown' + // Span + | 'span'; diff --git a/packages/types/src/envelope.ts b/packages/types/src/envelope.ts index 98bb9145a547..fe08ce1962d1 100644 --- a/packages/types/src/envelope.ts +++ b/packages/types/src/envelope.ts @@ -7,6 +7,7 @@ import type { Profile } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, Session, SessionAggregates } from './session'; +import type { Span } from './span'; import type { Transaction } from './transaction'; import type { UserFeedback } from './user'; @@ -41,7 +42,8 @@ export type EnvelopeItemType = | 'replay_event' | 'replay_recording' | 'check_in' - | 'statsd'; + | 'statsd' + | 'span'; export type BaseEnvelopeHeaders = { [key: string]: unknown; @@ -82,6 +84,7 @@ type ReplayRecordingItemHeaders = { type: 'replay_recording'; length: number }; type CheckInItemHeaders = { type: 'check_in' }; type StatsdItemHeaders = { type: 'statsd'; length: number }; type ProfileItemHeaders = { type: 'profile' }; +type SpanItemHeaders = { type: 'span' }; // TODO (v8): Replace `Event` with `SerializedEvent` export type EventItem = BaseEnvelopeItem; @@ -98,6 +101,7 @@ type ReplayRecordingItem = BaseEnvelopeItem; export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; +export type SpanItem = BaseEnvelopeItem; export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: DynamicSamplingContext }; type SessionEnvelopeHeaders = { sent_at: string }; @@ -105,6 +109,7 @@ type CheckInEnvelopeHeaders = { trace?: DynamicSamplingContext }; type ClientReportEnvelopeHeaders = BaseEnvelopeHeaders; type ReplayEnvelopeHeaders = BaseEnvelopeHeaders; type StatsdEnvelopeHeaders = BaseEnvelopeHeaders; +type SpanEnvelopeHeaders = BaseEnvelopeHeaders; export type EventEnvelope = BaseEnvelope< EventEnvelopeHeaders, @@ -115,6 +120,7 @@ export type ClientReportEnvelope = BaseEnvelope; export type StatsdEnvelope = BaseEnvelope; +export type SpanEnvelope = BaseEnvelope; export type Envelope = | EventEnvelope @@ -122,5 +128,6 @@ export type Envelope = | ClientReportEnvelope | ReplayEnvelope | CheckInEnvelope - | StatsdEnvelope; + | StatsdEnvelope + | SpanEnvelope; export type EnvelopeItem = Envelope[1][number]; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0504803c49a1..198bf183d051 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -45,6 +45,8 @@ export type { StatsdItem, StatsdEnvelope, ProfileItem, + SpanEnvelope, + SpanItem, } from './envelope'; export type { ExtendedError } from './error'; export type { Event, EventHint, EventType, ErrorEvent, TransactionEvent, SerializedEvent } from './event'; diff --git a/packages/types/src/span.ts b/packages/types/src/span.ts index 73c2fbdaaaa8..0b8f99421a40 100644 --- a/packages/types/src/span.ts +++ b/packages/types/src/span.ts @@ -1,5 +1,6 @@ import type { TraceContext } from './context'; import type { Instrumenter } from './instrumenter'; +import type { Measurements } from './measurement'; import type { Primitive } from './misc'; import type { HrTime } from './opentelemetry'; import type { Transaction } from './transaction'; @@ -21,7 +22,8 @@ export type SpanAttributeValue = | boolean | Array | Array - | Array; + | Array + | Measurements; export type SpanAttributes = Partial<{ 'sentry.origin': string; diff --git a/packages/utils/src/envelope.ts b/packages/utils/src/envelope.ts index c716ab282dfe..1aa87938ed8c 100644 --- a/packages/utils/src/envelope.ts +++ b/packages/utils/src/envelope.ts @@ -209,6 +209,7 @@ const ITEM_TYPE_TO_DATA_CATEGORY_MAP: Record = { replay_recording: 'replay', check_in: 'monitor', feedback: 'feedback', + span: 'span', // TODO: This is a temporary workaround until we have a proper data category for metrics statsd: 'unknown', };