diff --git a/.size-limit.js b/.size-limit.js index 51f1e0c711f7..ef140e160170 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -52,7 +52,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: '{ init, browserTracingIntegration }', gzip: true, - limit: '35 KB', + limit: '36 KB', }, { name: '@sentry/browser (incl. Feedback) - Webpack (gzipped)', diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 9114884384b7..6ebec8511214 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -583,6 +583,9 @@ export function createProfilingEvent( return createProfilePayload(profile_id, start_timestamp, profile, event); } +// TODO (v8): We need to obtain profile ids in @sentry-internal/tracing, +// but we don't have access to this map because importing this map would +// cause a circular dependancy. We need to resolve this in v8. const PROFILE_MAP: Map = new Map(); /** * diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index afd0d123090f..46d79a8bcc92 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -19,3 +19,8 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_OP = 'sentry.op'; * Use this attribute to represent the origin of a span. */ export const SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN = 'sentry.origin'; + +/** + * The id of the profile that this span occured in. + */ +export const SEMANTIC_ATTRIBUTE_PROFILE_ID = 'profile_id'; diff --git a/packages/core/src/tracing/index.ts b/packages/core/src/tracing/index.ts index d1e1c7f65b44..998a73147822 100644 --- a/packages/core/src/tracing/index.ts +++ b/packages/core/src/tracing/index.ts @@ -27,3 +27,4 @@ export { } from './trace'; export { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext'; export { setMeasurement } from './measurement'; +export { isValidSampleRate } from './sampling'; diff --git a/packages/core/src/tracing/sampling.ts b/packages/core/src/tracing/sampling.ts index 427a7076d6d0..4b1bbef47d9d 100644 --- a/packages/core/src/tracing/sampling.ts +++ b/packages/core/src/tracing/sampling.ts @@ -103,7 +103,7 @@ export function sampleTransaction( /** * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). */ -function isValidSampleRate(rate: unknown): boolean { +export function isValidSampleRate(rate: unknown): boolean { // we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck // eslint-disable-next-line @typescript-eslint/no-explicit-any if (isNaN(rate) || !(typeof rate === 'number' || typeof rate === 'boolean')) { diff --git a/packages/core/src/tracing/span.ts b/packages/core/src/tracing/span.ts index 6937f5502804..bb4c8da90d97 100644 --- a/packages/core/src/tracing/span.ts +++ b/packages/core/src/tracing/span.ts @@ -18,7 +18,11 @@ import { dropUndefinedKeys, logger, timestampInSeconds, uuid4 } from '@sentry/ut import { DEBUG_BUILD } from '../debug-build'; import { getMetricSummaryJsonForSpan } from '../metrics/metric-summary'; -import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../semanticAttributes'; +import { + SEMANTIC_ATTRIBUTE_PROFILE_ID, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, +} from '../semanticAttributes'; import { getRootSpan } from '../utils/getRootSpan'; import { TRACE_FLAG_NONE, @@ -634,6 +638,7 @@ export class Span implements SpanInterface { trace_id: this._traceId, origin: this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined, _metrics_summary: getMetricSummaryJsonForSpan(this), + profile_id: this._attributes[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined, exclusive_time: this._exclusiveTime, measurements: Object.keys(this._measurements).length > 0 ? this._measurements : undefined, }); diff --git a/packages/core/src/tracing/transaction.ts b/packages/core/src/tracing/transaction.ts index 905aaf6c5040..e30eec8d8249 100644 --- a/packages/core/src/tracing/transaction.ts +++ b/packages/core/src/tracing/transaction.ts @@ -254,6 +254,16 @@ export class Transaction extends SpanClass implements TransactionInterface { this._hub = hub; } + /** + * Get the profile id of the transaction. + */ + public getProfileId(): string | undefined { + if (this._contexts !== undefined && this._contexts['profile'] !== undefined) { + return this._contexts['profile'].profile_id as string; + } + return undefined; + } + /** * Finish the transaction & prepare the event to send to Sentry. */ diff --git a/packages/tracing-internal/src/browser/browserTracingIntegration.ts b/packages/tracing-internal/src/browser/browserTracingIntegration.ts index 40a13849cb60..01dda5bcdcfc 100644 --- a/packages/tracing-internal/src/browser/browserTracingIntegration.ts +++ b/packages/tracing-internal/src/browser/browserTracingIntegration.ts @@ -1,6 +1,6 @@ /* eslint-disable max-lines */ import type { IdleTransaction } from '@sentry/core'; -import { getActiveSpan } from '@sentry/core'; +import { getActiveSpan, getClient, getCurrentScope } from '@sentry/core'; import { getCurrentHub } from '@sentry/core'; import { SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -12,6 +12,7 @@ import { } from '@sentry/core'; import type { Client, + Integration, IntegrationFn, StartSpanOptions, Transaction, @@ -29,8 +30,10 @@ import { import { DEBUG_BUILD } from '../common/debug-build'; import { registerBackgroundTabDetection } from './backgroundtab'; +import { addPerformanceInstrumentationHandler } from './instrument'; import { addPerformanceEntries, + startTrackingINP, startTrackingInteractions, startTrackingLongTasks, startTrackingWebVitals, @@ -38,6 +41,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'; @@ -103,6 +107,13 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions { */ enableLongTask: boolean; + /** + * If true, Sentry will capture INP web vitals as standalone spans . + * + * Default: false + */ + enableInp: boolean; + /** * _metricOptions allows the user to send options to change how metrics are collected. * @@ -142,6 +153,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { instrumentPageLoad: true, markBackgroundSpan: true, enableLongTask: true, + enableInp: false, _experiments: {}, ...defaultRequestInstrumentationOptions, }; @@ -181,6 +193,12 @@ export const browserTracingIntegration = ((_options: Partial { @@ -483,7 +511,7 @@ function registerInteractionListener( op, trimEnd: true, data: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.source || 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: latestRoute.context ? getSource(latestRoute.context) : undefined || 'url', }, }; @@ -504,6 +532,70 @@ function registerInteractionListener( }); } +function isPerformanceEventTiming(entry: PerformanceEntry): entry is PerformanceEventTiming { + return 'duration' in entry; +} + +/** We store up to 10 interaction candidates max to cap memory usage. This is the same cap as getINP from web-vitals */ +const MAX_INTERACTIONS = 10; + +/** Creates a listener on interaction entries, and maps interactionIds to the origin path of the interaction */ +function registerInpInteractionListener( + interactionIdtoRouteNameMapping: InteractionRouteNameMapping, + latestRoute: { + name: string | undefined; + context: TransactionContext | undefined; + }, +): void { + addPerformanceInstrumentationHandler('event', ({ entries }) => { + const client = getClient(); + // We need to get the replay, user, and activeTransaction from the current scope + // so that we can associate replay id, profile id, and a user display to the span + const replay = + client !== undefined && client.getIntegrationByName !== undefined + ? (client.getIntegrationByName('Replay') as Integration & { getReplayId: () => string }) + : undefined; + const replayId = replay !== undefined ? replay.getReplayId() : undefined; + // eslint-disable-next-line deprecation/deprecation + const activeTransaction = getActiveTransaction(); + const currentScope = getCurrentScope(); + const user = currentScope !== undefined ? currentScope.getUser() : undefined; + 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 routeName = latestRoute.name; + const parentContext = latestRoute.context; + if (interactionId && routeName && parentContext) { + if (minInteractionId && Object.keys(interactionIdtoRouteNameMapping).length >= MAX_INTERACTIONS) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete interactionIdtoRouteNameMapping[minInteractionId]; + } + interactionIdtoRouteNameMapping[interactionId] = { + routeName, + duration, + parentContext, + user, + activeTransaction, + replayId, + }; + } + } + } + } + }); +} + 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..10c96a0c5aa3 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 @@ -19,6 +20,14 @@ interface PerformanceEntry { readonly startTime: number; toJSON(): Record; } +interface PerformanceEventTiming extends PerformanceEntry { + processingStart: number; + processingEnd: number; + duration: number; + cancelable?: boolean; + target?: unknown | null; + interactionId?: number; +} interface Metric { /** @@ -86,6 +95,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 +133,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 +219,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..b9c08f7dffaf 100644 --- a/packages/tracing-internal/src/browser/metrics/index.ts +++ b/packages/tracing-internal/src/browser/metrics/index.ts @@ -1,7 +1,14 @@ /* eslint-disable max-lines */ import type { IdleTransaction, Transaction } from '@sentry/core'; -import { getActiveTransaction, setMeasurement } from '@sentry/core'; -import type { Measurements, SpanContext } from '@sentry/types'; +import { + Span, + getActiveTransaction, + getClient, + hasTracingEnabled, + isValidSampleRate, + setMeasurement, +} from '@sentry/core'; +import type { ClientOptions, Measurements, SpanContext, TransactionContext } from '@sentry/types'; import { browserPerformanceTimeOrigin, getComponentName, htmlTreeAsString, logger, parseUrl } from '@sentry/utils'; import { spanToJSON } from '@sentry/core'; @@ -9,14 +16,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 +141,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 +201,69 @@ 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 options = 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, parentContext, activeTransaction, user, replayId } = + entry.interactionId !== undefined + ? interactionIdtoRouteNameMapping[entry.interactionId] + : { + routeName: undefined, + parentContext: undefined, + activeTransaction: undefined, + user: undefined, + replayId: undefined, + }; + const userDisplay = user !== undefined ? user.email || user.id || user.ip_address : undefined; + // eslint-disable-next-line deprecation/deprecation + const profileId = activeTransaction !== undefined ? activeTransaction.getProfileId() : undefined; + const span = new Span({ + startTimestamp: startTime, + endTimestamp: startTime + duration, + op: 'ui.interaction.click', + name: htmlTreeAsString(entry.target), + attributes: { + release: options.release, + environment: options.environment, + transaction: routeName, + ...(userDisplay !== undefined && userDisplay !== '' ? { user: userDisplay } : {}), + ...(profileId !== undefined ? { profile_id: profileId } : {}), + ...(replayId !== undefined ? { replay_id: replayId } : {}), + }, + exclusiveTime: metric.value, + measurements: { + inp: { value: metric.value, unit: 'millisecond' }, + }, + }); + + /** Check to see if the span should be sampled */ + const sampleRate = getSampleRate(parentContext, options); + if (!sampleRate) { + return; + } + + if (Math.random() < (sampleRate as number | boolean)) { + 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); + }); + } + return; + } + }); +} + /** Add performance related spans to a transaction */ export function addPerformanceEntries(transaction: Transaction): void { const performance = getBrowserPerformanceAPI(); @@ -569,3 +662,35 @@ export function _addTtfbToMeasurements( } } } + +/** Taken from @sentry/core sampling.ts */ +function getSampleRate(transactionContext: TransactionContext | undefined, options: ClientOptions): number | boolean { + if (!hasTracingEnabled(options)) { + return false; + } + let sampleRate; + if (transactionContext !== undefined && typeof options.tracesSampler === 'function') { + sampleRate = options.tracesSampler({ + transactionContext, + name: transactionContext.name, + parentSampled: transactionContext.parentSampled, + attributes: { + // eslint-disable-next-line deprecation/deprecation + ...transactionContext.data, + ...transactionContext.attributes, + }, + location: WINDOW.location, + }); + } else if (transactionContext !== undefined && transactionContext.sampled !== undefined) { + sampleRate = transactionContext.sampled; + } else if (typeof options.tracesSampleRate !== 'undefined') { + sampleRate = options.tracesSampleRate; + } else { + sampleRate = 1; + } + if (!isValidSampleRate(sampleRate)) { + DEBUG_BUILD && logger.warn('[Tracing] Discarding transaction because of invalid sample rate.'); + return false; + } + return sampleRate; +} diff --git a/packages/tracing-internal/src/browser/web-vitals/types.ts b/packages/tracing-internal/src/browser/web-vitals/types.ts index b4096b2678f6..adc95084bda9 100644 --- a/packages/tracing-internal/src/browser/web-vitals/types.ts +++ b/packages/tracing-internal/src/browser/web-vitals/types.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import type { Transaction, TransactionContext, User } from '@sentry/types'; import type { FirstInputPolyfillCallback } from './types/polyfills'; export * from './types/base'; @@ -162,3 +163,14 @@ declare global { element?: Element; } } + +export type InteractionRouteNameMapping = { + [key: string]: { + routeName: string; + duration: number; + parentContext: TransactionContext; + user?: User; + activeTransaction?: Transaction; + replayId?: string; + }; +}; diff --git a/packages/types/src/transaction.ts b/packages/types/src/transaction.ts index fbcf8b38f02d..caae6c49027d 100644 --- a/packages/types/src/transaction.ts +++ b/packages/types/src/transaction.ts @@ -152,6 +152,12 @@ export interface Transaction extends TransactionContext, Omit; + + /** + * Get the profile id from the transaction + * @deprecated Use `toJSON()` or access the fields directly instead. + */ + getProfileId(): string | undefined; } /**