diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 381055dcbef0..5714a6f680d6 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -32,3 +32,8 @@ export { Replay } from '@sentry/replay'; // __ROLLUP_EXCLUDE_OFFLINE_FROM_BUNDLES_BEGIN__ export { makeBrowserOfflineTransport } from './transports/offline'; // __ROLLUP_EXCLUDE_OFFLINE_FROM_BUNDLES_END__ + +// __ROLLUP_EXCLUDE_BROWSER_PROFILING_FROM_BUNDLES_BEGIN__ +export { onProfilingStartRouteTransaction } from './profiling/hubextensions'; +export { BrowserProfilingIntegration } from './profiling/integration'; +// __ROLLUP_EXCLUDE_BROWSER_PROFILING_FROM_BUNDLES_END__ diff --git a/packages/browser/src/profiling/cache.ts b/packages/browser/src/profiling/cache.ts new file mode 100644 index 000000000000..ee62538e60cb --- /dev/null +++ b/packages/browser/src/profiling/cache.ts @@ -0,0 +1,72 @@ +import type { Event } from '@sentry/types'; + +/** + * Creates a cache that evicts keys in fifo order + * @param size {Number} + */ +export function makeProfilingCache( + size: number, +): { + get: (key: Key) => Value | undefined; + add: (key: Key, value: Value) => void; + delete: (key: Key) => boolean; + clear: () => void; + size: () => number; +} { + // Maintain a fifo queue of keys, we cannot rely on Object.keys as the browser may not support it. + let evictionOrder: Key[] = []; + let cache: Record = {}; + + return { + add(key: Key, value: Value) { + while (evictionOrder.length >= size) { + // shift is O(n) but this is small size and only happens if we are + // exceeding the cache size so it should be fine. + const evictCandidate = evictionOrder.shift(); + + if (evictCandidate !== undefined) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete cache[evictCandidate]; + } + } + + // in case we have a collision, delete the old key. + if (cache[key]) { + this.delete(key); + } + + evictionOrder.push(key); + cache[key] = value; + }, + clear() { + cache = {}; + evictionOrder = []; + }, + get(key: Key): Value | undefined { + return cache[key]; + }, + size() { + return evictionOrder.length; + }, + // Delete cache key and return true if it existed, false otherwise. + delete(key: Key): boolean { + if (!cache[key]) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete cache[key]; + + for (let i = 0; i < evictionOrder.length; i++) { + if (evictionOrder[i] === key) { + evictionOrder.splice(i, 1); + break; + } + } + + return true; + }, + }; +} + +export const PROFILING_EVENT_CACHE = makeProfilingCache(20); diff --git a/packages/browser/src/profiling/hubextensions.ts b/packages/browser/src/profiling/hubextensions.ts new file mode 100644 index 000000000000..fee0fa73a353 --- /dev/null +++ b/packages/browser/src/profiling/hubextensions.ts @@ -0,0 +1,259 @@ +import { getCurrentHub, getMainCarrier } from '@sentry/core'; +import type { CustomSamplingContext, Hub, Transaction, TransactionContext } from '@sentry/types'; +import { logger, uuid4 } from '@sentry/utils'; + +import { WINDOW } from '../helpers'; +import type { JSSelfProfile, JSSelfProfiler, ProcessedJSSelfProfile } from './jsSelfProfiling'; +import { sendProfile } from './sendProfile'; + +// Max profile duration. +const MAX_PROFILE_DURATION_MS = 30_000; + +// While we experiment, per transaction sampling interval will be more flexible to work with. +type StartTransaction = ( + this: Hub, + transactionContext: TransactionContext, + customSamplingContext?: CustomSamplingContext, +) => Transaction | undefined; + +/** + * Check if profiler constructor is available. + * @param maybeProfiler + */ +function isJSProfilerSupported(maybeProfiler: unknown): maybeProfiler is typeof JSSelfProfiler { + return typeof maybeProfiler === 'function'; +} + +/** + * Safety wrapper for startTransaction for the unlikely case that transaction starts before tracing is imported - + * if that happens we want to avoid throwing an error from profiling code. + * see https://github.com/getsentry/sentry-javascript/issues/4731. + * + * @experimental + */ +export function onProfilingStartRouteTransaction(transaction: Transaction | undefined): Transaction | undefined { + if (!transaction) { + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Transaction is undefined, skipping profiling'); + } + return transaction; + } + + return wrapTransactionWithProfiling(transaction); +} + +/** + * Wraps startTransaction and stopTransaction with profiling related logic. + * startProfiling is called after the call to startTransaction in order to avoid our own code from + * being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction. + */ +function wrapTransactionWithProfiling(transaction: Transaction): Transaction { + // Feature support check first + const JSProfiler = WINDOW.Profiler; + if (!isJSProfilerSupported(JSProfiler)) { + if (__DEBUG_BUILD__) { + logger.log( + '[Profiling] Profiling is not supported by this browser, Profiler interface missing on window object.', + ); + } + return transaction; + } + + // profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate. + if (!transaction.sampled) { + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Transaction is not sampled, skipping profiling'); + } + return transaction; + } + + const client = getCurrentHub().getClient(); + const options = client && client.getOptions(); + + // @ts-ignore not part of the browser options yet + const profilesSampleRate = (options && options.profilesSampleRate) || 0; + if (profilesSampleRate === undefined) { + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Profiling disabled, enable it by setting `profilesSampleRate` option to SDK init call.'); + } + return transaction; + } + + // Check if we should sample this profile + if (Math.random() > profilesSampleRate) { + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Skip profiling transaction due to sampling.'); + } + return transaction; + } + + // From initial testing, it seems that the minimum value for sampleInterval is 10ms. + const samplingIntervalMS = 10; + // Start the profiler + const maxSamples = Math.floor(MAX_PROFILE_DURATION_MS / samplingIntervalMS); + const profiler = new JSProfiler({ sampleInterval: samplingIntervalMS, maxBufferSize: maxSamples }); + if (__DEBUG_BUILD__) { + logger.log(`[Profiling] started profiling transaction: ${transaction.name || transaction.description}`); + } + + // We create "unique" transaction names to avoid concurrent transactions with same names + // from being ignored by the profiler. From here on, only this transaction name should be used when + // calling the profiler methods. Note: we log the original name to the user to avoid confusion. + const profileId = uuid4(); + + // A couple of important things to note here: + // `CpuProfilerBindings.stopProfiling` will be scheduled to run in 30seconds in order to exceed max profile duration. + // Whichever of the two (transaction.finish/timeout) is first to run, the profiling will be stopped and the gathered profile + // will be processed when the original transaction is finished. Since onProfileHandler can be invoked multiple times in the + // event of an error or user mistake (calling transaction.finish multiple times), it is important that the behavior of onProfileHandler + // is idempotent as we do not want any timings or profiles to be overriden by the last call to onProfileHandler. + // After the original finish method is called, the event will be reported through the integration and delegated to transport. + let processedProfile: ProcessedJSSelfProfile | null = null; + + /** + * Idempotent handler for profile stop + */ + function onProfileHandler(): void { + // Check if the profile exists and return it the behavior has to be idempotent as users may call transaction.finish multiple times. + if (!transaction) { + return; + } + if (processedProfile) { + if (__DEBUG_BUILD__) { + logger.log( + '[Profiling] profile for:', + transaction.name || transaction.description, + 'already exists, returning early', + ); + } + return; + } + + profiler + .stop() + .then((p: JSSelfProfile): void => { + if (maxDurationTimeoutID) { + WINDOW.clearTimeout(maxDurationTimeoutID); + maxDurationTimeoutID = undefined; + } + + if (__DEBUG_BUILD__) { + logger.log(`[Profiling] stopped profiling of transaction: ${transaction.name || transaction.description}`); + } + + // In case of an overlapping transaction, stopProfiling may return null and silently ignore the overlapping profile. + if (!p) { + if (__DEBUG_BUILD__) { + logger.log( + `[Profiling] profiler returned null profile for: ${transaction.name || transaction.description}`, + 'this may indicate an overlapping transaction or a call to stopProfiling with a profile title that was never started', + ); + } + return; + } + + // If a profile has less than 2 samples, it is not useful and should be discarded. + if (p.samples.length < 2) { + return; + } + + processedProfile = { ...p, profile_id: profileId }; + sendProfile(profileId, processedProfile); + }) + .catch(error => { + if (__DEBUG_BUILD__) { + logger.log('[Profiling] error while stopping profiler:', error); + } + return null; + }); + } + + // Enqueue a timeout to prevent profiles from running over max duration. + let maxDurationTimeoutID: number | undefined = WINDOW.setTimeout(() => { + if (__DEBUG_BUILD__) { + logger.log( + '[Profiling] max profile duration elapsed, stopping profiling for:', + transaction.name || transaction.description, + ); + } + void onProfileHandler(); + }, MAX_PROFILE_DURATION_MS); + + // We need to reference the original finish call to avoid creating an infinite loop + const originalFinish = transaction.finish.bind(transaction); + + /** + * Wraps startTransaction and stopTransaction with profiling related logic. + * startProfiling is called after the call to startTransaction in order to avoid our own code from + * being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction. + */ + function profilingWrappedTransactionFinish(): Promise { + if (!transaction) { + return originalFinish(); + } + // onProfileHandler should always return the same profile even if this is called multiple times. + // Always call onProfileHandler to ensure stopProfiling is called and the timeout is cleared. + onProfileHandler(); + + // Set profile context + transaction.setContext('profile', { profile_id: profileId }); + + return originalFinish(); + } + + transaction.finish = profilingWrappedTransactionFinish; + return transaction; +} + +/** + * Wraps startTransaction with profiling logic. This is done automatically by the profiling integration. + */ +function __PRIVATE__wrapStartTransactionWithProfiling(startTransaction: StartTransaction): StartTransaction { + return function wrappedStartTransaction( + this: Hub, + transactionContext: TransactionContext, + customSamplingContext?: CustomSamplingContext, + ): Transaction | undefined { + const transaction: Transaction | undefined = startTransaction.call(this, transactionContext, customSamplingContext); + if (transaction === undefined) { + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Transaction is undefined, skipping profiling'); + } + return transaction; + } + + return wrapTransactionWithProfiling(transaction); + }; +} + +/** + * Patches startTransaction and stopTransaction with profiling logic. + */ +export function addProfilingExtensionMethods(): void { + const carrier = getMainCarrier(); + if (!carrier.__SENTRY__) { + if (__DEBUG_BUILD__) { + logger.log("[Profiling] Can't find main carrier, profiling won't work."); + } + return; + } + carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {}; + + if (!carrier.__SENTRY__.extensions['startTransaction']) { + if (__DEBUG_BUILD__) { + logger.log( + '[Profiling] startTransaction does not exists, profiling will not work. Make sure you import @sentry/tracing package before @sentry/profiling-node as import order matters.', + ); + } + return; + } + + if (__DEBUG_BUILD__) { + logger.log('[Profiling] startTransaction exists, patching it with profiling functionality...'); + } + + carrier.__SENTRY__.extensions['startTransaction'] = __PRIVATE__wrapStartTransactionWithProfiling( + // This is already patched by sentry/tracing, we are going to re-patch it... + carrier.__SENTRY__.extensions['startTransaction'] as StartTransaction, + ); +} diff --git a/packages/browser/src/profiling/integration.ts b/packages/browser/src/profiling/integration.ts new file mode 100644 index 000000000000..9a9751c50d61 --- /dev/null +++ b/packages/browser/src/profiling/integration.ts @@ -0,0 +1,48 @@ +import type { Event, EventProcessor, Integration } from '@sentry/types'; +import { logger } from '@sentry/utils'; + +import { PROFILING_EVENT_CACHE } from './cache'; +import { addProfilingExtensionMethods } from './hubextensions'; + +/** + * Browser profiling integration. Stores any event that has contexts["profile"]["profile_id"] + * This exists because we do not want to await async profiler.stop calls as transaction.finish is called + * in a synchronous context. Instead, we handle sending the profile async from the promise callback and + * rely on being able to pull the event from the cache when we need to construct the envelope. This makes the + * integration less reliable as we might be dropping profiles when the cache is full. + * + * @experimental + */ +export class BrowserProfilingIntegration implements Integration { + public readonly name: string = 'BrowserProfilingIntegration'; + + /** + * @inheritDoc + */ + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void { + // Patching the hub to add the extension methods. + // Warning: we have an implicit dependency on import order and we will fail patching if the constructor of + // BrowserProfilingIntegration is called before @sentry/tracing is imported. This is because we need to patch + // the methods of @sentry/tracing which are patched as a side effect of importing @sentry/tracing. + addProfilingExtensionMethods(); + + // Add our event processor + addGlobalEventProcessor(this.handleGlobalEvent.bind(this)); + } + + /** + * @inheritDoc + */ + public handleGlobalEvent(event: Event): Event { + const profileId = event.contexts && event.contexts['profile'] && event.contexts['profile']['profile_id']; + + if (profileId && typeof profileId === 'string') { + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Profiling event found, caching it.'); + } + PROFILING_EVENT_CACHE.add(profileId, event); + } + + return event; + } +} diff --git a/packages/browser/src/profiling/jsSelfProfiling.ts b/packages/browser/src/profiling/jsSelfProfiling.ts new file mode 100644 index 000000000000..0622cba023ab --- /dev/null +++ b/packages/browser/src/profiling/jsSelfProfiling.ts @@ -0,0 +1,115 @@ +// Type definitions for https://wicg.github.io/js-self-profiling/ +type JSSelfProfileSampleMarker = 'script' | 'gc' | 'style' | 'layout' | 'paint' | 'other'; + +export type JSSelfProfileSample = { + timestamp: number; + stackId?: number; + marker?: JSSelfProfileSampleMarker; +}; + +export type JSSelfProfileStack = { + frameId: number; + parentId?: number; +}; + +export type JSSelfProfileFrame = { + name: string; + resourceId?: number; + line?: number; + column?: number; +}; + +export type JSSelfProfile = { + resources: string[]; + frames: JSSelfProfileFrame[]; + stacks: JSSelfProfileStack[]; + samples: JSSelfProfileSample[]; +}; + +export interface ProcessedJSSelfProfile extends JSSelfProfile { + profile_id: string; +} + +type BufferFullCallback = (trace: JSSelfProfile) => void; + +interface JSSelfProfiler { + sampleInterval: number; + stopped: boolean; + + stop: () => Promise; + addEventListener(event: 'samplebufferfull', callback: BufferFullCallback): void; +} + +export declare const JSSelfProfiler: { + new (options: { sampleInterval: number; maxBufferSize: number }): JSSelfProfiler; +}; + +declare global { + interface Window { + Profiler: typeof JSSelfProfiler | undefined; + } +} + +export interface RawThreadCpuProfile extends JSSelfProfile { + profile_id: string; +} +export interface ThreadCpuProfile { + samples: { + stack_id: number; + thread_id: string; + elapsed_since_start_ns: string; + }[]; + stacks: number[][]; + frames: { + function: string; + file: string | undefined; + line: number | undefined; + column: number | undefined; + }[]; + thread_metadata: Record; + queue_metadata?: Record; +} + +export interface SentryProfile { + event_id: string; + version: string; + os: { + name: string; + version: string; + build_number: string; + }; + runtime: { + name: string; + version: string; + }; + device: { + architecture: string; + is_emulator: boolean; + locale: string; + manufacturer: string; + model: string; + }; + timestamp: string; + release: string; + environment: string; + platform: string; + profile: ThreadCpuProfile; + debug_meta?: { + images: { + debug_id: string; + image_addr: string; + code_file: string; + type: string; + image_size: number; + image_vmaddr: string; + }[]; + }; + transactions: { + name: string; + trace_id: string; + id: string; + active_thread_id: string; + relative_start_ns: string; + relative_end_ns: string; + }[]; +} diff --git a/packages/browser/src/profiling/sendProfile.ts b/packages/browser/src/profiling/sendProfile.ts new file mode 100644 index 000000000000..6637e86c8f44 --- /dev/null +++ b/packages/browser/src/profiling/sendProfile.ts @@ -0,0 +1,86 @@ +import { getCurrentHub } from '@sentry/core'; +import { logger } from '@sentry/utils'; + +import { PROFILING_EVENT_CACHE } from './cache'; +import type { ProcessedJSSelfProfile } from './jsSelfProfiling'; +import type { ProfiledEvent } from './utils'; +import { createProfilingEventEnvelope } from './utils'; +/** + * Performs lookup in the event cache and sends the profile to Sentry. + * If the profiled transaction event is found, we use the profiled transaction event and profile + * to construct a profile type envelope and send it to Sentry. + */ +export function sendProfile(profileId: string, profile: ProcessedJSSelfProfile): void { + const event = PROFILING_EVENT_CACHE.get(profileId); + + if (!event) { + // We could not find a corresponding transaction event for this profile. + // Opt to do nothing for now, but in the future we should implement a simple retry mechanism. + if (__DEBUG_BUILD__) { + logger.log("[Profiling] Couldn't find a transaction event for this profile, dropping it."); + } + return; + } + + event.sdkProcessingMetadata = event.sdkProcessingMetadata || {}; + if (event.sdkProcessingMetadata && !event.sdkProcessingMetadata['profile']) { + event.sdkProcessingMetadata['profile'] = profile; + } + + // Client, Dsn and Transport are all required to be able to send the profiling event to Sentry. + // If either of them is not available, we remove the profile from the transaction event. + // and forward it to the next event processor. + const hub = getCurrentHub(); + const client = hub.getClient(); + + if (!client) { + if (__DEBUG_BUILD__) { + logger.log( + '[Profiling] getClient did not return a Client, removing profile from event and forwarding to next event processors.', + ); + } + return; + } + + const dsn = client.getDsn(); + if (!dsn) { + if (__DEBUG_BUILD__) { + logger.log( + '[Profiling] getDsn did not return a Dsn, removing profile from event and forwarding to next event processors.', + ); + } + return; + } + + const transport = client.getTransport(); + if (!transport) { + if (__DEBUG_BUILD__) { + logger.log( + '[Profiling] getTransport did not return a Transport, removing profile from event and forwarding to next event processors.', + ); + } + return; + } + + // If all required components are available, we construct a profiling event envelope and send it to Sentry. + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Preparing envelope and sending a profiling event'); + } + const envelope = createProfilingEventEnvelope(event as ProfiledEvent, dsn); + + // Evict event from the cache - we want to prevent the LRU cache from prioritizing already sent events over new ones. + PROFILING_EVENT_CACHE.delete(profileId); + + if (!envelope) { + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Failed to construct envelope'); + } + return; + } + + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Envelope constructed, sending it'); + } + + void transport.send(envelope); +} diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts new file mode 100644 index 000000000000..34ba9663fab3 --- /dev/null +++ b/packages/browser/src/profiling/utils.ts @@ -0,0 +1,370 @@ +import type { + DsnComponents, + DynamicSamplingContext, + Event, + EventEnvelope, + EventEnvelopeHeaders, + EventItem, + SdkInfo, + SdkMetadata, +} from '@sentry/types'; +import { createEnvelope, dropUndefinedKeys, dsnToString, logger, uuid4 } from '@sentry/utils'; + +import { WINDOW } from '../helpers'; +import type { + JSSelfProfile, + JSSelfProfileStack, + RawThreadCpuProfile, + SentryProfile, + ThreadCpuProfile, +} from './jsSelfProfiling'; + +const MS_TO_NS = 1e6; +// Use 0 as main thread id which is identical to threadId in node:worker_threads +// where main logs 0 and workers seem to log in increments of 1 +const THREAD_ID_STRING = String(0); +const THREAD_NAME = 'main'; + +// Machine properties (eval only once) +let OS_PLATFORM = ''; // macos +let OS_PLATFORM_VERSION = ''; // 13.2 +let OS_ARCH = ''; // arm64 +let OS_BROWSER = (WINDOW.navigator && WINDOW.navigator.userAgent) || ''; +let OS_MODEL = ''; +const OS_LOCALE = + (WINDOW.navigator && WINDOW.navigator.language) || + (WINDOW.navigator && WINDOW.navigator.languages && WINDOW.navigator.languages[0]) || + ''; + +type UAData = { + platform?: string; + architecture?: string; + model?: string; + platformVersion?: string; + fullVersionList?: { + brand: string; + version: string; + }[]; +}; + +interface UserAgentData { + getHighEntropyValues: (keys: string[]) => Promise; +} + +function isUserAgentData(data: unknown): data is UserAgentData { + return typeof data === 'object' && data !== null && 'getHighEntropyValues' in data; +} + +// @ts-ignore userAgentData is not part of the navigator interface yet +const userAgentData = WINDOW.navigator && WINDOW.navigator.userAgentData; + +if (isUserAgentData(userAgentData)) { + userAgentData + .getHighEntropyValues(['architecture', 'model', 'platform', 'platformVersion', 'fullVersionList']) + .then((ua: UAData) => { + OS_PLATFORM = ua.platform || ''; + OS_ARCH = ua.architecture || ''; + OS_MODEL = ua.model || ''; + OS_PLATFORM_VERSION = ua.platformVersion || ''; + + if (ua.fullVersionList && ua.fullVersionList.length > 0) { + const firstUa = ua.fullVersionList[ua.fullVersionList.length - 1]; + OS_BROWSER = `${firstUa.brand} ${firstUa.version}`; + } + }) + .catch(e => void e); +} + +function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): profile is RawThreadCpuProfile { + return !('thread_metadata' in profile); +} + +// Enriches the profile with threadId of the current thread. +// This is done in node as we seem to not be able to get the info from C native code. +/** + * + */ +export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThreadCpuProfile): ThreadCpuProfile { + if (!isRawThreadCpuProfile(profile)) { + return profile; + } + + return convertJSSelfProfileToSampledFormat(profile); +} + +// Profile is marked as optional because it is deleted from the metadata +// by the integration before the event is processed by other integrations. +export interface ProfiledEvent extends Event { + sdkProcessingMetadata: { + profile?: RawThreadCpuProfile; + }; +} + +/** Extract sdk info from from the API metadata */ +function getSdkMetadataForEnvelopeHeader(metadata?: SdkMetadata): SdkInfo | undefined { + if (!metadata || !metadata.sdk) { + return undefined; + } + + return { name: metadata.sdk.name, version: metadata.sdk.version } as SdkInfo; +} + +/** + * Apply SdkInfo (name, version, packages, integrations) to the corresponding event key. + * Merge with existing data if any. + **/ +function enhanceEventWithSdkInfo(event: Event, sdkInfo?: SdkInfo): Event { + if (!sdkInfo) { + return event; + } + event.sdk = event.sdk || {}; + event.sdk.name = event.sdk.name || sdkInfo.name || 'unknown sdk'; + event.sdk.version = event.sdk.version || sdkInfo.version || 'unknown sdk version'; + event.sdk.integrations = [...(event.sdk.integrations || []), ...(sdkInfo.integrations || [])]; + event.sdk.packages = [...(event.sdk.packages || []), ...(sdkInfo.packages || [])]; + return event; +} + +function createEventEnvelopeHeaders( + event: Event, + sdkInfo: SdkInfo | undefined, + tunnel: string | undefined, + dsn: DsnComponents, +): EventEnvelopeHeaders { + const dynamicSamplingContext = event.sdkProcessingMetadata && event.sdkProcessingMetadata['dynamicSamplingContext']; + + return { + event_id: event.event_id as string, + sent_at: new Date().toISOString(), + ...(sdkInfo && { sdk: sdkInfo }), + ...(!!tunnel && { dsn: dsnToString(dsn) }), + ...(event.type === 'transaction' && + dynamicSamplingContext && { + trace: dropUndefinedKeys({ ...dynamicSamplingContext }) as DynamicSamplingContext, + }), + }; +} + +function getTraceId(event: Event): string { + const traceId: unknown = event && event.contexts && event.contexts['trace'] && event.contexts['trace']['trace_id']; + // Log a warning if the profile has an invalid traceId (should be uuidv4). + // All profiles and transactions are rejected if this is the case and we want to + // warn users that this is happening if they enable debug flag + if (typeof traceId === 'string' && traceId.length !== 32) { + if (__DEBUG_BUILD__) { + logger.log(`[Profiling] Invalid traceId: ${traceId} on profiled event`); + } + } + if (typeof traceId !== 'string') { + return ''; + } + + return traceId; +} +/** + * Creates a profiling event envelope from a Sentry event. If profile does not pass + * validation, returns null. + * @param event + * @param dsn + * @param metadata + * @param tunnel + * @returns {EventEnvelope | null} + */ + +/** + * Creates a profiling event envelope from a Sentry event. + */ +export function createProfilingEventEnvelope( + event: ProfiledEvent, + dsn: DsnComponents, + metadata?: SdkMetadata, + tunnel?: string, +): EventEnvelope | null { + if (event.type !== 'transaction') { + // createProfilingEventEnvelope should only be called for transactions, + // we type guard this behavior with isProfiledTransactionEvent. + throw new TypeError('Profiling events may only be attached to transactions, this should never occur.'); + } + + const rawProfile = event.sdkProcessingMetadata['profile']; + + if (rawProfile === undefined || rawProfile === null) { + throw new TypeError( + `Cannot construct profiling event envelope without a valid profile. Got ${rawProfile} instead.`, + ); + } + + if (!rawProfile.profile_id) { + throw new TypeError('Profile is missing profile_id'); + } + + if (rawProfile.samples.length <= 1) { + if (__DEBUG_BUILD__) { + // Log a warning if the profile has less than 2 samples so users can know why + // they are not seeing any profiling data and we cant avoid the back and forth + // of asking them to provide us with a dump of the profile data. + logger.log('[Profiling] Discarding profile because it contains less than 2 samples'); + } + return null; + } + + const traceId = getTraceId(event); + const sdkInfo = getSdkMetadataForEnvelopeHeader(metadata); + enhanceEventWithSdkInfo(event, metadata && metadata.sdk); + const envelopeHeaders = createEventEnvelopeHeaders(event, sdkInfo, tunnel, dsn); + const enrichedThreadProfile = enrichWithThreadInformation(rawProfile); + const transactionStartMs = typeof event.start_timestamp === 'number' ? event.start_timestamp * 1000 : Date.now(); + const transactionEndMs = typeof event.timestamp === 'number' ? event.timestamp * 1000 : Date.now(); + + const profile: SentryProfile = { + event_id: rawProfile.profile_id, + timestamp: new Date(transactionStartMs).toISOString(), + platform: 'javascript', + version: '1', + release: event.release || '', + environment: event.environment || '', + runtime: { + name: 'javascript', + version: WINDOW.navigator.userAgent, + }, + os: { + name: OS_PLATFORM, + version: OS_PLATFORM_VERSION, + build_number: OS_BROWSER, + }, + device: { + locale: OS_LOCALE, + model: OS_MODEL, + manufacturer: OS_BROWSER, + architecture: OS_ARCH, + is_emulator: false, + }, + profile: enrichedThreadProfile, + transactions: [ + { + name: event.transaction || '', + id: event.event_id || uuid4(), + trace_id: traceId, + active_thread_id: THREAD_ID_STRING, + relative_start_ns: '0', + relative_end_ns: ((transactionEndMs - transactionStartMs) * 1e6).toFixed(0), + }, + ], + }; + + const envelopeItem: EventItem = [ + { + type: 'profile', + }, + // @ts-ignore this is missing in typedef + profile, + ]; + + return createEnvelope(envelopeHeaders, [envelopeItem]); +} + +/** + * + */ +export function isProfiledTransactionEvent(event: Event): event is ProfiledEvent { + return !!(event.sdkProcessingMetadata && event.sdkProcessingMetadata['profile']); +} + +// Due to how profiles are attached to event metadata, we may sometimes want to remove them to ensure +// they are not processed by other Sentry integrations. This can be the case when we cannot construct a valid +// profile from the data we have or some of the mechanisms to send the event (Hub, Transport etc) are not available to us. +/** + * + */ +export function maybeRemoveProfileFromSdkMetadata(event: Event | ProfiledEvent): Event { + if (!isProfiledTransactionEvent(event)) { + return event; + } + + delete event.sdkProcessingMetadata.profile; + return event; +} + +/** + * Converts a JSSelfProfile to a our sampled format. + * Does not currently perform stack indexing. + */ +export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): ThreadCpuProfile { + let EMPTY_STACK_ID: undefined | number = undefined; + let STACK_ID = 0; + + // Initialize the profile that we will fill with data + const profile: ThreadCpuProfile = { + samples: [], + stacks: [], + frames: [], + thread_metadata: { + [THREAD_ID_STRING]: { name: THREAD_NAME }, + }, + }; + + if (!input.samples.length) { + return profile; + } + + // We assert samples.length > 0 above and timestamp should always be present + const start = input.samples[0].timestamp; + + for (let i = 0; i < input.samples.length; i++) { + const jsSample = input.samples[i]; + + // If sample has no stack, add an empty sample + if (jsSample.stackId === undefined) { + if (EMPTY_STACK_ID === undefined) { + EMPTY_STACK_ID = STACK_ID; + profile.stacks[EMPTY_STACK_ID] = []; + STACK_ID++; + } + + profile['samples'][i] = { + // convert ms timestamp to ns + elapsed_since_start_ns: ((jsSample.timestamp - start) * MS_TO_NS).toFixed(0), + stack_id: EMPTY_STACK_ID, + thread_id: THREAD_ID_STRING, + }; + continue; + } + + let stackTop: JSSelfProfileStack | undefined = input.stacks[jsSample.stackId]; + + // Functions in top->down order (root is last) + // We follow the stackTop.parentId trail and collect each visited frameId + const stack: number[] = []; + + while (stackTop) { + stack.push(stackTop.frameId); + + const frame = input.frames[stackTop.frameId]; + + // If our frame has not been indexed yet, index it + if (profile.frames[stackTop.frameId] === undefined) { + profile.frames[stackTop.frameId] = { + function: frame.name, + file: frame.resourceId ? input.resources[frame.resourceId] : undefined, + line: frame.line, + column: frame.column, + }; + } + + stackTop = stackTop.parentId === undefined ? undefined : input.stacks[stackTop.parentId]; + } + + const sample: ThreadCpuProfile['samples'][0] = { + // convert ms timestamp to ns + elapsed_since_start_ns: ((jsSample.timestamp - start) * MS_TO_NS).toFixed(0), + stack_id: STACK_ID, + thread_id: THREAD_ID_STRING, + }; + + profile['stacks'][STACK_ID] = stack; + profile['samples'][i] = sample; + STACK_ID++; + } + + return profile; +} diff --git a/packages/browser/test/unit/profiling/integration.test.ts b/packages/browser/test/unit/profiling/integration.test.ts new file mode 100644 index 000000000000..1ea59ee7068e --- /dev/null +++ b/packages/browser/test/unit/profiling/integration.test.ts @@ -0,0 +1,151 @@ +import { getCurrentHub } from '@sentry/browser'; +import type { Event } from '@sentry/types'; +import { TextDecoder, TextEncoder } from 'util'; + +// @ts-ignore patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) +const patchedEncoder = (!global.window.TextEncoder && (global.window.TextEncoder = TextEncoder)) || true; +// @ts-ignore patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) +const patchedDecoder = (!global.window.TextDecoder && (global.window.TextDecoder = TextDecoder)) || true; + +import { JSDOM } from 'jsdom'; + +import { PROFILING_EVENT_CACHE } from '../../../src/profiling/cache'; +import { BrowserProfilingIntegration } from '../../../src/profiling/integration'; +import { sendProfile } from '../../../src/profiling/sendProfile'; + +// @ts-ignore store a reference so we can reset it later +const globalDocument = global.document; +// @ts-ignore store a reference so we can reset it later +const globalWindow = global.window; +// @ts-ignore store a reference so we can reset it later +const globalLocation = global.location; + +describe('BrowserProfilingIntegration', () => { + beforeEach(() => { + // Clear profiling event cache + PROFILING_EVENT_CACHE.clear(); + + const dom = new JSDOM(); + // @ts-ignore need to override global document + global.document = dom.window.document; + // @ts-ignore need to override global document + global.window = dom.window; + // @ts-ignore need to override global document + global.location = dom.window.location; + }); + + // Reset back to previous values + afterEach(() => { + // @ts-ignore need to override global document + global.document = globalDocument; + // @ts-ignore need to override global document + global.window = globalWindow; + // @ts-ignore need to override global document + global.location = globalLocation; + }); + + afterAll(() => { + // @ts-ignore patch the encoder on the window, else importing JSDOM fails + patchedEncoder && delete global.window.TextEncoder; + // @ts-ignore patch the encoder on the window, else importing JSDOM fails + patchedDecoder && delete global.window.TextDecoder; + }); + + it('does not store event in profiling event cache if context["profile"]["profile_id"] is not present', () => { + const integration = new BrowserProfilingIntegration(); + const event: Event = { + contexts: {}, + }; + integration.handleGlobalEvent(event); + expect(PROFILING_EVENT_CACHE.size()).toBe(0); + }); + + it('stores event in profiling event cache if context["profile"]["profile_id"] is present', () => { + const integration = new BrowserProfilingIntegration(); + const event: Event = { + contexts: { + profile: { + profile_id: 'profile_id', + }, + }, + }; + integration.handleGlobalEvent(event); + expect(PROFILING_EVENT_CACHE.get(event.contexts!.profile!.profile_id as string)).toBe(event); + }); + + it('sending profile evicts it from the LRU cache', () => { + const hub = getCurrentHub(); + const client: any = { + getDsn() { + return {}; + }, + getTransport() { + return { + send() {}, + }; + }, + }; + + hub.bindClient(client); + + const integration = new BrowserProfilingIntegration(); + const event: Event = { + type: 'transaction', + contexts: { + profile: { + profile_id: 'profile_id', + }, + }, + }; + + integration.handleGlobalEvent(event); + + sendProfile('profile_id', { + resources: [], + samples: [], + stacks: [], + frames: [], + profile_id: 'profile_id', + }); + + expect(PROFILING_EVENT_CACHE.get('profile_id')).toBe(undefined); + }); +}); + +describe('ProfilingEventCache', () => { + beforeEach(() => { + PROFILING_EVENT_CACHE.clear(); + }); + + it('caps the size of the profiling event cache', () => { + for (let i = 0; i <= 21; i++) { + const integration = new BrowserProfilingIntegration(); + const event: Event = { + contexts: { + profile: { + profile_id: `profile_id_${i}`, + }, + }, + }; + integration.handleGlobalEvent(event); + } + expect(PROFILING_EVENT_CACHE.size()).toBe(20); + // Evicts the first item in the cache + expect(PROFILING_EVENT_CACHE.get('profile_id_0')).toBe(undefined); + }); + + it('handles collision by replacing the value', () => { + PROFILING_EVENT_CACHE.add('profile_id_0', {}); + const second = {}; + PROFILING_EVENT_CACHE.add('profile_id_0', second); + + expect(PROFILING_EVENT_CACHE.get('profile_id_0')).toBe(second); + expect(PROFILING_EVENT_CACHE.size()).toBe(1); + }); + + it('clears cache', () => { + PROFILING_EVENT_CACHE.add('profile_id_0', {}); + PROFILING_EVENT_CACHE.clear(); + expect(PROFILING_EVENT_CACHE.size()).toBe(0); + }); +}); diff --git a/packages/browser/test/unit/profiling/utils.test.ts b/packages/browser/test/unit/profiling/utils.test.ts new file mode 100644 index 000000000000..bbba7dbf5f08 --- /dev/null +++ b/packages/browser/test/unit/profiling/utils.test.ts @@ -0,0 +1,196 @@ +import { TextDecoder, TextEncoder } from 'util'; +// @ts-ignore patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) +const patchedEncoder = (!global.window.TextEncoder && (global.window.TextEncoder = TextEncoder)) || true; +// @ts-ignore patch the encoder on the window, else importing JSDOM fails (deleted in afterAll) +const patchedDecoder = (!global.window.TextDecoder && (global.window.TextDecoder = TextDecoder)) || true; + +import { JSDOM } from 'jsdom'; + +import type { JSSelfProfile } from '../../../src/profiling/jsSelfProfiling'; +import { convertJSSelfProfileToSampledFormat } from '../../../src/profiling/utils'; + +const makeJSProfile = (partial: Partial = {}): JSSelfProfile => { + return { + resources: [], + samples: [], + stacks: [], + frames: [], + ...partial, + }; +}; + +// @ts-ignore store a reference so we can reset it later +const globalDocument = global.document; +// @ts-ignore store a reference so we can reset it later +const globalWindow = global.window; +// @ts-ignore store a reference so we can reset it later +const globalLocation = global.location; + +describe('convertJSSelfProfileToSampledFormat', () => { + beforeEach(() => { + const dom = new JSDOM(); + // @ts-ignore need to override global document + global.document = dom.window.document; + // @ts-ignore need to override global document + global.window = dom.window; + // @ts-ignore need to override global document + global.location = dom.window.location; + }); + + // Reset back to previous values + afterEach(() => { + // @ts-ignore need to override global document + global.document = globalDocument; + // @ts-ignore need to override global document + global.window = globalWindow; + // @ts-ignore need to override global document + global.location = globalLocation; + }); + + afterAll(() => { + // @ts-ignore patch the encoder on the window, else importing JSDOM fails + patchedEncoder && delete global.window.TextEncoder; + // @ts-ignore patch the encoder on the window, else importing JSDOM fails + patchedDecoder && delete global.window.TextDecoder; + }); + + it('gracefully handles empty profiles', () => { + expect(() => + convertJSSelfProfileToSampledFormat( + makeJSProfile({ + samples: [], + frames: [], + stacks: [ + { frameId: 0, parentId: undefined }, + { frameId: 1, parentId: 2 }, + { frameId: 2, parentId: 3 }, + ], + }), + ), + ).not.toThrow(); + }); + it('converts stack to sampled stack', () => { + const profile = convertJSSelfProfileToSampledFormat( + makeJSProfile({ + samples: [ + { + stackId: 0, + timestamp: 0, + }, + { + stackId: 1, + timestamp: 100, + }, + ], + frames: [{ name: 'f0' }, { name: 'f1' }, { name: 'f2' }], + stacks: [ + { frameId: 0, parentId: undefined }, + { frameId: 1, parentId: 2 }, + { frameId: 2, parentId: 3 }, + ], + }), + ); + + expect(profile.stacks.length).toBe(2); + expect(profile.stacks[0]).toEqual([0]); + expect(profile.stacks[1]).toEqual([1, 2]); + }); + + it('converts sample to sampled profile', () => { + const profile = convertJSSelfProfileToSampledFormat( + makeJSProfile({ + samples: [ + { + stackId: 0, + timestamp: 0, + }, + { + stackId: 1, + timestamp: 1, + }, + ], + frames: [{ name: 'f0' }, { name: 'f1' }, { name: 'f2' }], + stacks: [ + { frameId: 0, parentId: undefined }, + { frameId: 1, parentId: 2 }, + { frameId: 2, parentId: 3 }, + ], + }), + ); + + expect(profile.samples[0].stack_id).toBe(0); + expect(profile.samples[1].stack_id).toBe(1); + + expect(profile.samples[0].elapsed_since_start_ns).toBe('0'); + expect(profile.samples[1].elapsed_since_start_ns).toBe((1 * 1e6).toFixed(0)); + }); + + it('assert frames has no holes', () => { + const profile = convertJSSelfProfileToSampledFormat( + makeJSProfile({ + samples: [ + { + stackId: 0, + timestamp: 0, + }, + { + stackId: 1, + timestamp: 1, + }, + ], + frames: [{ name: 'f0' }, { name: 'f1' }, { name: 'f2' }], + stacks: [ + { frameId: 0, parentId: undefined }, + { frameId: 1, parentId: 2 }, + { frameId: 2, parentId: 3 }, + ], + }), + ); + + for (const frame of profile.frames) { + expect(frame).not.toBeUndefined(); + } + }); + + it('handles empty stacks', () => { + const profile = convertJSSelfProfileToSampledFormat( + makeJSProfile({ + samples: [ + { + timestamp: 0, + }, + ], + stacks: [], + }), + ); + + expect(profile.stacks.length).toBe(1); + expect(profile.stacks[0]).toEqual([]); + }); + + it('reuses empty stack inde', () => { + const profile = convertJSSelfProfileToSampledFormat( + makeJSProfile({ + samples: [ + { + timestamp: 0, + }, + { + stackId: 0, + timestamp: 100, + }, + { + timestamp: 200, + }, + ], + frames: [{ name: 'f0' }], + stacks: [{ frameId: 0, parentId: undefined }], + }), + ); + + expect(profile.stacks.length).toBe(2); + expect(profile.samples.length).toBe(3); + expect(profile.samples[0].stack_id).toEqual(profile.samples[2].stack_id); + expect(profile.stacks[profile.samples[0].stack_id]).toEqual([]); + }); +}); diff --git a/packages/tracing/src/browser/browsertracing.ts b/packages/tracing/src/browser/browsertracing.ts index 9030e8a11400..74b0f6d9b6ad 100644 --- a/packages/tracing/src/browser/browsertracing.ts +++ b/packages/tracing/src/browser/browsertracing.ts @@ -100,7 +100,11 @@ export interface BrowserTracingOptions extends RequestInstrumentationOptions { * * Default: undefined */ - _experiments: Partial<{ enableLongTask: boolean; enableInteractions: boolean }>; + _experiments: Partial<{ + enableLongTask: boolean; + enableInteractions: boolean; + onStartRouteTransaction: (t: Transaction | undefined, ctx: TransactionContext, getCurrentHub: () => Hub) => void; + }>; /** * beforeNavigate is called before a pageload/navigation transaction is created and allows users to modify transaction @@ -211,7 +215,14 @@ export class BrowserTracing implements Integration { } = this.options; instrumentRouting( - (context: TransactionContext) => this._createRouteTransaction(context), + (context: TransactionContext) => { + const transaction = this._createRouteTransaction(context); + + this.options._experiments.onStartRouteTransaction && + this.options._experiments.onStartRouteTransaction(transaction, context, getCurrentHub); + + return transaction; + }, startTransactionOnPageLoad, startTransactionOnLocationChange, ); diff --git a/packages/tracing/test/browser/browsertracing.test.ts b/packages/tracing/test/browser/browsertracing.test.ts index 1bb0c631e6ec..2d42187d13da 100644 --- a/packages/tracing/test/browser/browsertracing.test.ts +++ b/packages/tracing/test/browser/browsertracing.test.ts @@ -149,6 +149,17 @@ describe('BrowserTracing', () => { customStartTransaction({ name: 'a/path', op: 'pageload' }); }; + it('_experiements calls onStartRouteTransaction on route instrumentation', () => { + const onStartTranscation = jest.fn(); + createBrowserTracing(true, { + _experiments: { + onStartRouteTransaction: onStartTranscation, + }, + }); + + expect(onStartTranscation).toHaveBeenCalledTimes(1); + }); + it('calls custom routing instrumenation', () => { createBrowserTracing(true, { routingInstrumentation: customInstrumentRouting, diff --git a/rollup/bundleHelpers.js b/rollup/bundleHelpers.js index ffc9b1c796d0..56ef80d12415 100644 --- a/rollup/bundleHelpers.js +++ b/rollup/bundleHelpers.js @@ -33,6 +33,7 @@ export function makeBaseBundleConfig(options) { packageSpecificConfig, includeReplay, includeOffline, + includeBrowserProfiling, } = options; const nodeResolvePlugin = makeNodeResolvePlugin(); @@ -43,6 +44,7 @@ export function makeBaseBundleConfig(options) { const tsPlugin = makeTSPlugin(jsVersion.toLowerCase()); const excludeReplayPlugin = makeExcludeBlockPlugin('REPLAY'); const excludeOfflineTransport = makeExcludeBlockPlugin('OFFLINE'); + const excludeBrowserProfiling = makeExcludeBlockPlugin('BROWSER_PROFILING'); // The `commonjs` plugin is the `esModuleInterop` of the bundling world. When used with `transformMixedEsModules`, it // will include all dependencies, imported or required, in the final bundle. (Without it, CJS modules aren't included @@ -67,6 +69,10 @@ export function makeBaseBundleConfig(options) { standAloneBundleConfig.plugins.push(excludeOfflineTransport); } + if (!includeBrowserProfiling) { + standAloneBundleConfig.plugins.push(excludeBrowserProfiling); + } + // used by `@sentry/integrations` and `@sentry/wasm` (bundles which need to be combined with a stand-alone SDK bundle) const addOnBundleConfig = { // These output settings are designed to mimic an IIFE. We don't use Rollup's `iife` format because we don't want to