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 068e545d5996..47bad615586d 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, @@ -198,9 +199,12 @@ export const browserTracingIntegration = ((_options: Partial { @@ -497,7 +504,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', }, }; @@ -527,9 +534,24 @@ 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; source: TransactionSource | undefined }, + 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; @@ -545,12 +567,20 @@ function registerInpInteractionListener( if (minInteractionId === undefined || duration > interactionIdtoRouteNameMapping[minInteractionId].duration) { const interactionId = entry.interactionId; const routeName = latestRoute.name; - if (interactionId && routeName) { + 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 }; + interactionIdtoRouteNameMapping[interactionId] = { + routeName, + duration, + parentContext, + user, + activeTransaction, + replayId, + }; } } } diff --git a/packages/tracing-internal/src/browser/metrics/index.ts b/packages/tracing-internal/src/browser/metrics/index.ts index 26b86f8ef452..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 { Span, getActiveTransaction, getClient, 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'; @@ -202,33 +209,57 @@ function _trackINP(interactionIdtoRouteNameMapping: InteractionRouteNameMapping) if (!entry || !client) { return; } - const { release, environment } = client.getOptions(); + 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 = - entry.interactionId !== undefined ? interactionIdtoRouteNameMapping[entry.interactionId].routeName : undefined; + 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, - environment, + 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' }, }, }); - 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); - }); + + /** 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; } }); } @@ -631,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 fd4a31311074..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'; @@ -163,4 +164,13 @@ declare global { } } -export type InteractionRouteNameMapping = { [key: string]: { routeName: string; duration: number } }; +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; } /**