diff --git a/dev-packages/e2e-tests/test-applications/node-profiling/index.js b/dev-packages/e2e-tests/test-applications/node-profiling/index.js index be569e12f921..7fbe23ac7652 100644 --- a/dev-packages/e2e-tests/test-applications/node-profiling/index.js +++ b/dev-packages/e2e-tests/test-applications/node-profiling/index.js @@ -1,11 +1,11 @@ const Sentry = require('@sentry/node'); -const Profiling = require('@sentry/profiling-node'); +const { nodeProfilingIntegration } = require('@sentry/profiling-node'); const wait = ms => new Promise(resolve => setTimeout(resolve, ms)); Sentry.init({ dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', - integrations: [new Profiling.ProfilingIntegration()], + integrations: [nodeProfilingIntegration()], tracesSampleRate: 1.0, profilesSampleRate: 1.0, }); diff --git a/packages/profiling-node/src/hubextensions.ts b/packages/profiling-node/src/hubextensions.ts deleted file mode 100644 index 2f615eb1324e..000000000000 --- a/packages/profiling-node/src/hubextensions.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { getMainCarrier, spanToJSON } from '@sentry/core'; -import type { NodeClient } from '@sentry/node-experimental'; -import type { CustomSamplingContext, Hub, Transaction, TransactionContext } from '@sentry/types'; -import { logger, uuid4 } from '@sentry/utils'; - -import { CpuProfilerBindings } from './cpu_profiler'; -import { DEBUG_BUILD } from './debug-build'; -import { isValidSampleRate } from './utils'; - -export const MAX_PROFILE_DURATION_MS = 30 * 1000; - -type StartTransaction = ( - this: Hub, - transactionContext: TransactionContext, - customSamplingContext?: CustomSamplingContext, -) => Transaction; - -/** - * Takes a transaction and determines if it should be profiled or not. If it should be profiled, it returns the - * profile_id, otherwise returns undefined. Takes care of setting profile context on transaction as well - */ -export function maybeProfileTransaction( - client: NodeClient | undefined, - transaction: Transaction, - customSamplingContext?: CustomSamplingContext, -): string | undefined { - // profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate. We dont perform - // the actual multiplication to get the final rate, but we discard the profile if the transaction was sampled, - // so anything after this block from here is based on the transaction sampling. - // eslint-disable-next-line deprecation/deprecation - if (!transaction.sampled) { - return; - } - - // Client and options are required for profiling - if (!client) { - DEBUG_BUILD && logger.log('[Profiling] Profiling disabled, no client found.'); - return; - } - - const options = client.getOptions(); - if (!options) { - DEBUG_BUILD && logger.log('[Profiling] Profiling disabled, no options found.'); - return; - } - - const profilesSampler = options.profilesSampler; - let profilesSampleRate: number | boolean | undefined = options.profilesSampleRate; - - // Prefer sampler to sample rate if both are provided. - if (typeof profilesSampler === 'function') { - // eslint-disable-next-line deprecation/deprecation - profilesSampleRate = profilesSampler({ transactionContext: transaction.toContext(), ...customSamplingContext }); - } - - // 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; - } - - // 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 ${ - typeof profilesSampler === 'function' - ? 'profileSampler returned 0 or false' - : 'a negative sampling decision was inherited or profileSampleRate is set to 0' - }`, - ); - return; - } - - // 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 (!sampled) { - DEBUG_BUILD && - logger.log( - `[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number( - profilesSampleRate, - )})`, - ); - return; - } - - const profile_id = uuid4(); - CpuProfilerBindings.startProfiling(profile_id); - DEBUG_BUILD && logger.log(`[Profiling] started profiling transaction: ${spanToJSON(transaction).description}`); - - // set transaction context - do this regardless if profiling fails down the line - // so that we can still see the profile_id in the transaction context - return profile_id; -} - -/** - * Stops the profiler for profile_id and returns the profile - * @param transaction - * @param profile_id - * @returns - */ -export function stopTransactionProfile( - transaction: Transaction, - profile_id: string | undefined, -): ReturnType<(typeof CpuProfilerBindings)['stopProfiling']> | null { - // Should not happen, but satisfy the type checker and be safe regardless. - if (!profile_id) { - return null; - } - - const profile = CpuProfilerBindings.stopProfiling(profile_id); - - DEBUG_BUILD && logger.log(`[Profiling] stopped profiling of transaction: ${spanToJSON(transaction).description}`); - - // In case of an overlapping transaction, stopProfiling may return null and silently ignore the overlapping profile. - if (!profile) { - DEBUG_BUILD && - logger.log( - `[Profiling] profiler returned null profile for: ${spanToJSON(transaction).description}`, - 'this may indicate an overlapping transaction or a call to stopProfiling with a profile title that was never started', - ); - return null; - } - - // Assign profile_id to the profile - profile.profile_id = profile_id; - return profile; -} - -/** - * 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. - */ -export function __PRIVATE__wrapStartTransactionWithProfiling(startTransaction: StartTransaction): StartTransaction { - return function wrappedStartTransaction( - this: Hub, - transactionContext: TransactionContext, - customSamplingContext?: CustomSamplingContext, - ): Transaction { - const transaction: Transaction = startTransaction.call(this, transactionContext, customSamplingContext); - - // Client is required if we want to profile - // eslint-disable-next-line deprecation/deprecation - const client = this.getClient() as NodeClient | undefined; - if (!client) { - return transaction; - } - - // Check if we should profile this transaction. If a profile_id is returned, then profiling has been started. - const profile_id = maybeProfileTransaction(client, transaction, customSamplingContext); - if (!profile_id) { - return transaction; - } - - // 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 profile: ReturnType<(typeof CpuProfilerBindings)['stopProfiling']> | null = null; - - const options = client.getOptions(); - // Not intended for external use, hence missing types, but we want to profile a couple of things at Sentry that - // currently exceed the default timeout set by the SDKs. - const maxProfileDurationMs = - (options._experiments && options._experiments['maxProfileDurationMs']) || MAX_PROFILE_DURATION_MS; - - // Enqueue a timeout to prevent profiles from running over max duration. - let maxDurationTimeoutID: NodeJS.Timeout | void = global.setTimeout(() => { - DEBUG_BUILD && - logger.log( - '[Profiling] max profile duration elapsed, stopping profiling for:', - spanToJSON(transaction).description, - ); - - profile = stopTransactionProfile(transaction, profile_id); - }, maxProfileDurationMs); - - // We need to reference the original finish call to avoid creating an infinite loop - const originalEnd = transaction.end.bind(transaction); - - // Wrap the transaction finish method to stop profiling and set the profile on the transaction. - function profilingWrappedTransactionEnd(): void { - if (!profile_id) { - return originalEnd(); - } - - // We stop the handler first to ensure that the timeout is cleared and the profile is stopped. - if (maxDurationTimeoutID) { - global.clearTimeout(maxDurationTimeoutID); - maxDurationTimeoutID = undefined; - } - - // 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. - if (!profile) { - profile = stopTransactionProfile(transaction, profile_id); - } - - // @ts-expect-error profile is not part of metadata - // eslint-disable-next-line deprecation/deprecation - transaction.setMetadata({ profile }); - return originalEnd(); - } - - transaction.end = profilingWrappedTransactionEnd; - return transaction; - }; -} - -/** - * Patches startTransaction and stopTransaction with profiling logic. - * This is used by the SDK's that do not support event hooks. - * @private - */ -function _addProfilingExtensionMethods(): void { - const carrier = getMainCarrier(); - if (!carrier.__SENTRY__) { - 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']) { - DEBUG_BUILD && logger.log('[Profiling] startTransaction does not exists, profiling will not work.'); - return; - } - - DEBUG_BUILD && logger.log('[Profiling] startTransaction exists, patching it with profiling functionality...'); - - carrier.__SENTRY__.extensions['startTransaction'] = __PRIVATE__wrapStartTransactionWithProfiling( - // This is patched by sentry/tracing, we are going to re-patch it... - carrier.__SENTRY__.extensions['startTransaction'] as StartTransaction, - ); -} - -/** - * This patches the global object and injects the Profiling extensions methods - */ -export function addProfilingExtensionMethods(): void { - _addProfilingExtensionMethods(); -} diff --git a/packages/profiling-node/src/index.ts b/packages/profiling-node/src/index.ts index 086fbb86de58..a806019cc5ee 100644 --- a/packages/profiling-node/src/index.ts +++ b/packages/profiling-node/src/index.ts @@ -1,5 +1 @@ -export { - // eslint-disable-next-line deprecation/deprecation - ProfilingIntegration, - nodeProfilingIntegration, -} from './integration'; +export { nodeProfilingIntegration } from './integration'; diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 90c3ec6d7ffb..a417ac2b1c78 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -1,34 +1,14 @@ -import { spanToJSON } from '@sentry/core'; +import { defineIntegration, getCurrentScope, getRootSpan, spanToJSON } from '@sentry/core'; import type { NodeClient } from '@sentry/node-experimental'; -import type { - Event, - EventProcessor, - Hub, - Integration, - IntegrationFn, - IntegrationFnResult, - Transaction, -} from '@sentry/types'; +import type { IntegrationFn, Span } from '@sentry/types'; import { logger } from '@sentry/utils'; import { DEBUG_BUILD } from './debug-build'; -import { - MAX_PROFILE_DURATION_MS, - addProfilingExtensionMethods, - maybeProfileTransaction, - stopTransactionProfile, -} from './hubextensions'; +import { MAX_PROFILE_DURATION_MS, maybeProfileSpan, stopSpanProfile } from './spanProfileUtils'; import type { Profile, RawThreadCpuProfile } from './types'; -import { - addProfilesToEnvelope, - createProfilingEvent, - createProfilingEventEnvelope, - findProfiledTransactionsFromEnvelope, - isProfiledTransactionEvent, - maybeRemoveProfileFromSdkMetadata, -} from './utils'; +import { addProfilesToEnvelope, createProfilingEvent, findProfiledTransactionsFromEnvelope } from './utils'; const MAX_PROFILE_QUEUE_LENGTH = 50; const PROFILE_QUEUE: RawThreadCpuProfile[] = []; @@ -43,36 +23,19 @@ function addToProfileQueue(profile: RawThreadCpuProfile): void { } } -/** - * We need this integration in order to send data to Sentry. We hook into the event processor - * and inspect each event to see if it is a transaction event and if that transaction event - * contains a profile on it's metadata. If that is the case, we create a profiling event envelope - * and delete the profile from the transaction metadata. - * - * @deprecated Use `nodeProfilingIntegration` instead. - */ -export class ProfilingIntegration implements Integration { - /** - * @inheritDoc - */ - public readonly name: string; - public getCurrentHub?: () => Hub; - - public constructor() { - this.name = 'ProfilingIntegration'; - } +/** Exported only for tests. */ +export const _nodeProfilingIntegration = (() => { + return { + name: 'ProfilingIntegration', + setup(client: NodeClient) { + const spanToProfileIdMap = new WeakMap(); - /** - * @inheritDoc - */ - public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void { - this.getCurrentHub = getCurrentHub; - // eslint-disable-next-line deprecation/deprecation - const client = this.getCurrentHub().getClient() as NodeClient; + client.on('spanStart', span => { + if (span !== getRootSpan(span)) { + return; + } - if (client && typeof client.on === 'function') { - client.on('startTransaction', (transaction: Transaction) => { - const profile_id = maybeProfileTransaction(client, transaction, undefined); + const profile_id = maybeProfileSpan(client, span, undefined); if (profile_id) { const options = client.getOptions(); @@ -92,34 +55,31 @@ export class ProfilingIntegration implements Integration { DEBUG_BUILD && logger.log( '[Profiling] max profile duration elapsed, stopping profiling for:', - spanToJSON(transaction).description, + spanToJSON(span).description, ); - const profile = stopTransactionProfile(transaction, profile_id); + const profile = stopSpanProfile(span, profile_id); if (profile) { addToProfileQueue(profile); } }, maxProfileDurationMs); - // eslint-disable-next-line deprecation/deprecation - transaction.setContext('profile', { profile_id }); - // @ts-expect-error profile_id is not part of the metadata type - // eslint-disable-next-line deprecation/deprecation - transaction.setMetadata({ profile_id: profile_id }); + getCurrentScope().setContext('profile', { profile_id }); + + spanToProfileIdMap.set(span, profile_id); } }); - client.on('finishTransaction', transaction => { - // @ts-expect-error profile_id is not part of the metadata type - // eslint-disable-next-line deprecation/deprecation - const profile_id = transaction.metadata.profile_id; - if (profile_id && typeof profile_id === 'string') { + client.on('spanEnd', span => { + const profile_id = spanToProfileIdMap.get(span); + + if (profile_id) { if (PROFILE_TIMEOUTS[profile_id]) { global.clearTimeout(PROFILE_TIMEOUTS[profile_id]); // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete PROFILE_TIMEOUTS[profile_id]; } - const profile = stopTransactionProfile(transaction, profile_id); + const profile = stopSpanProfile(span, profile_id); if (profile) { addToProfileQueue(profile); @@ -190,71 +150,9 @@ export class ProfilingIntegration implements Integration { addProfilesToEnvelope(envelope, profilesToAddToEnvelope); }); - } else { - // Patch the carrier methods and add the event processor. - addProfilingExtensionMethods(); - addGlobalEventProcessor(this.handleGlobalEvent.bind(this)); - } - } - - /** - * @inheritDoc - */ - public async handleGlobalEvent(event: Event): Promise { - if (this.getCurrentHub === undefined) { - return maybeRemoveProfileFromSdkMetadata(event); - } - - if (isProfiledTransactionEvent(event)) { - // 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 = this.getCurrentHub(); - - // eslint-disable-next-line deprecation/deprecation - const client = hub.getClient(); - - if (!client) { - DEBUG_BUILD && - logger.log( - '[Profiling] getClient did not return a Client, removing profile from event and forwarding to next event processors.', - ); - return maybeRemoveProfileFromSdkMetadata(event); - } - - const dsn = client.getDsn(); - if (!dsn) { - DEBUG_BUILD && - logger.log( - '[Profiling] getDsn did not return a Dsn, removing profile from event and forwarding to next event processors.', - ); - return maybeRemoveProfileFromSdkMetadata(event); - } - - const transport = client.getTransport(); - if (!transport) { - DEBUG_BUILD && - logger.log( - '[Profiling] getTransport did not return a Transport, removing profile from event and forwarding to next event processors.', - ); - return maybeRemoveProfileFromSdkMetadata(event); - } - - // If all required components are available, we construct a profiling event envelope and send it to Sentry. - DEBUG_BUILD && logger.log('[Profiling] Preparing envelope and sending a profiling event'); - const envelope = createProfilingEventEnvelope(event, dsn); - - if (envelope) { - // Fire and forget, we don't want to block the main event processing flow. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - transport.send(envelope); - } - } - - // Ensure sdkProcessingMetadata["profile"] is removed from the event before forwarding it to the next event processor. - return maybeRemoveProfileFromSdkMetadata(event); - } -} + }, + }; +}) satisfies IntegrationFn; /** * We need this integration in order to send data to Sentry. We hook into the event processor @@ -262,7 +160,4 @@ export class ProfilingIntegration implements Integration { * contains a profile on it's metadata. If that is the case, we create a profiling event envelope * and delete the profile from the transaction metadata. */ -export const nodeProfilingIntegration = (() => { - // eslint-disable-next-line deprecation/deprecation - return new ProfilingIntegration() as unknown as IntegrationFnResult; -}) satisfies IntegrationFn; +export const nodeProfilingIntegration = defineIntegration(_nodeProfilingIntegration); diff --git a/packages/profiling-node/src/spanProfileUtils.ts b/packages/profiling-node/src/spanProfileUtils.ts new file mode 100644 index 000000000000..61e4b7d8a4de --- /dev/null +++ b/packages/profiling-node/src/spanProfileUtils.ts @@ -0,0 +1,136 @@ +import { spanIsSampled, spanToJSON } from '@sentry/core'; +import type { NodeClient } from '@sentry/node-experimental'; +import type { CustomSamplingContext, Span } from '@sentry/types'; +import { logger, uuid4 } from '@sentry/utils'; + +import { CpuProfilerBindings } from './cpu_profiler'; +import { DEBUG_BUILD } from './debug-build'; +import { isValidSampleRate } from './utils'; + +export const MAX_PROFILE_DURATION_MS = 30 * 1000; + +/** + * Takes a transaction and determines if it should be profiled or not. If it should be profiled, it returns the + * profile_id, otherwise returns undefined. Takes care of setting profile context on transaction as well + */ +export function maybeProfileSpan( + client: NodeClient | undefined, + span: Span, + customSamplingContext?: CustomSamplingContext, +): string | undefined { + // profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate. We dont perform + // the actual multiplication to get the final rate, but we discard the profile if the span was sampled, + // so anything after this block from here is based on the span sampling. + if (!spanIsSampled(span)) { + return; + } + + // Client and options are required for profiling + if (!client) { + DEBUG_BUILD && logger.log('[Profiling] Profiling disabled, no client found.'); + return; + } + + const options = client.getOptions(); + if (!options) { + DEBUG_BUILD && logger.log('[Profiling] Profiling disabled, no options found.'); + return; + } + + const profilesSampler = options.profilesSampler; + let profilesSampleRate: number | boolean | undefined = options.profilesSampleRate; + + // Prefer sampler to sample rate if both are provided. + if (typeof profilesSampler === 'function') { + const { description: spanName = '', data } = spanToJSON(span); + // We bail out early if that is not the case + const parentSampled = true; + + profilesSampleRate = profilesSampler({ + name: spanName, + attributes: data, + transactionContext: { + name: spanName, + parentSampled, + }, + parentSampled, + ...customSamplingContext, + }); + } + + // 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; + } + + // 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 ${ + typeof profilesSampler === 'function' + ? 'profileSampler returned 0 or false' + : 'a negative sampling decision was inherited or profileSampleRate is set to 0' + }`, + ); + return; + } + + // 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 (!sampled) { + DEBUG_BUILD && + logger.log( + `[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number( + profilesSampleRate, + )})`, + ); + return; + } + + const profile_id = uuid4(); + CpuProfilerBindings.startProfiling(profile_id); + DEBUG_BUILD && logger.log(`[Profiling] started profiling transaction: ${spanToJSON(span).description}`); + + // set transaction context - do this regardless if profiling fails down the line + // so that we can still see the profile_id in the transaction context + return profile_id; +} + +/** + * Stops the profiler for profile_id and returns the profile + * @param transaction + * @param profile_id + * @returns + */ +export function stopSpanProfile( + span: Span, + profile_id: string | undefined, +): ReturnType<(typeof CpuProfilerBindings)['stopProfiling']> | null { + // Should not happen, but satisfy the type checker and be safe regardless. + if (!profile_id) { + return null; + } + + const profile = CpuProfilerBindings.stopProfiling(profile_id); + + DEBUG_BUILD && logger.log(`[Profiling] stopped profiling of transaction: ${spanToJSON(span).description}`); + + // In case of an overlapping span, stopProfiling may return null and silently ignore the overlapping profile. + if (!profile) { + DEBUG_BUILD && + logger.log( + `[Profiling] profiler returned null profile for: ${spanToJSON(span).description}`, + 'this may indicate an overlapping span or a call to stopProfiling with a profile title that was never started', + ); + return null; + } + + // Assign profile_id to the profile + profile.profile_id = profile_id; + return profile; +} diff --git a/packages/profiling-node/src/utils.ts b/packages/profiling-node/src/utils.ts index 97e17eab8888..626d79db9be1 100644 --- a/packages/profiling-node/src/utils.ts +++ b/packages/profiling-node/src/utils.ts @@ -1,24 +1,10 @@ -/* eslint-disable max-lines */ import * as os from 'os'; -import type { - Context, - DsnComponents, - DynamicSamplingContext, - Envelope, - Event, - EventEnvelope, - EventEnvelopeHeaders, - EventItem, - SdkInfo, - SdkMetadata, - StackFrame, - StackParser, -} from '@sentry/types'; +import type { Context, Envelope, Event, StackFrame, StackParser } from '@sentry/types'; import { env, versions } from 'process'; import { isMainThread, threadId } from 'worker_threads'; import * as Sentry from '@sentry/node-experimental'; -import { GLOBAL_OBJ, createEnvelope, dropUndefinedKeys, dsnToString, forEachEnvelopeItem, logger } from '@sentry/utils'; +import { GLOBAL_OBJ, forEachEnvelopeItem, logger } from '@sentry/utils'; import { DEBUG_BUILD } from './debug-build'; import type { Profile, ProfiledEvent, RawThreadCpuProfile, ThreadCpuProfile } from './types'; @@ -75,67 +61,6 @@ export function enrichWithThreadInformation(profile: ThreadCpuProfile | RawThrea }; } -/** - * Extract sdk info from from the API metadata - * @param {SdkMetadata | undefined} metadata - * @returns {SdkInfo | undefined} - */ -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. - * - * @param {Event} event - * @param {SdkInfo | undefined} sdkInfo - * @returns {Event} - */ -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; -} - -/** - * - * @param {Event} event - * @param {SdkInfo | undefined} sdkInfo - * @param {string | undefined} tunnel - * @param {DsnComponents} dsn - * @returns {EventEnvelopeHeaders} - */ -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, - }), - }; -} - /** * Creates a profiling event envelope from a Sentry event. If profile does not pass * validation, returns null. @@ -274,67 +199,6 @@ function createProfilePayload( return profile; } -/** - * Creates an envelope from a profiling event. - * @param {Event} Profile - * @param {DsnComponents} dsn - * @param {SdkMetadata} metadata - * @param {string|undefined} tunnel - * @returns {Envelope|null} - */ -export function createProfilingEventEnvelope( - event: ProfiledEvent, - dsn: DsnComponents, - metadata?: SdkMetadata, - tunnel?: string, -): EventEnvelope | null { - const sdkInfo = getSdkMetadataForEnvelopeHeader(metadata); - enhanceEventWithSdkInfo(event, metadata && metadata.sdk); - - const envelopeHeaders = createEventEnvelopeHeaders(event, sdkInfo, tunnel, dsn); - const profile = createProfilingEventFromTransaction(event); - - if (!profile) { - return null; - } - - const envelopeItem: EventItem = [ - { - type: 'profile', - }, - // @ts-expect-error profile is not part of EventItem yet - profile, - ]; - - return createEnvelope(envelopeHeaders, [envelopeItem]); -} - -/** - * Check if event metadata contains profile information - * @param {Event} - * @returns {boolean} - */ -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. - * - * @param {Event | ProfiledEvent} event - * @returns {Event} - */ -export function maybeRemoveProfileFromSdkMetadata(event: Event | ProfiledEvent): Event { - if (!isProfiledTransactionEvent(event)) { - return event; - } - - delete event.sdkProcessingMetadata.profile; - return event; -} - /** * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). * @param {unknown} rate diff --git a/packages/profiling-node/test/hubextensions.test.ts b/packages/profiling-node/test/hubextensions.test.ts deleted file mode 100644 index 060892be9d0f..000000000000 --- a/packages/profiling-node/test/hubextensions.test.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type { - BaseTransportOptions, - ClientOptions, - Context, - Hub, - Transaction, - TransactionMetadata, -} from '@sentry/types'; - -import type { NodeClient } from '@sentry/node-experimental'; - -import { CpuProfilerBindings } from '../src/cpu_profiler'; -import { __PRIVATE__wrapStartTransactionWithProfiling } from '../src/hubextensions'; - -function makeTransactionMock(options = {}): Transaction { - return { - metadata: {}, - sampled: true, - contexts: {}, - startChild: () => ({ end: () => void 0 }), - end() { - return; - }, - toContext: () => { - return {}; - }, - setContext(this: Transaction, key: string, context: Context) { - // @ts-expect-error - contexts is private - this.contexts[key] = context; - }, - setMetadata(this: Transaction, metadata: Partial) { - // eslint-disable-next-line deprecation/deprecation - this.metadata = { ...metadata } as TransactionMetadata; - }, - ...options, - } as unknown as Transaction; -} - -function makeHubMock({ - profilesSampleRate, - client, -}: { - profilesSampleRate: number | undefined; - client?: Partial; -}): Hub { - return { - getClient: jest.fn().mockImplementation(() => { - return { - getOptions: jest.fn().mockImplementation(() => { - return { - profilesSampleRate, - } as unknown as ClientOptions; - }), - ...(client ?? {}), - }; - }), - } as unknown as Hub; -} - -describe('hubextensions', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.restoreAllMocks(); - }); - it('skips profiling if profilesSampleRate is not set (undefined)', () => { - const hub = makeHubMock({ profilesSampleRate: undefined }); - const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); - const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); - - const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); - const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, {}); - transaction.end(); - - expect(startTransaction).toHaveBeenCalledTimes(1); - expect(startProfilingSpy).not.toHaveBeenCalled(); - expect((transaction.metadata as any)?.profile).toBeUndefined(); - }); - it('skips profiling if profilesSampleRate is set to 0', () => { - const hub = makeHubMock({ profilesSampleRate: 0 }); - const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); - const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); - - const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); - const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, {}); - transaction.end(); - - expect(startTransaction).toHaveBeenCalledTimes(1); - expect(startProfilingSpy).not.toHaveBeenCalled(); - - expect((transaction.metadata as any)?.profile).toBeUndefined(); - }); - it('skips profiling when random > sampleRate', () => { - const hub = makeHubMock({ profilesSampleRate: 0.5 }); - jest.spyOn(global.Math, 'random').mockReturnValue(1); - const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); - const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); - - const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); - const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, {}); - transaction.end(); - - expect(startTransaction).toHaveBeenCalledTimes(1); - expect(startProfilingSpy).not.toHaveBeenCalled(); - - expect((transaction.metadata as any)?.profile).toBeUndefined(); - }); - it('starts the profiler', () => { - const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); - const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); - - const hub = makeHubMock({ profilesSampleRate: 1 }); - const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); - - const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); - const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, {}); - transaction.end(); - - expect(startTransaction).toHaveBeenCalledTimes(1); - expect(startProfilingSpy).toHaveBeenCalledTimes(1); - expect(stopProfilingSpy).toHaveBeenCalledTimes(1); - - expect((transaction.metadata as any)?.profile).toBeDefined(); - }); - - it('does not start the profiler if transaction is sampled', () => { - const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); - const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); - - const hub = makeHubMock({ profilesSampleRate: 1 }); - const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock({ sampled: false })); - - const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); - const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, {}); - transaction.end(); - - expect(startTransaction).toHaveBeenCalledTimes(1); - expect(startProfilingSpy).not.toHaveBeenCalledTimes(1); - expect(stopProfilingSpy).not.toHaveBeenCalledTimes(1); - }); - - it('disabled if neither profilesSampler and profilesSampleRate are not set', () => { - const hub = makeHubMock({ profilesSampleRate: undefined }); - const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); - - const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); - const samplingContext = { beep: 'boop' }; - const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext); - transaction.end(); - - const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); - expect(startProfilingSpy).not.toHaveBeenCalled(); - }); - - it('does not call startProfiling if profilesSampler returns invalid rate', () => { - const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); - const options = { profilesSampler: jest.fn().mockReturnValue(NaN) }; - const hub = makeHubMock({ - profilesSampleRate: undefined, - client: { - // @ts-expect-error partial client - getOptions: () => options, - }, - }); - const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); - - const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); - const samplingContext = { beep: 'boop' }; - const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext); - transaction.end(); - - expect(options.profilesSampler).toHaveBeenCalled(); - expect(startProfilingSpy).not.toHaveBeenCalled(); - }); - - it('does not call startProfiling if profilesSampleRate is invalid', () => { - const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); - const options = { profilesSampler: jest.fn().mockReturnValue(NaN) }; - const hub = makeHubMock({ - profilesSampleRate: NaN, - client: { - // @ts-expect-error partial client - getOptions: () => options, - }, - }); - const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); - - const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); - const samplingContext = { beep: 'boop' }; - const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext); - transaction.end(); - - expect(options.profilesSampler).toHaveBeenCalled(); - expect(startProfilingSpy).not.toHaveBeenCalled(); - }); - - it('calls profilesSampler with sampling context', () => { - const options = { profilesSampler: jest.fn() }; - const hub = makeHubMock({ - profilesSampleRate: undefined, - client: { - // @ts-expect-error partial client - getOptions: () => options, - }, - }); - const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); - - const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); - const samplingContext = { beep: 'boop' }; - const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext); - transaction.end(); - - expect(options.profilesSampler).toHaveBeenCalledWith({ - ...samplingContext, - transactionContext: transaction.toContext(), - }); - }); - - it('prioritizes profilesSampler outcome over profilesSampleRate', () => { - const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); - const options = { profilesSampler: jest.fn().mockReturnValue(1) }; - const hub = makeHubMock({ - profilesSampleRate: 0, - client: { - // @ts-expect-error partial client - getOptions: () => options, - }, - }); - const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock()); - - const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction); - const samplingContext = { beep: 'boop' }; - const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext); - transaction.end(); - - expect(startProfilingSpy).toHaveBeenCalled(); - }); -}); diff --git a/packages/profiling-node/test/index.test.ts b/packages/profiling-node/test/index.test.ts deleted file mode 100644 index 0a074226f692..000000000000 --- a/packages/profiling-node/test/index.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import * as Sentry from '@sentry/node-experimental'; -import type { Transport } from '@sentry/types'; - -import { getMainCarrier } from '@sentry/core'; -import { ProfilingIntegration } from '../src/index'; -import type { Profile } from '../src/types'; - -interface MockTransport extends Transport { - events: any[]; -} - -function makeStaticTransport(): MockTransport { - return { - events: [] as any[], - send: function (...args: any[]) { - this.events.push(args); - return Promise.resolve({}); - }, - flush: function () { - return Promise.resolve(true); - }, - }; -} - -function makeClientWithoutHooks(): [Sentry.NodeClient, MockTransport] { - // eslint-disable-next-line deprecation/deprecation - const integration = new ProfilingIntegration(); - const transport = makeStaticTransport(); - const client = new Sentry.NodeClient({ - stackParser: Sentry.defaultStackParser, - tracesSampleRate: 1, - profilesSampleRate: 1, - debug: true, - environment: 'test-environment', - dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', - integrations: [integration], - transport: () => transport, - }); - // eslint-disable-next-line deprecation/deprecation - client.setupIntegrations = () => { - integration.setupOnce( - cb => { - // @ts-expect-error __SENTRY__ is private - getMainCarrier().__SENTRY__.globalEventProcessors = [cb]; - }, - // eslint-disable-next-line deprecation/deprecation - () => Sentry.getCurrentHub(), - ); - }; - // @ts-expect-error override private property - client.on = undefined; - return [client, transport]; -} - -function findAllProfiles(transport: MockTransport): [any, Profile][] | null { - return transport?.events.filter(call => { - return call[0][1][0][0].type === 'profile'; - }); -} - -function findProfile(transport: MockTransport): Profile | null { - return ( - transport?.events.find(call => { - return call[0][1][0][0].type === 'profile'; - })?.[0][1][0][1] ?? null - ); -} - -const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - -describe('Sentry - Profiling', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.useRealTimers(); - // We will mock the carrier as if it has been initialized by the SDK, else everything is short circuited - getMainCarrier().__SENTRY__ = {}; - }); - afterEach(() => { - delete getMainCarrier().__SENTRY__; - }); - describe('without hooks', () => { - it('profiles a transaction', async () => { - const [client, transport] = makeClientWithoutHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); - - // eslint-disable-next-line deprecation/deprecation - const transaction = Sentry.startTransaction({ name: 'title' }); - await wait(500); - transaction.end(); - - await Sentry.flush(500); - expect(findProfile(transport)).not.toBe(null); - }); - - it('can profile overlapping transactions', async () => { - const [client, transport] = makeClientWithoutHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); - - // eslint-disable-next-line deprecation/deprecation - const t1 = Sentry.startTransaction({ name: 'outer' }); - // eslint-disable-next-line deprecation/deprecation - const t2 = Sentry.startTransaction({ name: 'inner' }); - await wait(500); - - t2.end(); - t1.end(); - - await Sentry.flush(500); - - expect(findAllProfiles(transport)?.[0]?.[0]?.[1]?.[0]?.[1].transaction.name).toBe('inner'); - expect(findAllProfiles(transport)?.[1]?.[0]?.[1]?.[0]?.[1].transaction.name).toBe('outer'); - expect(findAllProfiles(transport)).toHaveLength(2); - expect(findProfile(transport)).not.toBe(null); - }); - - it('does not discard overlapping transaction with same title', async () => { - const [client, transport] = makeClientWithoutHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); - - // eslint-disable-next-line deprecation/deprecation - const t1 = Sentry.startTransaction({ name: 'same-title' }); - // eslint-disable-next-line deprecation/deprecation - const t2 = Sentry.startTransaction({ name: 'same-title' }); - await wait(500); - t2.end(); - t1.end(); - - await Sentry.flush(500); - expect(findAllProfiles(transport)).toHaveLength(2); - expect(findProfile(transport)).not.toBe(null); - }); - - it('does not crash if end is called multiple times', async () => { - const [client, transport] = makeClientWithoutHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); - - // eslint-disable-next-line deprecation/deprecation - const transaction = Sentry.startTransaction({ name: 'title' }); - await wait(500); - transaction.end(); - transaction.end(); - - await Sentry.flush(500); - expect(findAllProfiles(transport)).toHaveLength(1); - expect(findProfile(transport)).not.toBe(null); - }); - }); -}); diff --git a/packages/profiling-node/test/integration.test.ts b/packages/profiling-node/test/integration.test.ts index 7efd1cd03878..40ae171192e8 100644 --- a/packages/profiling-node/test/integration.test.ts +++ b/packages/profiling-node/test/integration.test.ts @@ -1,281 +1,74 @@ import { EventEmitter } from 'events'; -import type { Event, Hub, Transport } from '@sentry/types'; -import { logger } from '@sentry/utils'; +import type { Transport } from '@sentry/types'; -import { ProfilingIntegration } from '../src/integration'; -import type { ProfiledEvent } from '../src/types'; - -function assertCleanProfile(event: ProfiledEvent | Event): void { - expect(event.sdkProcessingMetadata?.profile).toBeUndefined(); -} - -function makeProfiledEvent(): ProfiledEvent { - return { - type: 'transaction', - sdkProcessingMetadata: { - profile: { - profile_id: 'id', - profiler_logging_mode: 'lazy', - samples: [ - { - elapsed_since_start_ns: '0', - thread_id: '0', - stack_id: 0, - }, - { - elapsed_since_start_ns: '1', - thread_id: '0', - stack_id: 0, - }, - ], - measurements: {}, - frames: [], - stacks: [], - resources: [], - }, - }, - }; -} +import type { NodeClient } from '@sentry/node-experimental'; +import { _nodeProfilingIntegration } from '../src/integration'; describe('ProfilingIntegration', () => { afterEach(() => { jest.clearAllMocks(); }); it('has a name', () => { - // eslint-disable-next-line deprecation/deprecation - expect(new ProfilingIntegration().name).toBe('ProfilingIntegration'); - }); - - it('stores a reference to getCurrentHub', () => { - // eslint-disable-next-line deprecation/deprecation - const integration = new ProfilingIntegration(); - - const getCurrentHub = jest.fn().mockImplementation(() => { - return { - getClient: jest.fn(), - }; - }); - const addGlobalEventProcessor = () => void 0; - - integration.setupOnce(addGlobalEventProcessor, getCurrentHub); - expect(integration.getCurrentHub).toBe(getCurrentHub); + expect(_nodeProfilingIntegration().name).toBe('ProfilingIntegration'); }); - describe('without hooks', () => { - it('does not call transporter if null profile is received', () => { - const transport: Transport = { - send: jest.fn().mockImplementation(() => Promise.resolve()), - flush: jest.fn().mockImplementation(() => Promise.resolve()), - }; - // eslint-disable-next-line deprecation/deprecation - const integration = new ProfilingIntegration(); - - const getCurrentHub = jest.fn((): Hub => { + it('does not call transporter if null profile is received', () => { + const transport: Transport = { + send: jest.fn().mockImplementation(() => Promise.resolve()), + flush: jest.fn().mockImplementation(() => Promise.resolve()), + }; + const integration = _nodeProfilingIntegration(); + const emitter = new EventEmitter(); + + const client = { + on: emitter.on.bind(emitter), + emit: emitter.emit.bind(emitter), + getOptions: () => { return { - getClient: () => { - return { - getOptions: () => { - return { - _metadata: {}, - }; - }, - getDsn: () => { - return {}; - }, - getTransport: () => transport, - }; - }, - } as Hub; - }); - const addGlobalEventProcessor = () => void 0; - integration.setupOnce(addGlobalEventProcessor, getCurrentHub); - - integration.handleGlobalEvent({ - type: 'transaction', - sdkProcessingMetadata: { - profile: null, - }, - }); - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(transport.send).not.toHaveBeenCalled(); - }); - - it('when Hub.getClient returns undefined', async () => { - const logSpy = jest.spyOn(logger, 'log'); - // eslint-disable-next-line deprecation/deprecation - const integration = new ProfilingIntegration(); - - const getCurrentHub = jest.fn((): Hub => { - return { getClient: () => undefined } as Hub; - }); - const addGlobalEventProcessor = () => void 0; - integration.setupOnce(addGlobalEventProcessor, getCurrentHub); - - assertCleanProfile(await integration.handleGlobalEvent(makeProfiledEvent())); - expect(logSpy).toHaveBeenCalledWith( - '[Profiling] getClient did not return a Client, removing profile from event and forwarding to next event processors.', - ); - }); - it('when getDsn returns undefined', async () => { - const logSpy = jest.spyOn(logger, 'log'); - // eslint-disable-next-line deprecation/deprecation - const integration = new ProfilingIntegration(); - - const getCurrentHub = jest.fn((): Hub => { - return { - getClient: () => { - return { - getDsn: () => undefined, - }; - }, - } as Hub; - }); - const addGlobalEventProcessor = () => void 0; - integration.setupOnce(addGlobalEventProcessor, getCurrentHub); - - assertCleanProfile(await integration.handleGlobalEvent(makeProfiledEvent())); - expect(logSpy).toHaveBeenCalledWith( - '[Profiling] getDsn did not return a Dsn, removing profile from event and forwarding to next event processors.', - ); - }); - it('when getTransport returns undefined', async () => { - const logSpy = jest.spyOn(logger, 'log'); - // eslint-disable-next-line deprecation/deprecation - const integration = new ProfilingIntegration(); - - const getCurrentHub = jest.fn((): Hub => { - return { - getClient: () => { - return { - getDsn: () => { - return {}; - }, - getTransport: () => undefined, - }; - }, - } as Hub; - }); - const addGlobalEventProcessor = () => void 0; - integration.setupOnce(addGlobalEventProcessor, getCurrentHub); - - assertCleanProfile(await integration.handleGlobalEvent(makeProfiledEvent())); - expect(logSpy).toHaveBeenCalledWith( - '[Profiling] getTransport did not return a Transport, removing profile from event and forwarding to next event processors.', - ); - }); - - it('sends profile to sentry', async () => { - const logSpy = jest.spyOn(logger, 'log'); - const transport: Transport = { - send: jest.fn().mockImplementation(() => Promise.resolve()), - flush: jest.fn().mockImplementation(() => Promise.resolve()), - }; - // eslint-disable-next-line deprecation/deprecation - const integration = new ProfilingIntegration(); + _metadata: {}, + }; + }, + getDsn: () => { + return {}; + }, + getTransport: () => transport, + } as unknown as NodeClient; - const getCurrentHub = jest.fn((): Hub => { - return { - getClient: () => { - return { - getOptions: () => { - return { - _metadata: {}, - }; - }, - getDsn: () => { - return {}; - }, - getTransport: () => transport, - }; - }, - } as Hub; - }); - const addGlobalEventProcessor = () => void 0; - integration.setupOnce(addGlobalEventProcessor, getCurrentHub); + integration.setup(client); - assertCleanProfile(await integration.handleGlobalEvent(makeProfiledEvent())); - expect(logSpy.mock.calls?.[1]?.[0]).toBe('[Profiling] Preparing envelope and sending a profiling event'); - }); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(transport.send).not.toHaveBeenCalled(); }); - describe('with SDK hooks', () => { - it('does not call transporter if null profile is received', () => { - const transport: Transport = { - send: jest.fn().mockImplementation(() => Promise.resolve()), - flush: jest.fn().mockImplementation(() => Promise.resolve()), - }; - // eslint-disable-next-line deprecation/deprecation - const integration = new ProfilingIntegration(); - const emitter = new EventEmitter(); - - const getCurrentHub = jest.fn((): Hub => { + it('binds to spanStart, spanEnd and beforeEnvelope', () => { + const transport: Transport = { + send: jest.fn().mockImplementation(() => Promise.resolve()), + flush: jest.fn().mockImplementation(() => Promise.resolve()), + }; + const integration = _nodeProfilingIntegration(); + + const client = { + on: jest.fn(), + emit: jest.fn(), + getOptions: () => { return { - getClient: () => { - return { - on: emitter.on.bind(emitter), - emit: emitter.emit.bind(emitter), - getOptions: () => { - return { - _metadata: {}, - }; - }, - getDsn: () => { - return {}; - }, - getTransport: () => transport, - } as any; - }, - } as Hub; - }); - - const addGlobalEventProcessor = () => void 0; - integration.setupOnce(addGlobalEventProcessor, getCurrentHub); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(transport.send).not.toHaveBeenCalled(); - }); - - it('binds to startTransaction, finishTransaction and beforeEnvelope', () => { - const transport: Transport = { - send: jest.fn().mockImplementation(() => Promise.resolve()), - flush: jest.fn().mockImplementation(() => Promise.resolve()), - }; - // eslint-disable-next-line deprecation/deprecation - const integration = new ProfilingIntegration(); - const emitter = new EventEmitter(); - - const getCurrentHub = jest.fn((): Hub => { - return { - getClient: () => { - return { - on: emitter.on.bind(emitter), - emit: emitter.emit.bind(emitter), - getOptions: () => { - return { - _metadata: {}, - }; - }, - getDsn: () => { - return {}; - }, - getTransport: () => transport, - } as any; - }, - } as Hub; - }); - - const spy = jest.spyOn(emitter, 'on'); + _metadata: {}, + }; + }, + getDsn: () => { + return {}; + }, + getTransport: () => transport, + } as unknown as NodeClient; - const addGlobalEventProcessor = jest.fn(); - integration.setupOnce(addGlobalEventProcessor, getCurrentHub); + const spy = jest.spyOn(client, 'on'); - expect(spy).toHaveBeenCalledTimes(3); - expect(spy.mock?.calls?.[0]?.[0]).toBe('startTransaction'); - expect(spy.mock?.calls?.[1]?.[0]).toBe('finishTransaction'); - expect(spy.mock?.calls?.[2]?.[0]).toBe('beforeEnvelope'); + integration.setup(client); - expect(addGlobalEventProcessor).not.toHaveBeenCalled(); - }); + expect(spy).toHaveBeenCalledTimes(3); + expect(spy).toHaveBeenCalledWith('spanStart', expect.any(Function)); + expect(spy).toHaveBeenCalledWith('spanEnd', expect.any(Function)); + expect(spy).toHaveBeenCalledWith('beforeEnvelope', expect.any(Function)); }); }); diff --git a/packages/profiling-node/test/hubextensions.hub.test.ts b/packages/profiling-node/test/spanProfileUtils.test.ts similarity index 56% rename from packages/profiling-node/test/hubextensions.hub.test.ts rename to packages/profiling-node/test/spanProfileUtils.test.ts index 2266e5cec610..a6a493fc7bbc 100644 --- a/packages/profiling-node/test/hubextensions.hub.test.ts +++ b/packages/profiling-node/test/spanProfileUtils.test.ts @@ -4,46 +4,10 @@ import { getMainCarrier } from '@sentry/core'; import type { Transport } from '@sentry/types'; import { GLOBAL_OBJ, createEnvelope, logger } from '@sentry/utils'; import { CpuProfilerBindings } from '../src/cpu_profiler'; -import { ProfilingIntegration } from '../src/index'; - -function makeClientWithoutHooks(): [Sentry.NodeClient, Transport] { - // eslint-disable-next-line deprecation/deprecation - const integration = new ProfilingIntegration(); - const transport = Sentry.makeNodeTransport({ - url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', - recordDroppedEvent: () => { - return undefined; - }, - }); - const client = new Sentry.NodeClient({ - stackParser: Sentry.defaultStackParser, - tracesSampleRate: 1, - profilesSampleRate: 1, - debug: true, - environment: 'test-environment', - dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', - integrations: [integration], - transport: _opts => transport, - }); - // eslint-disable-next-line deprecation/deprecation - client.setupIntegrations = () => { - integration.setupOnce( - cb => { - // @ts-expect-error __SENTRY__ is a private property - getMainCarrier().__SENTRY__.globalEventProcessors = [cb]; - }, - // eslint-disable-next-line deprecation/deprecation - () => Sentry.getCurrentHub(), - ); - }; - // @ts-expect-error override private - client.on = undefined; - return [client, transport]; -} +import { _nodeProfilingIntegration } from '../src/integration'; function makeClientWithHooks(): [Sentry.NodeClient, Transport] { - // eslint-disable-next-line deprecation/deprecation - const integration = new ProfilingIntegration(); + const integration = _nodeProfilingIntegration(); const client = new Sentry.NodeClient({ stackParser: Sentry.defaultStackParser, tracesSampleRate: 1, @@ -61,24 +25,12 @@ function makeClientWithHooks(): [Sentry.NodeClient, Transport] { }), }); - // eslint-disable-next-line deprecation/deprecation - client.setupIntegrations = () => { - integration.setupOnce( - cb => { - // @ts-expect-error __SENTRY__ is a private property - getMainCarrier().__SENTRY__.globalEventProcessors = [cb]; - }, - // eslint-disable-next-line deprecation/deprecation - () => Sentry.getCurrentHub(), - ); - }; - return [client, client.getTransport() as Transport]; } const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); -describe('hubextensions', () => { +describe('spanProfileUtils', () => { beforeEach(() => { jest.useRealTimers(); // We will mock the carrier as if it has been initialized by the SDK, else everything is short circuited @@ -92,11 +44,9 @@ describe('hubextensions', () => { }); it('pulls environment from sdk init', async () => { - const [client, transport] = makeClientWithoutHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); + const [client, transport] = makeClientWithHooks(); + Sentry.setCurrentClient(client); + client.init(); const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); @@ -112,11 +62,9 @@ describe('hubextensions', () => { it('logger warns user if there are insufficient samples and discards the profile', async () => { const logSpy = jest.spyOn(logger, 'log'); - const [client, transport] = makeClientWithoutHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); + const [client, transport] = makeClientWithHooks(); + Sentry.setCurrentClient(client); + client.init(); jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { return { @@ -143,9 +91,7 @@ describe('hubextensions', () => { await Sentry.flush(1000); - expect(logSpy.mock?.calls[logSpy.mock.calls.length - 1]?.[0]).toBe( - '[Profiling] Discarding profile because it contains less than 2 samples', - ); + expect(logSpy).toHaveBeenCalledWith('[Profiling] Discarding profile because it contains less than 2 samples'); expect((transport.send as any).mock.calls[0][0][1][0][0].type).toBe('transaction'); // eslint-disable-next-line @typescript-eslint/unbound-method @@ -155,11 +101,9 @@ describe('hubextensions', () => { it('logger warns user if traceId is invalid', async () => { const logSpy = jest.spyOn(logger, 'log'); - const [client, transport] = makeClientWithoutHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); + const [client, transport] = makeClientWithHooks(); + Sentry.setCurrentClient(client); + client.init(); jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { return { @@ -191,16 +135,15 @@ describe('hubextensions', () => { transaction.end(); await Sentry.flush(1000); - expect(logSpy.mock?.calls?.[6]?.[0]).toBe('[Profiling] Invalid traceId: ' + 'boop' + ' on profiled event'); + + expect(logSpy).toHaveBeenCalledWith('[Profiling] Invalid traceId: ' + 'boop' + ' on profiled event'); }); describe('with hooks', () => { it('calls profiler when transaction is started/stopped', async () => { const [client, transport] = makeClientWithHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); + Sentry.setCurrentClient(client); + client.init(); const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); @@ -208,7 +151,7 @@ describe('hubextensions', () => { jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); // eslint-disable-next-line deprecation/deprecation - const transaction = hub.startTransaction({ name: 'profile_hub' }); + const transaction = Sentry.getCurrentHub().startTransaction({ name: 'profile_hub' }); await wait(500); transaction.end(); @@ -220,15 +163,13 @@ describe('hubextensions', () => { it('sends profile in the same envelope as transaction', async () => { const [client, transport] = makeClientWithHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); + Sentry.setCurrentClient(client); + client.init(); const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); // eslint-disable-next-line deprecation/deprecation - const transaction = hub.startTransaction({ name: 'profile_hub' }); + const transaction = Sentry.getCurrentHub().startTransaction({ name: 'profile_hub' }); await wait(500); transaction.end(); @@ -241,10 +182,8 @@ describe('hubextensions', () => { it('does not crash if transaction has no profile context or it is invalid', async () => { const [client] = makeClientWithHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); + Sentry.setCurrentClient(client); + client.init(); // @ts-expect-error transaction is partial client.emit('beforeEnvelope', createEnvelope({ type: 'transaction' }, { type: 'transaction' })); @@ -267,10 +206,8 @@ describe('hubextensions', () => { it('if transaction was profiled, but profiler returned null', async () => { const [client, transport] = makeClientWithHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); + Sentry.setCurrentClient(client); + client.init(); jest.spyOn(CpuProfilerBindings, 'stopProfiling').mockReturnValue(null); // Emit is sync, so we can just assert that we got here @@ -280,7 +217,7 @@ describe('hubextensions', () => { }); // eslint-disable-next-line deprecation/deprecation - const transaction = hub.startTransaction({ name: 'profile_hub' }); + const transaction = Sentry.getCurrentHub().startTransaction({ name: 'profile_hub' }); await wait(500); transaction.end(); @@ -293,16 +230,15 @@ describe('hubextensions', () => { it('emits preprocessEvent for profile', async () => { const [client] = makeClientWithHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); + Sentry.setCurrentClient(client); + client.init(); + const onPreprocessEvent = jest.fn(); client.on('preprocessEvent', onPreprocessEvent); // eslint-disable-next-line deprecation/deprecation - const transaction = hub.startTransaction({ name: 'profile_hub' }); + const transaction = Sentry.getCurrentHub().startTransaction({ name: 'profile_hub' }); await wait(500); transaction.end(); @@ -317,87 +253,12 @@ describe('hubextensions', () => { }); }); - describe('without hooks', () => { - it('calls profiler when transaction is started/stopped', async () => { - const [client] = makeClientWithoutHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); - - const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); - const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); - - // eslint-disable-next-line deprecation/deprecation - const transaction = hub.startTransaction({ name: 'profile_hub' }); - await wait(500); - transaction.end(); - - await Sentry.flush(1000); - - expect(startProfilingSpy).toHaveBeenCalledTimes(1); - expect((stopProfilingSpy.mock.calls[startProfilingSpy.mock.calls.length - 1]?.[0] as string).length).toBe(32); - }); - - it('sends profile in separate envelope', async () => { - const [client, transport] = makeClientWithoutHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); - - const transportSpy = jest.spyOn(transport, 'send').mockImplementation(() => { - // Do nothing so we don't send events to Sentry - return Promise.resolve({}); - }); - - // eslint-disable-next-line deprecation/deprecation - const transaction = hub.startTransaction({ name: 'profile_hub' }); - await wait(500); - transaction.end(); - - await Sentry.flush(1000); - - // One for profile, the other for transaction - expect(transportSpy).toHaveBeenCalledTimes(2); - expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]).toMatchObject({ type: 'profile' }); - }); - - it('respect max profile duration timeout', async () => { - // it seems that in node 19 globals (or least part of them) are a readonly object - // so when useFakeTimers is called it throws an error because it cannot override - // a readonly property of performance on global object. Use legacyFakeTimers for now - jest.useFakeTimers('legacy'); - const startProfilingSpy = jest.spyOn(CpuProfilerBindings, 'startProfiling'); - const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); - - const [client] = makeClientWithoutHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); - - // eslint-disable-next-line deprecation/deprecation - const transaction = Sentry.getCurrentHub().startTransaction({ name: 'timeout_transaction' }); - expect(startProfilingSpy).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(30001); - - expect(stopProfilingSpy).toHaveBeenCalledTimes(1); - expect((stopProfilingSpy.mock.calls[startProfilingSpy.mock.calls.length - 1]?.[0] as string).length).toBe(32); - - transaction.end(); - expect(stopProfilingSpy).toHaveBeenCalledTimes(1); - }); - }); - it('does not crash if stop is called multiple times', async () => { const stopProfilingSpy = jest.spyOn(CpuProfilerBindings, 'stopProfiling'); - const [client] = makeClientWithoutHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); + const [client] = makeClientWithHooks(); + Sentry.setCurrentClient(client); + client.init(); // eslint-disable-next-line deprecation/deprecation const transaction = Sentry.getCurrentHub().startTransaction({ name: 'txn' }); @@ -436,15 +297,13 @@ describe('hubextensions', () => { }); const [client, transport] = makeClientWithHooks(); - // eslint-disable-next-line deprecation/deprecation - const hub = Sentry.getCurrentHub(); - // eslint-disable-next-line deprecation/deprecation - hub.bindClient(client); + Sentry.setCurrentClient(client); + client.init(); const transportSpy = jest.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); // eslint-disable-next-line deprecation/deprecation - const transaction = hub.startTransaction({ name: 'profile_hub' }); + const transaction = Sentry.getCurrentHub().startTransaction({ name: 'profile_hub' }); await wait(500); transaction.end(); diff --git a/packages/profiling-node/test/utils.test.ts b/packages/profiling-node/test/utils.test.ts index 640d0eace7f2..fe661e41f07f 100644 --- a/packages/profiling-node/test/utils.test.ts +++ b/packages/profiling-node/test/utils.test.ts @@ -1,4 +1,4 @@ -import type { DsnComponents, Event, SdkMetadata } from '@sentry/types'; +import type { Event } from '@sentry/types'; import { addItemToEnvelope, createEnvelope, uuid4 } from '@sentry/utils'; import { @@ -8,36 +8,7 @@ import { isValidSampleRate, } from '../src/utils'; -import type { Profile, ProfiledEvent } from '../src/types'; -import { - createProfilingEventEnvelope, - isProfiledTransactionEvent, - maybeRemoveProfileFromSdkMetadata, -} from '../src/utils'; - -function makeSdkMetadata(props: Partial): SdkMetadata { - return { - sdk: { - ...props, - }, - }; -} - -function makeDsn(props: Partial): DsnComponents { - return { - protocol: 'http', - projectId: '1', - host: 'localhost', - ...props, - }; -} - -function makeEvent( - props: Partial, - profile: NonNullable, -): ProfiledEvent { - return { ...props, sdkProcessingMetadata: { profile: profile } }; -} +import type { ProfiledEvent } from '../src/types'; function makeProfile( props: Partial, @@ -57,213 +28,6 @@ function makeProfile( }; } -describe('isProfiledTransactionEvent', () => { - it('profiled event', () => { - expect(isProfiledTransactionEvent({ sdkProcessingMetadata: { profile: {} } })).toBe(true); - }); - it('not profiled event', () => { - expect(isProfiledTransactionEvent({ sdkProcessingMetadata: { something: {} } })).toBe(false); - }); -}); - -describe('maybeRemoveProfileFromSdkMetadata', () => { - it('removes profile', () => { - expect(maybeRemoveProfileFromSdkMetadata({ sdkProcessingMetadata: { profile: {} } })).toEqual({ - sdkProcessingMetadata: {}, - }); - }); - - it('does nothing', () => { - expect(maybeRemoveProfileFromSdkMetadata({ sdkProcessingMetadata: { something: {} } })).toEqual({ - sdkProcessingMetadata: { something: {} }, - }); - }); -}); - -describe('createProfilingEventEnvelope', () => { - it('throws if profile_id is not set', () => { - const profile = makeProfile({}); - delete profile.profile_id; - - expect(() => - createProfilingEventEnvelope(makeEvent({ type: 'transaction' }, profile), makeDsn({}), makeSdkMetadata({})), - ).toThrow('Cannot construct profiling event envelope without a valid profile id. Got undefined instead.'); - }); - it('throws if profile is undefined', () => { - expect(() => - // @ts-expect-error mock profile as undefined - createProfilingEventEnvelope(makeEvent({ type: 'transaction' }, undefined), makeDsn({}), makeSdkMetadata({})), - ).toThrow('Cannot construct profiling event envelope without a valid profile. Got undefined instead.'); - expect(() => - // @ts-expect-error mock profile as null - createProfilingEventEnvelope(makeEvent({ type: 'transaction' }, null), makeDsn({}), makeSdkMetadata({})), - ).toThrow('Cannot construct profiling event envelope without a valid profile. Got null instead.'); - }); - - it('envelope header is of type: profile', () => { - const envelope = createProfilingEventEnvelope( - makeEvent( - { type: 'transaction' }, - makeProfile({ - samples: [ - { elapsed_since_start_ns: '0', thread_id: '0', stack_id: 0 }, - { elapsed_since_start_ns: '0', thread_id: '0', stack_id: 0 }, - ], - }), - ), - makeDsn({}), - makeSdkMetadata({ - name: 'sentry.javascript.node', - version: '1.2.3', - integrations: ['integration1', 'integration2'], - packages: [ - { name: 'package1', version: '1.2.3' }, - { name: 'package2', version: '4.5.6' }, - ], - }), - ); - expect(envelope?.[1][0]?.[0].type).toBe('profile'); - }); - - it('returns if samples.length <= 1', () => { - const envelope = createProfilingEventEnvelope( - makeEvent( - { type: 'transaction' }, - makeProfile({ - samples: [{ elapsed_since_start_ns: '0', thread_id: '0', stack_id: 0 }], - }), - ), - makeDsn({}), - makeSdkMetadata({ - name: 'sentry.javascript.node', - version: '1.2.3', - integrations: ['integration1', 'integration2'], - packages: [ - { name: 'package1', version: '1.2.3' }, - { name: 'package2', version: '4.5.6' }, - ], - }), - ); - expect(envelope).toBe(null); - }); - - it('enriches envelope with sdk metadata', () => { - const envelope = createProfilingEventEnvelope( - makeEvent({ type: 'transaction' }, makeProfile({})), - makeDsn({}), - makeSdkMetadata({ - name: 'sentry.javascript.node', - version: '1.2.3', - }), - ); - - expect(envelope && envelope[0]?.sdk?.name).toBe('sentry.javascript.node'); - expect(envelope && envelope[0]?.sdk?.version).toBe('1.2.3'); - }); - - it('handles undefined sdk metadata', () => { - const envelope = createProfilingEventEnvelope( - makeEvent({ type: 'transaction' }, makeProfile({})), - makeDsn({}), - undefined, - ); - - expect(envelope?.[0].sdk).toBe(undefined); - }); - - it('enriches envelope with dsn metadata', () => { - const envelope = createProfilingEventEnvelope( - makeEvent({ type: 'transaction' }, makeProfile({})), - makeDsn({ - host: 'sentry.io', - projectId: '123', - protocol: 'https', - path: 'path', - port: '9000', - publicKey: 'publicKey', - }), - makeSdkMetadata({}), - 'tunnel', - ); - - expect(envelope?.[0].dsn).toBe('https://publicKey@sentry.io:9000/path/123'); - }); - - it('enriches profile with device info', () => { - const envelope = createProfilingEventEnvelope( - makeEvent({ type: 'transaction' }, makeProfile({})), - makeDsn({}), - makeSdkMetadata({}), - ); - const profile = envelope?.[1][0]?.[1] as unknown as Profile; - - expect(typeof profile.device.manufacturer).toBe('string'); - expect(typeof profile.device.model).toBe('string'); - expect(typeof profile.os.name).toBe('string'); - expect(typeof profile.os.version).toBe('string'); - - expect(profile.device.manufacturer.length).toBeGreaterThan(0); - expect(profile.device.model.length).toBeGreaterThan(0); - expect(profile.os.name.length).toBeGreaterThan(0); - expect(profile.os.version.length).toBeGreaterThan(0); - }); - - it('throws if event.type is not a transaction', () => { - expect(() => - createProfilingEventEnvelope( - makeEvent( - // @ts-expect-error force invalid value - { type: 'error' }, - // @ts-expect-error mock tid as undefined - makeProfile({ samples: [{ stack_id: 0, thread_id: undefined, elapsed_since_start_ns: '0' }] }), - ), - makeDsn({}), - makeSdkMetadata({}), - ), - ).toThrow('Profiling events may only be attached to transactions, this should never occur.'); - }); - - it('inherits transaction properties', () => { - const start = new Date(2022, 8, 1, 12, 0, 0); - const end = new Date(2022, 8, 1, 12, 0, 10); - - const envelope = createProfilingEventEnvelope( - makeEvent( - { - event_id: uuid4(), - type: 'transaction', - transaction: 'transaction-name', - start_timestamp: start.getTime() / 1000, - timestamp: end.getTime() / 1000, - contexts: { - trace: { - span_id: 'span_id', - trace_id: 'trace_id', - }, - }, - }, - makeProfile({ - samples: [ - // @ts-expect-error mock tid as undefined - { stack_id: 0, thread_id: undefined, elapsed_since_start_ns: '0' }, - // @ts-expect-error mock tid as undefined - { stack_id: 0, thread_id: undefined, elapsed_since_start_ns: '0' }, - ], - }), - ), - makeDsn({}), - makeSdkMetadata({}), - ); - - const profile = envelope?.[1][0]?.[1] as unknown as Profile; - - expect(profile.transaction.name).toBe('transaction-name'); - expect(typeof profile.transaction.id).toBe('string'); - expect(profile.transaction.id?.length).toBe(32); - expect(profile.transaction.trace_id).toBe('trace_id'); - }); -}); - describe('isValidSampleRate', () => { it.each([ [0, true],