From 947db1b2fa63e2dac15457b35fc5c7ec6c979e7a Mon Sep 17 00:00:00 2001 From: Jonas Date: Mon, 26 Jun 2023 09:26:41 -0400 Subject: [PATCH] feat(browser): send profiles in same envelope as transactions (#8375) This change ensures that browser profiles are sent in the same envelope as transactions and enables sourcemap support. --- .../browser/src/profiling/hubextensions.ts | 171 +++------ packages/browser/src/profiling/integration.ts | 92 +++-- .../browser/src/profiling/jsSelfProfiling.ts | 8 - packages/browser/src/profiling/sendProfile.ts | 89 ----- packages/browser/src/profiling/utils.ts | 345 +++++++++++------- .../test/unit/profiling/integration.test.ts | 151 -------- packages/types/src/profiling.ts | 10 +- 7 files changed, 350 insertions(+), 516 deletions(-) delete mode 100644 packages/browser/src/profiling/sendProfile.ts delete mode 100644 packages/browser/test/unit/profiling/integration.test.ts diff --git a/packages/browser/src/profiling/hubextensions.ts b/packages/browser/src/profiling/hubextensions.ts index e2d94a11d33f..1c04eeb68362 100644 --- a/packages/browser/src/profiling/hubextensions.ts +++ b/packages/browser/src/profiling/hubextensions.ts @@ -1,29 +1,17 @@ -import { getCurrentHub, getMainCarrier } from '@sentry/core'; -import type { CustomSamplingContext, Hub, Transaction, TransactionContext } from '@sentry/types'; +/* eslint-disable complexity */ +import { getCurrentHub } from '@sentry/core'; +import type { Transaction } from '@sentry/types'; import { logger, uuid4 } from '@sentry/utils'; import { WINDOW } from '../helpers'; -import type { - JSSelfProfile, - JSSelfProfiler, - JSSelfProfilerConstructor, - ProcessedJSSelfProfile, -} from './jsSelfProfiling'; -import { sendProfile } from './sendProfile'; +import type { JSSelfProfile, JSSelfProfiler, JSSelfProfilerConstructor } from './jsSelfProfiling'; +import { addProfileToMap, isValidSampleRate } from './utils'; -// Max profile duration. -const MAX_PROFILE_DURATION_MS = 30_000; +export const MAX_PROFILE_DURATION_MS = 30_000; // Keep a flag value to avoid re-initializing the profiler constructor. If it fails // once, it will always fail and this allows us to early return. let PROFILING_CONSTRUCTOR_FAILED = false; -// 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 @@ -55,7 +43,7 @@ export function onProfilingStartRouteTransaction(transaction: Transaction | unde * 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 { +export function wrapTransactionWithProfiling(transaction: Transaction): Transaction { // Feature support check first const JSProfilerConstructor = WINDOW.Profiler; @@ -68,14 +56,6 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { 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; - } - // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (__DEBUG_BUILD__) { @@ -86,21 +66,41 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { const client = getCurrentHub().getClient(); const options = client && client.getOptions(); + if (!options) { + __DEBUG_BUILD__ && logger.log('[Profiling] Profiling disabled, no options found.'); + return transaction; + } - // @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.'); - } + // @ts-ignore profilesSampleRate is not part of the browser options yet + const profilesSampleRate: number | boolean | undefined = options.profilesSampleRate; + + // Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The + // only valid values are booleans or numbers between 0 and 1.) + if (!isValidSampleRate(profilesSampleRate)) { + __DEBUG_BUILD__ && logger.warn('[Profiling] Discarding profile because of invalid sample rate.'); return transaction; } + // if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped + if (!profilesSampleRate) { + __DEBUG_BUILD__ && + logger.log( + '[Profiling] Discarding profile because a negative sampling decision was inherited or profileSampleRate is set to 0', + ); + return transaction; + } + + // Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is + // a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false. + const sampled = profilesSampleRate === true ? true : Math.random() < profilesSampleRate; // Check if we should sample this profile - if (Math.random() > profilesSampleRate) { - if (__DEBUG_BUILD__) { - logger.log('[Profiling] Skip profiling transaction due to sampling.'); - } + if (!sampled) { + __DEBUG_BUILD__ && + logger.log( + `[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number( + profilesSampleRate, + )})`, + ); return transaction; } @@ -147,19 +147,19 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { // 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; + const processedProfile: JSSelfProfile | null = null; /** * Idempotent handler for profile stop */ - function onProfileHandler(): void { + async function onProfileHandler(): Promise { // 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; + return null; } // Satisfy the type checker, but profiler will always be defined here. if (!profiler) { - return; + return null; } if (processedProfile) { if (__DEBUG_BUILD__) { @@ -169,12 +169,12 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { 'already exists, returning early', ); } - return; + return null; } - profiler + return profiler .stop() - .then((p: JSSelfProfile): void => { + .then((p: JSSelfProfile): null => { if (maxDurationTimeoutID) { WINDOW.clearTimeout(maxDurationTimeoutID); maxDurationTimeoutID = undefined; @@ -192,16 +192,11 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { '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; + return null; } - processedProfile = { ...p, profile_id: profileId }; - sendProfile(profileId, processedProfile); + addProfileToMap(profileId, p); + return null; }) .catch(error => { if (__DEBUG_BUILD__) { @@ -219,6 +214,7 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { transaction.name || transaction.description, ); } + // If the timeout exceeds, we want to stop profiling, but not finish the transaction void onProfileHandler(); }, MAX_PROFILE_DURATION_MS); @@ -230,73 +226,26 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction { * 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 { + function profilingWrappedTransactionFinish(): Transaction { 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 }); + void onProfileHandler().then( + () => { + transaction.setContext('profile', { profile_id: profileId }); + originalFinish(); + }, + () => { + // If onProfileHandler fails, we still want to call the original finish method. + originalFinish(); + }, + ); - return originalFinish(); + return transaction; } 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 index 9a9751c50d61..36fb6432e6df 100644 --- a/packages/browser/src/profiling/integration.ts +++ b/packages/browser/src/profiling/integration.ts @@ -1,8 +1,16 @@ -import type { Event, EventProcessor, Integration } from '@sentry/types'; +import type { EventProcessor, Hub, Integration, Transaction } from '@sentry/types'; +import type { Profile } from '@sentry/types/src/profiling'; import { logger } from '@sentry/utils'; -import { PROFILING_EVENT_CACHE } from './cache'; -import { addProfilingExtensionMethods } from './hubextensions'; +import type { BrowserClient } from './../client'; +import { wrapTransactionWithProfiling } from './hubextensions'; +import type { ProfiledEvent } from './utils'; +import { + addProfilesToEnvelope, + createProfilingEvent, + findProfiledTransactionsFromEnvelope, + PROFILE_MAP, +} from './utils'; /** * Browser profiling integration. Stores any event that has contexts["profile"]["profile_id"] @@ -15,34 +23,66 @@ import { addProfilingExtensionMethods } from './hubextensions'; */ export class BrowserProfilingIntegration implements Integration { public readonly name: string = 'BrowserProfilingIntegration'; + public getCurrentHub?: () => Hub = undefined; /** * @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)); - } + public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { + this.getCurrentHub = getCurrentHub; + const client = this.getCurrentHub().getClient() as BrowserClient; - /** - * @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); - } + if (client && typeof client.on === 'function') { + client.on('startTransaction', (transaction: Transaction) => { + wrapTransactionWithProfiling(transaction); + }); + + client.on('beforeEnvelope', (envelope): void => { + // if not profiles are in queue, there is nothing to add to the envelope. + if (!PROFILE_MAP['size']) { + return; + } + + const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope); + if (!profiledTransactionEvents.length) { + return; + } + + const profilesToAddToEnvelope: Profile[] = []; - return event; + for (const profiledTransaction of profiledTransactionEvents) { + const context = profiledTransaction && profiledTransaction.contexts; + const profile_id = context && context['profile'] && (context['profile']['profile_id'] as string); + + if (!profile_id) { + __DEBUG_BUILD__ && + logger.log('[Profiling] cannot find profile for a transaction without a profile context'); + continue; + } + + // Remove the profile from the transaction context before sending, relay will take care of the rest. + if (context && context['profile']) { + delete context.profile; + } + + const profile = PROFILE_MAP.get(profile_id); + if (!profile) { + __DEBUG_BUILD__ && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); + continue; + } + + PROFILE_MAP.delete(profile_id); + const profileEvent = createProfilingEvent(profile_id, profile, profiledTransaction as ProfiledEvent); + + if (profileEvent) { + profilesToAddToEnvelope.push(profileEvent); + } + } + + addProfilesToEnvelope(envelope, profilesToAddToEnvelope); + }); + } else { + logger.warn('[Profiling] Client does not support hooks, profiling will be disabled'); + } } } diff --git a/packages/browser/src/profiling/jsSelfProfiling.ts b/packages/browser/src/profiling/jsSelfProfiling.ts index efa4a0a0a0bc..8dc981d3d5d3 100644 --- a/packages/browser/src/profiling/jsSelfProfiling.ts +++ b/packages/browser/src/profiling/jsSelfProfiling.ts @@ -26,10 +26,6 @@ export type JSSelfProfile = { samples: JSSelfProfileSample[]; }; -export interface ProcessedJSSelfProfile extends JSSelfProfile { - profile_id: string; -} - type BufferFullCallback = (trace: JSSelfProfile) => void; export interface JSSelfProfiler { @@ -49,7 +45,3 @@ declare global { Profiler: typeof JSSelfProfilerConstructor | undefined; } } - -export interface RawThreadCpuProfile extends JSSelfProfile { - profile_id: string; -} diff --git a/packages/browser/src/profiling/sendProfile.ts b/packages/browser/src/profiling/sendProfile.ts deleted file mode 100644 index 83ca990c516e..000000000000 --- a/packages/browser/src/profiling/sendProfile.ts +++ /dev/null @@ -1,89 +0,0 @@ -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'); - } - - // Wrap in try/catch because send will throw in case of a network error. - transport.send(envelope).then(null, reason => { - __DEBUG_BUILD__ && logger.log('[Profiling] Error while sending event:', reason); - }); -} diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 7b2e1b60e848..6c9b4d8ed6b9 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -1,20 +1,12 @@ -import { DEFAULT_ENVIRONMENT } from '@sentry/core'; -import type { - DsnComponents, - DynamicSamplingContext, - Event, - EventEnvelope, - EventEnvelopeHeaders, - EventItem, - Profile as SentryProfile, - SdkInfo, - SdkMetadata, - ThreadCpuProfile, -} from '@sentry/types'; -import { createEnvelope, dropUndefinedKeys, dsnToString, logger, uuid4 } from '@sentry/utils'; +/* eslint-disable max-lines */ + +import { DEFAULT_ENVIRONMENT, getCurrentHub } from '@sentry/core'; +import type { DebugImage, Envelope, Event, StackFrame, StackParser } from '@sentry/types'; +import type { Profile, ThreadCpuProfile } from '@sentry/types/src/profiling'; +import { forEachEnvelopeItem, GLOBAL_OBJ, logger, uuid4 } from '@sentry/utils'; import { WINDOW } from '../helpers'; -import type { JSSelfProfile, JSSelfProfileStack, RawThreadCpuProfile } from './jsSelfProfiling'; +import type { JSSelfProfile, JSSelfProfileStack } from './jsSelfProfiling'; const MS_TO_NS = 1e6; // Use 0 as main thread id which is identical to threadId in node:worker_threads @@ -23,9 +15,9 @@ 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_PLATFORM = ''; +let OS_PLATFORM_VERSION = ''; +let OS_ARCH = ''; let OS_BROWSER = (WINDOW.navigator && WINDOW.navigator.userAgent) || ''; let OS_MODEL = ''; const OS_LOCALE = @@ -72,7 +64,7 @@ if (isUserAgentData(userAgentData)) { .catch(e => void e); } -function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): profile is RawThreadCpuProfile { +function isProcessedJSSelfProfile(profile: ThreadCpuProfile | JSSelfProfile): profile is JSSelfProfile { return !('thread_metadata' in profile); } @@ -81,8 +73,8 @@ function isRawThreadCpuProfile(profile: ThreadCpuProfile | RawThreadCpuProfile): /** * */ -export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThreadCpuProfile): ThreadCpuProfile { - if (!isRawThreadCpuProfile(profile)) { +export function enrichWithThreadInformation(profile: ThreadCpuProfile | JSSelfProfile): ThreadCpuProfile { + if (!isProcessedJSSelfProfile(profile)) { return profile; } @@ -93,52 +85,7 @@ export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThrea // 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, - }), + profile?: JSSelfProfile; }; } @@ -171,50 +118,30 @@ function getTraceId(event: Event): string { /** * Creates a profiling event envelope from a Sentry event. */ -export function createProfilingEventEnvelope( +export function createProfilePayload( event: ProfiledEvent, - dsn: DsnComponents, - metadata?: SdkMetadata, - tunnel?: string, -): EventEnvelope | null { + processedProfile: JSSelfProfile, + profile_id: string, +): Profile { 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) { + if (processedProfile === undefined || processedProfile === null) { throw new TypeError( - `Cannot construct profiling event envelope without a valid profile. Got ${rawProfile} instead.`, + `Cannot construct profiling event envelope without a valid profile. Got ${processedProfile} 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 enrichedThreadProfile = enrichWithThreadInformation(processedProfile); 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, + const profile: Profile = { + event_id: profile_id, timestamp: new Date(transactionStartMs).toISOString(), platform: 'javascript', version: '1', @@ -236,6 +163,9 @@ export function createProfilingEventEnvelope( architecture: OS_ARCH, is_emulator: false, }, + debug_meta: { + images: applyDebugMetadata(processedProfile.resources), + }, profile: enrichedThreadProfile, transactions: [ { @@ -249,15 +179,7 @@ export function createProfilingEventEnvelope( ], }; - const envelopeItem: EventItem = [ - { - type: 'profile', - }, - // @ts-ignore this is missing in typedef - profile, - ]; - - return createEnvelope(envelopeHeaders, [envelopeItem]); + return profile; } /** @@ -267,31 +189,16 @@ 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 { +export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Profile['profile'] { let EMPTY_STACK_ID: undefined | number = undefined; let STACK_ID = 0; // Initialize the profile that we will fill with data - const profile: ThreadCpuProfile = { + const profile: Profile['profile'] = { samples: [], stacks: [], frames: [], @@ -351,7 +258,7 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Threa stackTop = stackTop.parentId === undefined ? undefined : input.stacks[stackTop.parentId]; } - const sample: ThreadCpuProfile['samples'][0] = { + const sample: Profile['profile']['samples'][0] = { // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp - start) * MS_TO_NS).toFixed(0), stack_id: STACK_ID, @@ -365,3 +272,195 @@ export function convertJSSelfProfileToSampledFormat(input: JSSelfProfile): Threa return profile; } + +/** + * Adds items to envelope if they are not already present - mutates the envelope. + * @param envelope + */ +export function addProfilesToEnvelope(envelope: Envelope, profiles: Profile[]): Envelope { + if (!profiles.length) { + return envelope; + } + + for (const profile of profiles) { + // @ts-ignore untyped envelope + envelope[1].push([{ type: 'profile' }, profile]); + } + return envelope; +} + +/** + * Finds transactions with profile_id context in the envelope + * @param envelope + * @returns + */ +export function findProfiledTransactionsFromEnvelope(envelope: Envelope): Event[] { + const events: Event[] = []; + + forEachEnvelopeItem(envelope, (item, type) => { + if (type !== 'transaction') { + return; + } + + for (let j = 1; j < item.length; j++) { + const event = item[j] as Event; + + if (event && event.contexts && event.contexts['profile'] && event.contexts['profile']['profile_id']) { + events.push(item[j] as Event); + } + } + }); + + return events; +} + +const debugIdStackParserCache = new WeakMap>(); +/** + * Applies debug meta data to an event from a list of paths to resources (sourcemaps) + */ +export function applyDebugMetadata(resource_paths: ReadonlyArray): DebugImage[] { + const debugIdMap = GLOBAL_OBJ._sentryDebugIds; + + if (!debugIdMap) { + return []; + } + + const hub = getCurrentHub(); + if (!hub) { + return []; + } + const client = hub.getClient(); + if (!client) { + return []; + } + const options = client.getOptions(); + if (!options) { + return []; + } + const stackParser = options.stackParser; + if (!stackParser) { + return []; + } + + let debugIdStackFramesCache: Map; + const cachedDebugIdStackFrameCache = debugIdStackParserCache.get(stackParser); + if (cachedDebugIdStackFrameCache) { + debugIdStackFramesCache = cachedDebugIdStackFrameCache; + } else { + debugIdStackFramesCache = new Map(); + debugIdStackParserCache.set(stackParser, debugIdStackFramesCache); + } + + // Build a map of filename -> debug_id + const filenameDebugIdMap = Object.keys(debugIdMap).reduce>((acc, debugIdStackTrace) => { + let parsedStack: StackFrame[]; + + const cachedParsedStack = debugIdStackFramesCache.get(debugIdStackTrace); + if (cachedParsedStack) { + parsedStack = cachedParsedStack; + } else { + parsedStack = stackParser(debugIdStackTrace); + debugIdStackFramesCache.set(debugIdStackTrace, parsedStack); + } + + for (let i = parsedStack.length - 1; i >= 0; i--) { + const stackFrame = parsedStack[i]; + const file = stackFrame && stackFrame.filename; + + if (stackFrame && file) { + acc[file] = debugIdMap[debugIdStackTrace] as string; + break; + } + } + return acc; + }, {}); + + const images: DebugImage[] = []; + for (const path of resource_paths) { + if (path && filenameDebugIdMap[path]) { + images.push({ + type: 'sourcemap', + code_file: path, + debug_id: filenameDebugIdMap[path] as string, + }); + } + } + + return images; +} + +/** + * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). + */ +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 + if ((typeof rate !== 'number' && typeof rate !== 'boolean') || (typeof rate === 'number' && isNaN(rate))) { + __DEBUG_BUILD__ && + logger.warn( + `[Profiling] Invalid sample rate. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify( + rate, + )} of type ${JSON.stringify(typeof rate)}.`, + ); + return false; + } + + // Boolean sample rates are always valid + if (rate === true || rate === false) { + return true; + } + + // in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false + if (rate < 0 || rate > 1) { + __DEBUG_BUILD__ && + logger.warn(`[Profiling] Invalid sample rate. Sample rate must be between 0 and 1. Got ${rate}.`); + return false; + } + return true; +} + +function isValidProfile(profile: JSSelfProfile): profile is JSSelfProfile & { profile_id: string } { + if (profile.samples.length < 2) { + 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 false; + } + + if (!profile.frames.length) { + if (__DEBUG_BUILD__) { + logger.log('[Profiling] Discarding profile because it contains no frames'); + } + return false; + } + + return true; +} + +/** + * Creates a profiling envelope item, if the profile does not pass validation, returns null. + * @param event + * @returns {Profile | null} + */ +export function createProfilingEvent(profile_id: string, profile: JSSelfProfile, event: ProfiledEvent): Profile | null { + if (!isValidProfile(profile)) { + return null; + } + + return createProfilePayload(event, profile, profile_id); +} + +export const PROFILE_MAP: Map = new Map(); +/** + * + */ +export function addProfileToMap(profile_id: string, profile: JSSelfProfile): void { + PROFILE_MAP.set(profile_id, profile); + + if (PROFILE_MAP.size > 30) { + const last: string = PROFILE_MAP.keys().next().value; + PROFILE_MAP.delete(last); + } +} diff --git a/packages/browser/test/unit/profiling/integration.test.ts b/packages/browser/test/unit/profiling/integration.test.ts deleted file mode 100644 index 1ea59ee7068e..000000000000 --- a/packages/browser/test/unit/profiling/integration.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -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/types/src/profiling.ts b/packages/types/src/profiling.ts index 1b6dac566901..d99736df735e 100644 --- a/packages/types/src/profiling.ts +++ b/packages/types/src/profiling.ts @@ -1,3 +1,4 @@ +import type { DebugImage } from './debugMeta'; export type ThreadId = string; export type FrameId = number; export type StackId = number; @@ -50,14 +51,7 @@ export interface Profile { platform: string; profile: ThreadCpuProfile; debug_meta?: { - images: { - debug_id: string; - image_addr: string; - code_file: string; - type: string; - image_size: number; - image_vmaddr: string; - }[]; + images: DebugImage[]; }; transaction?: { name: string;