diff --git a/packages/core/src/profiling.ts b/packages/core/src/profiling.ts index 1cd33bae8135..7330bf8b55f8 100644 --- a/packages/core/src/profiling.ts +++ b/packages/core/src/profiling.ts @@ -18,7 +18,7 @@ function isProfilingIntegrationWithProfiler( * Starts the Sentry continuous profiler. * This mode is exclusive with the transaction profiler and will only work if the profilesSampleRate is set to a falsy value. * In continuous profiling mode, the profiler will keep reporting profile chunks to Sentry until it is stopped, which allows for continuous profiling of the application. - * @deprecated Use `startProfilerSession()` instead. + * @deprecated Use `startProfileSession()` instead. */ function startProfiler(): void { const client = getClient(); @@ -71,16 +71,54 @@ function stopProfiler(): void { /** * Starts a new profiler session. */ -function startProfilerSession(): void {} +function startProfileSession(): void { + const client = getClient(); + if (!client) { + DEBUG_BUILD && logger.warn('No Sentry client available, profiling is not started'); + return; + } + + const integration = client.getIntegrationByName>('ProfilingIntegration'); + if (!integration) { + DEBUG_BUILD && logger.warn('ProfilingIntegration is not available'); + return; + } + + if (!isProfilingIntegrationWithProfiler(integration)) { + DEBUG_BUILD && logger.warn('Profiler is not available on profiling integration.'); + return; + } + + integration._profiler.startProfileSession(); +} /** * Stops the current profiler session. */ -function stopProfilerSession(): void {} +function stopProfileSession(): void { + const client = getClient(); + if (!client) { + DEBUG_BUILD && logger.warn('No Sentry client available, profiling is not started'); + return; + } + + const integration = client.getIntegrationByName>('ProfilingIntegration'); + if (!integration) { + DEBUG_BUILD && logger.warn('ProfilingIntegration is not available'); + return; + } + + if (!isProfilingIntegrationWithProfiler(integration)) { + DEBUG_BUILD && logger.warn('Profiler is not available on profiling integration.'); + return; + } + + integration._profiler.stopProfileSession(); +} export const profiler: Profiler = { startProfiler, stopProfiler, - startProfilerSession, - stopProfilerSession, + startProfileSession, + stopProfileSession, }; diff --git a/packages/core/src/types-hoist/profiling.ts b/packages/core/src/types-hoist/profiling.ts index 5f9c47d6f409..0df93e835a3c 100644 --- a/packages/core/src/types-hoist/profiling.ts +++ b/packages/core/src/types-hoist/profiling.ts @@ -7,6 +7,8 @@ export interface ContinuousProfiler { initialize(client: T): void; start(): void; stop(): void; + startProfileSession(): void; + stopProfileSession(): void; } export interface ProfilingIntegration extends Integration { @@ -16,7 +18,7 @@ export interface ProfilingIntegration extends Integration { export interface Profiler { /** * Starts the profiler. - * @deprecated Use `startProfilerSession()` instead. + * @deprecated Use `startProfileSession()` instead. */ startProfiler(): void; @@ -29,12 +31,12 @@ export interface Profiler { /** * Starts a new profiler session. */ - startProfilerSession(): void; + startProfileSession(): void; /** * Stops the current profiler session. */ - stopProfilerSession(): void; + stopProfileSession(): void; } export type ThreadId = string; diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 7bc51611095f..9afa098561be 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -41,120 +41,6 @@ function takeFromProfileQueue(profile_id: string): RawThreadCpuProfile | undefin return profile; } -/** - * Instruments the client to automatically invoke the profiler on span start and stop events. - * @param client - */ -function setupAutomatedSpanProfiling(client: NodeClient): void { - const spanToProfileIdMap = new WeakMap(); - - client.on('spanStart', span => { - if (span !== getRootSpan(span)) { - return; - } - - const profile_id = maybeProfileSpan(client, span); - - if (profile_id) { - 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?.maxProfileDurationMs || MAX_PROFILE_DURATION_MS; - - 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]; - } - - // Enqueue a timeout to prevent profiles from running over max duration. - const timeout = global.setTimeout(() => { - DEBUG_BUILD && - logger.log('[Profiling] max profile duration elapsed, stopping profiling for:', spanToJSON(span).description); - - const profile = stopSpanProfile(span, profile_id); - if (profile) { - addToProfileQueue(profile_id, profile); - } - }, maxProfileDurationMs); - - // Unref timeout so it doesn't keep the process alive. - timeout.unref(); - - getIsolationScope().setContext('profile', { profile_id }); - spanToProfileIdMap.set(span, profile_id); - } - }); - - 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 = stopSpanProfile(span, profile_id); - - if (profile) { - addToProfileQueue(profile_id, profile); - } - } - }); - - 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[] = []; - - for (const profiledTransaction of profiledTransactionEvents) { - const profileContext = profiledTransaction.contexts?.profile; - const profile_id = profileContext?.profile_id; - - if (!profile_id) { - throw new TypeError('[Profiling] cannot find profile for a transaction without a profile context'); - } - - // Remove the profile from the transaction context before sending, relay will take care of the rest. - if (profileContext) { - delete profiledTransaction.contexts?.profile; - } - - const cpuProfile = takeFromProfileQueue(profile_id); - if (!cpuProfile) { - DEBUG_BUILD && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); - continue; - } - - const profile = createProfilingEvent(client, cpuProfile, profiledTransaction); - if (!profile) return; - - profilesToAddToEnvelope.push(profile); - - // @ts-expect-error profile does not inherit from Event - client.emit('preprocessEvent', profile, { - event_id: profiledTransaction.event_id, - }); - - // @ts-expect-error profile does not inherit from Event - client.emit('postprocessEvent', profile, { - event_id: profiledTransaction.event_id, - }); - } - - addProfilesToEnvelope(envelope, profilesToAddToEnvelope); - }); -} - interface ChunkData { id: string; timer: NodeJS.Timeout | undefined; @@ -165,7 +51,11 @@ class ContinuousProfiler { private _profilerId: string | undefined; private _client: NodeClient | undefined = undefined; private _chunkData: ChunkData | undefined = undefined; - + private _mode: 'legacy' | 'current' | undefined = undefined; + private _legacyProfilerMode: 'span' | 'continuous' | undefined = undefined; + private _profileLifecycle: 'manual' | 'trace' | undefined = undefined; + private _sampled: boolean | undefined = undefined; + private _sessionSamplingRate: number | undefined = undefined; /** * Called when the profiler is attached to the client (continuous mode is enabled). If of the profiler * methods called before the profiler is initialized will result in a noop action with debug logs. @@ -173,6 +63,61 @@ class ContinuousProfiler { */ public initialize(client: NodeClient): void { this._client = client; + const options = client.getOptions(); + + this._mode = getProfilingMode(options); + this._sessionSamplingRate = Math.random(); + this._sampled = this._sessionSamplingRate < (options.profileSessionSampleRate ?? 0); + this._profileLifecycle = options.profileLifecycle ?? 'manual'; + + switch (this._mode) { + case 'legacy': { + this._legacyProfilerMode = + 'profilesSampleRate' in options || 'profilesSampler' in options ? 'span' : 'continuous'; + + switch (this._legacyProfilerMode) { + case 'span': { + this._setupAutomaticSpanProfiling(); + break; + } + case 'continuous': { + // Continous mode requires manual calls to profiler.start() and profiler.stop() + break; + } + default: { + DEBUG_BUILD && + logger.warn( + `[Profiling] Unknown profiler mode: ${this._legacyProfilerMode}, profiler was not initialized`, + ); + break; + } + } + break; + } + + case 'current': { + switch (this._profileLifecycle) { + case 'trace': { + this._startTraceLifecycleProfiling(); + break; + } + case 'manual': { + // Manual mode requires manual calls to profiler.startProfileSession() and profiler.stopProfileSession() + break; + } + default: { + DEBUG_BUILD && + logger.warn(`[Profiling] Unknown profiler mode: ${this._profileLifecycle}, profiler was not initialized`); + break; + } + } + break; + } + default: { + DEBUG_BUILD && logger.warn(`[Profiling] Unknown profiler mode: ${this._mode}, profiler was not initialized`); + break; + } + } // Attaches a listener to beforeSend which will add the threadId data to the event being sent. // This adds a constant overhead to all events being sent which could be improved to only attach @@ -190,12 +135,22 @@ class ContinuousProfiler { return; } + if (this._mode !== 'legacy') { + DEBUG_BUILD && logger.log('[Profiling] Continuous profiling is not supported in the current mode.'); + return; + } + + if (this._legacyProfilerMode === 'span') { + DEBUG_BUILD && logger.log('[Profiling] Calls to profiler.start() are not supported in span profiling mode.'); + return; + } + // Flush any existing chunks before starting a new one. - this._chunkStop(); + this._stopChunkProfiling(); // Restart the profiler session this._setupSpanChunkInstrumentation(); - this._chunkStart(); + this._restartChunkProfiling(); } /** @@ -207,14 +162,229 @@ class ContinuousProfiler { DEBUG_BUILD && logger.log('[Profiling] Failed to stop, sentry client was never attached to the profiler.'); return; } - this._chunkStop(); + + if (this._mode !== 'legacy') { + DEBUG_BUILD && logger.log('[Profiling] Continuous profiling is not supported in the current mode.'); + return; + } + + if (this._legacyProfilerMode === 'span') { + DEBUG_BUILD && logger.log('[Profiling] Calls to profiler.stop() are not supported in span profiling mode.'); + return; + } + + this._stopChunkProfiling(); this._teardownSpanChunkInstrumentation(); } + public startProfileSession(): void { + if (this._mode !== 'current') { + DEBUG_BUILD && logger.log('[Profiling] Continuous profiling is not supported in the current mode.'); + return; + } + + if (this._chunkData !== undefined) { + DEBUG_BUILD && logger.log('[Profiling] Profile session already running, no-op.'); + return; + } + + if (this._mode === 'current') { + if (!this._sampled) { + DEBUG_BUILD && logger.log('[Profiling] Profile session not sampled, no-op.'); + return; + } + } + + if (this._profileLifecycle === 'trace') { + DEBUG_BUILD && + logger.log( + '[Profiling] You are using the trace profile lifecycle, manual calls to profiler.startProfileSession() and profiler.stopProfileSession() will be ignored.', + ); + return; + } + + this._startChunkProfiling(); + } + + public stopProfileSession(): void { + if (this._mode !== 'current') { + DEBUG_BUILD && logger.log('[Profiling] Continuous profiling is not supported in the current mode.'); + return; + } + + if (this._profileLifecycle === 'trace') { + DEBUG_BUILD && + logger.log( + '[Profiling] You are using the trace profile lifecycle, manual calls to profiler.startProfileSession() and profiler.stopProfileSession() will be ignored.', + ); + return; + } + + if (!this._chunkData) { + DEBUG_BUILD && logger.log('[Profiling] No profile session running, no-op.'); + return; + } + + this._stopChunkProfiling(); + } + + /** + * Starts trace lifecycle profiling. Profiling will remain active as long as there is an active span. + */ + private _startTraceLifecycleProfiling(): void { + if (!this._client) { + DEBUG_BUILD && + logger.log( + '[Profiling] Failed to start trace lifecycle profiling, sentry client was never attached to the profiler.', + ); + return; + } + + let activeSpanCounter = 0; + this._client.on('spanStart', _span => { + if (activeSpanCounter === 0) { + this._startChunkProfiling(); + } + activeSpanCounter++; + }); + + this._client.on('spanEnd', _span => { + if (activeSpanCounter === 1) { + this._stopChunkProfiling(); + } + activeSpanCounter--; + }); + } + + private _setupAutomaticSpanProfiling(): void { + if (!this._client) { + DEBUG_BUILD && + logger.log( + '[Profiling] Failed to setup automatic span profiling, sentry client was never attached to the profiler.', + ); + return; + } + + const spanToProfileIdMap = new WeakMap(); + + this._client.on('spanStart', span => { + if (span !== getRootSpan(span)) { + return; + } + + const profile_id = maybeProfileSpan(this._client, span); + + if (profile_id) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const options = this._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?.maxProfileDurationMs || MAX_PROFILE_DURATION_MS; + + 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]; + } + + // Enqueue a timeout to prevent profiles from running over max duration. + const timeout = global.setTimeout(() => { + DEBUG_BUILD && + logger.log( + '[Profiling] max profile duration elapsed, stopping profiling for:', + spanToJSON(span).description, + ); + + const profile = stopSpanProfile(span, profile_id); + if (profile) { + addToProfileQueue(profile_id, profile); + } + }, maxProfileDurationMs); + + // Unref timeout so it doesn't keep the process alive. + timeout.unref(); + + getIsolationScope().setContext('profile', { profile_id }); + spanToProfileIdMap.set(span, profile_id); + } + }); + + this._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 = stopSpanProfile(span, profile_id); + + if (profile) { + addToProfileQueue(profile_id, profile); + } + } + }); + + this._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[] = []; + + for (const profiledTransaction of profiledTransactionEvents) { + const profileContext = profiledTransaction.contexts?.profile; + const profile_id = profileContext?.profile_id; + + if (!profile_id) { + throw new TypeError('[Profiling] cannot find profile for a transaction without a profile context'); + } + + // Remove the profile from the transaction context before sending, relay will take care of the rest. + if (profileContext) { + delete profiledTransaction.contexts?.profile; + } + + const cpuProfile = takeFromProfileQueue(profile_id); + if (!cpuProfile) { + DEBUG_BUILD && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`); + continue; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const profile = createProfilingEvent(this._client!, cpuProfile, profiledTransaction); + if (!profile) return; + + profilesToAddToEnvelope.push(profile); + + // @ts-expect-error profile does not inherit from Event + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this._client!.emit('preprocessEvent', profile, { + event_id: profiledTransaction.event_id, + }); + + // @ts-expect-error profile does not inherit from Event + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this._client!.emit('postprocessEvent', profile, { + event_id: profiledTransaction.event_id, + }); + } + + addProfilesToEnvelope(envelope, profilesToAddToEnvelope); + }); + } + /** * Stop profiler and initializes profiling of the next chunk */ - private _chunkStart(): void { + private _restartChunkProfiling(): void { if (!this._client) { // The client is not attached to the profiler if the user has not enabled continuous profiling. // In this case, calling start() and stop() is a noop action.The reason this exists is because @@ -222,12 +392,13 @@ class ContinuousProfiler { DEBUG_BUILD && logger.log('[Profiling] Profiler was never attached to the client.'); return; } + if (this._chunkData) { DEBUG_BUILD && logger.log( `[Profiling] Chunk with chunk_id ${this._chunkData.id} is still running, current chunk will be stopped a new chunk will be started.`, ); - this._chunkStop(); + this._stopChunkProfiling(); } this._startChunkProfiling(); @@ -236,32 +407,42 @@ class ContinuousProfiler { /** * Stops profiling of the current chunks and flushes the profile to Sentry */ - private _chunkStop(): void { + private _stopChunkProfiling(): void { + if (!this._chunkData) { + DEBUG_BUILD && logger.log('[Profiling] No chunk data found, no-op.'); + return; + } + if (this._chunkData?.timer) { global.clearTimeout(this._chunkData.timer); this._chunkData.timer = undefined; DEBUG_BUILD && logger.log(`[Profiling] Stopping profiling chunk: ${this._chunkData.id}`); } + if (!this._client) { DEBUG_BUILD && logger.log('[Profiling] Failed to collect profile, sentry client was never attached to the profiler.'); + this._resetChunkData(); return; } if (!this._chunkData?.id) { DEBUG_BUILD && logger.log(`[Profiling] Failed to collect profile for: ${this._chunkData?.id}, the chunk_id is missing.`); + this._resetChunkData(); return; } const profile = CpuProfilerBindings.stopProfiling(this._chunkData.id, ProfileFormat.CHUNK); if (!profile) { - DEBUG_BUILD && logger.log(`[Profiling] _chunkiledStartTraceID to collect profile for: ${this._chunkData.id}`); + DEBUG_BUILD && logger.log(`[Profiling] Failed to collect profile for: ${this._chunkData.id}`); + this._resetChunkData(); return; } if (!this._profilerId) { DEBUG_BUILD && logger.log('[Profiling] Profile chunk does not contain a valid profiler_id, this is a bug in the SDK'); + this._resetChunkData(); return; } if (profile) { @@ -327,6 +508,11 @@ class ContinuousProfiler { * @param chunk */ private _startChunkProfiling(): void { + if (this._chunkData) { + DEBUG_BUILD && logger.log('[Profiling] Chunk is already running, no-op.'); + return; + } + const traceId = getCurrentScope().getPropagationContext().traceId || getIsolationScope().getPropagationContext().traceId; const chunk = this._initializeChunk(traceId); @@ -336,9 +522,9 @@ class ContinuousProfiler { chunk.timer = global.setTimeout(() => { DEBUG_BUILD && logger.log(`[Profiling] Stopping profiling chunk: ${chunk.id}`); - this._chunkStop(); + this._stopChunkProfiling(); DEBUG_BUILD && logger.log('[Profiling] Starting new profiling chunk.'); - setImmediate(this._chunkStart.bind(this)); + setImmediate(this._restartChunkProfiling.bind(this)); }, CHUNK_INTERVAL_MS); // Unref timeout so it doesn't keep the process alive. @@ -444,37 +630,7 @@ export const _nodeProfilingIntegration = ((): ProfilingIntegration = _profiler: new ContinuousProfiler(), setup(client: NodeClient) { DEBUG_BUILD && logger.log('[Profiling] Profiling integration setup.'); - const options = client.getOptions(); - const profilingAPIVersion = getProfilingMode(options); - - if (profilingAPIVersion === 'legacy') { - const mode = 'profilesSampleRate' in options || 'profilesSampler' in options ? 'span' : 'continuous'; - - switch (mode) { - case 'continuous': { - DEBUG_BUILD && logger.log('[Profiling] Continuous profiler mode enabled.'); - this._profiler.initialize(client); - return; - } - // Default to span profiling when no mode profiler mode is set - case 'span': - case undefined: { - DEBUG_BUILD && logger.log('[Profiling] Span profiler mode enabled.'); - setupAutomatedSpanProfiling(client); - return; - } - default: { - DEBUG_BUILD && logger.warn(`[Profiling] Unknown profiler mode: ${mode}, profiler was not initialized`); - } - } - } else if (profilingAPIVersion === 'current') { - DEBUG_BUILD && logger.log('[Profiling] Continuous profiler mode enabled.'); - this._profiler.initialize(client); - return; - } - - DEBUG_BUILD && - logger.log(['[Profiling] Profiling integration is added, but not enabled due to lack of SDK.init options.']); + this._profiler.initialize(client); return; }, }; @@ -485,7 +641,8 @@ export const _nodeProfilingIntegration = ((): ProfilingIntegration = * @param options * @returns 'legacy' if the options are using the legacy profiling API, 'current' if the options are using the current profiling API */ -function getProfilingMode(options: NodeOptions): 'legacy' | 'current' | null { +function getProfilingMode(options: NodeOptions): 'legacy' | 'current' { + // Legacy mode takes precedence over current mode if ('profilesSampleRate' in options || 'profilesSampler' in options) { return 'legacy'; } diff --git a/packages/profiling-node/src/spanProfileUtils.ts b/packages/profiling-node/src/spanProfileUtils.ts index 9ff20816895c..4bd28f8e9531 100644 --- a/packages/profiling-node/src/spanProfileUtils.ts +++ b/packages/profiling-node/src/spanProfileUtils.ts @@ -1,3 +1,4 @@ +/* eslint-disable deprecation/deprecation */ import { CpuProfilerBindings, type RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; import type { CustomSamplingContext, Span } from '@sentry/core'; import { logger, spanIsSampled, spanToJSON, uuid4 } from '@sentry/core'; diff --git a/packages/profiling-node/test/integration.test.ts b/packages/profiling-node/test/integration.test.ts index d7df77ccda20..9f534083b27b 100644 --- a/packages/profiling-node/test/integration.test.ts +++ b/packages/profiling-node/test/integration.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable deprecation/deprecation */ import * as Sentry from '@sentry/node'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -9,7 +10,7 @@ import type { ProfileChunk, Transport } from '@sentry/core'; import type { NodeClientOptions } from '@sentry/node/build/types/types'; import { _nodeProfilingIntegration } from '../src/integration'; -function makeClientWithHooks(): [Sentry.NodeClient, Transport] { +function makeLegacySpanProfilingClient(): [Sentry.NodeClient, Transport] { const integration = _nodeProfilingIntegration(); const client = new Sentry.NodeClient({ stackParser: Sentry.defaultStackParser, @@ -31,7 +32,7 @@ function makeClientWithHooks(): [Sentry.NodeClient, Transport] { return [client, client.getTransport() as Transport]; } -function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] { +function makeLegacyContinuousProfilingClient(): [Sentry.NodeClient, Transport] { const integration = _nodeProfilingIntegration(); const client = new Sentry.NodeClient({ stackParser: Sentry.defaultStackParser, @@ -52,6 +53,28 @@ function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] { return [client, client.getTransport() as Transport]; } +function makeCurrentSpanProfilingClient(options: Partial = {}): [Sentry.NodeClient, Transport] { + const integration = _nodeProfilingIntegration(); + const client = new Sentry.NodeClient({ + stackParser: Sentry.defaultStackParser, + tracesSampleRate: 1, + debug: true, + environment: 'test-environment', + dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + integrations: [integration], + transport: _opts => + Sentry.makeNodeTransport({ + url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', + recordDroppedEvent: () => { + return undefined; + }, + }), + ...options, + }); + + return [client, client.getTransport() as Transport]; +} + function getProfilerId(): string { return ( Sentry.getClient()?.getIntegrationByName>('ProfilingIntegration') as any @@ -79,7 +102,7 @@ function makeClientOptions( const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); describe('ProfilingIntegration', () => { - describe('automated span instrumentation', () => { + describe('legacy automated span instrumentation', () => { beforeEach(() => { vi.useRealTimers(); // We will mock the carrier as if it has been initialized by the SDK, else everything is short circuited @@ -93,7 +116,7 @@ describe('ProfilingIntegration', () => { }); it('pulls environment from sdk init', async () => { - const [client, transport] = makeClientWithHooks(); + const [client, transport] = makeLegacySpanProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -110,7 +133,7 @@ describe('ProfilingIntegration', () => { it('logger warns user if there are insufficient samples and discards the profile', async () => { const logSpy = vi.spyOn(logger, 'log'); - const [client, transport] = makeClientWithHooks(); + const [client, transport] = makeLegacySpanProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -149,7 +172,7 @@ describe('ProfilingIntegration', () => { it('logger warns user if traceId is invalid', async () => { const logSpy = vi.spyOn(logger, 'log'); - const [client, transport] = makeClientWithHooks(); + const [client, transport] = makeLegacySpanProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -191,179 +214,99 @@ describe('ProfilingIntegration', () => { 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(); - Sentry.setCurrentClient(client); - client.init(); + it('calls profiler when transaction is started/stopped', async () => { + const [client, transport] = makeLegacySpanProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); - const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); - const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); - vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); - const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); - await wait(500); - transaction.end(); + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + await wait(500); + transaction.end(); - await Sentry.flush(1000); + await Sentry.flush(1000); - expect(startProfilingSpy).toHaveBeenCalledTimes(1); - expect((stopProfilingSpy.mock.calls[stopProfilingSpy.mock.calls.length - 1]?.[0] as string).length).toBe(32); - }); + expect(startProfilingSpy).toHaveBeenCalledTimes(1); + expect((stopProfilingSpy.mock.calls[stopProfilingSpy.mock.calls.length - 1]?.[0] as string).length).toBe(32); + }); - it('sends profile in the same envelope as transaction', async () => { - const [client, transport] = makeClientWithHooks(); - Sentry.setCurrentClient(client); - client.init(); + it('sends profile in the same envelope as transaction', async () => { + const [client, transport] = makeLegacySpanProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); - const transportSpy = vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + const transportSpy = vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); - const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); - await wait(500); - transaction.end(); + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + await wait(500); + transaction.end(); - await Sentry.flush(1000); + await Sentry.flush(1000); - // One for profile, the other for transaction - expect(transportSpy).toHaveBeenCalledTimes(1); - expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[1]?.[0]).toMatchObject({ type: 'profile' }); - }); + // One for profile, the other for transaction + expect(transportSpy).toHaveBeenCalledTimes(1); + expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[1]?.[0]).toMatchObject({ type: 'profile' }); + }); - it('does not crash if transaction has no profile context or it is invalid', async () => { - const [client] = makeClientWithHooks(); - Sentry.setCurrentClient(client); - client.init(); + it('does not crash if transaction has no profile context or it is invalid', async () => { + const [client] = makeLegacySpanProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + // @ts-expect-error transaction is partial + client.emit('beforeEnvelope', createEnvelope({ type: 'transaction' }, { type: 'transaction' })); + // @ts-expect-error transaction is partial + client.emit('beforeEnvelope', createEnvelope({ type: 'transaction' }, { type: 'transaction', contexts: {} })); + client.emit( + 'beforeEnvelope', // @ts-expect-error transaction is partial - client.emit('beforeEnvelope', createEnvelope({ type: 'transaction' }, { type: 'transaction' })); + createEnvelope({ type: 'transaction' }, { type: 'transaction', contexts: { profile: {} } }), + ); + client.emit( + 'beforeEnvelope', // @ts-expect-error transaction is partial - client.emit('beforeEnvelope', createEnvelope({ type: 'transaction' }, { type: 'transaction', contexts: {} })); - client.emit( - 'beforeEnvelope', - // @ts-expect-error transaction is partial - createEnvelope({ type: 'transaction' }, { type: 'transaction', contexts: { profile: {} } }), - ); - client.emit( - 'beforeEnvelope', - // @ts-expect-error transaction is partial - createEnvelope({ type: 'transaction' }, { type: 'transaction', contexts: { profile: { profile_id: null } } }), - ); - - // Emit is sync, so we can just assert that we got here - expect(true).toBe(true); - }); - - it('if transaction was profiled, but profiler returned null', async () => { - const [client, transport] = makeClientWithHooks(); - Sentry.setCurrentClient(client); - client.init(); - - vi.spyOn(CpuProfilerBindings, 'stopProfiling').mockReturnValue(null); - // Emit is sync, so we can just assert that we got here - const transportSpy = vi.spyOn(transport, 'send').mockImplementation(() => { - // Do nothing so we don't send events to Sentry - return Promise.resolve({}); - }); - - const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); - await wait(500); - transaction.end(); - - await Sentry.flush(1000); - - // Only transaction is sent - expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]).toMatchObject({ type: 'transaction' }); - expect(transportSpy.mock.calls?.[0]?.[0]?.[1][1]).toBeUndefined(); - }); - - it('emits preprocessEvent for profile', async () => { - const [client] = makeClientWithHooks(); - Sentry.setCurrentClient(client); - client.init(); - - const onPreprocessEvent = vi.fn(); - - client.on('preprocessEvent', onPreprocessEvent); + createEnvelope({ type: 'transaction' }, { type: 'transaction', contexts: { profile: { profile_id: null } } }), + ); - const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); - await wait(500); - transaction.end(); + // Emit is sync, so we can just assert that we got here + expect(true).toBe(true); + }); - await Sentry.flush(1000); + it('if transaction was profiled, but profiler returned null', async () => { + const [client, transport] = makeLegacySpanProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); - expect(onPreprocessEvent.mock.calls[1]?.[0]).toMatchObject({ - profile: { - samples: expect.arrayContaining([expect.anything()]), - stacks: expect.arrayContaining([expect.anything()]), - }, - }); + vi.spyOn(CpuProfilerBindings, 'stopProfiling').mockReturnValue(null); + // Emit is sync, so we can just assert that we got here + const transportSpy = vi.spyOn(transport, 'send').mockImplementation(() => { + // Do nothing so we don't send events to Sentry + return Promise.resolve({}); }); - it('automated span instrumentation does not support continuous profiling', () => { - const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + await wait(500); + transaction.end(); - const [client] = makeClientWithHooks(); - Sentry.setCurrentClient(client); - client.init(); + await Sentry.flush(1000); - const integration = - client.getIntegrationByName>('ProfilingIntegration'); - if (!integration) { - throw new Error('Profiling integration not found'); - } - integration._profiler.start(); - expect(startProfilingSpy).not.toHaveBeenCalled(); - }); + // Only transaction is sent + expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[0]).toMatchObject({ type: 'transaction' }); + expect(transportSpy.mock.calls?.[0]?.[0]?.[1][1]).toBeUndefined(); }); - it('does not crash if stop is called multiple times', async () => { - const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); - - const [client] = makeClientWithHooks(); + it('emits preprocessEvent for profile', async () => { + const [client] = makeLegacySpanProfilingClient(); Sentry.setCurrentClient(client); client.init(); - const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'txn' }); - transaction.end(); - transaction.end(); - expect(stopProfilingSpy).toHaveBeenCalledTimes(1); - }); - it('enriches profile with debug_id', async () => { - GLOBAL_OBJ._sentryDebugIds = { - 'Error\n at filename.js (filename.js:36:15)': 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', - 'Error\n at filename2.js (filename2.js:36:15)': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', - 'Error\n at filename3.js (filename3.js:36:15)': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', - }; - - // @ts-expect-error we just mock the return type and ignore the signature - vi.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { - return { - samples: [ - { - stack_id: 0, - thread_id: '0', - elapsed_since_start_ns: '10', - }, - { - stack_id: 0, - thread_id: '0', - elapsed_since_start_ns: '10', - }, - ], - measurements: {}, - resources: ['filename.js', 'filename2.js'], - stacks: [[0]], - frames: [], - profiler_logging_mode: 'lazy', - }; - }); - - const [client, transport] = makeClientWithHooks(); - Sentry.setCurrentClient(client); - client.init(); + const onPreprocessEvent = vi.fn(); - const transportSpy = vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + client.on('preprocessEvent', onPreprocessEvent); const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); await wait(500); @@ -371,54 +314,104 @@ describe('ProfilingIntegration', () => { await Sentry.flush(1000); - expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[1]?.[1]).toMatchObject({ - debug_meta: { - images: [ - { - type: 'sourcemap', - debug_id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', - code_file: 'filename.js', - }, - { - type: 'sourcemap', - debug_id: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', - code_file: 'filename2.js', - }, - ], + expect(onPreprocessEvent.mock.calls[1]?.[0]).toMatchObject({ + profile: { + samples: expect.arrayContaining([expect.anything()]), + stacks: expect.arrayContaining([expect.anything()]), }, }); }); + + it('automated span instrumentation does not support continuous profiling', () => { + const [client] = makeLegacySpanProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const integration = client.getIntegrationByName>('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } + + Sentry.profiler.startProfiler(); + expect(startProfilingSpy).not.toHaveBeenCalled(); + }); }); - it('top level methods do not proxy to integration', () => { - const client = new Sentry.NodeClient({ - ...makeClientOptions({ profilesSampleRate: undefined }), - dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', - tracesSampleRate: 1, - profilesSampleRate: 1, - transport: _opts => - Sentry.makeNodeTransport({ - url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', - recordDroppedEvent: () => { - return undefined; + it('does not crash if stop is called multiple times', async () => { + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const [client] = makeLegacySpanProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'txn' }); + transaction.end(); + transaction.end(); + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + }); + + it('enriches profile with debug_id', async () => { + GLOBAL_OBJ._sentryDebugIds = { + 'Error\n at filename.js (filename.js:36:15)': 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + 'Error\n at filename2.js (filename2.js:36:15)': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + 'Error\n at filename3.js (filename3.js:36:15)': 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + }; + + // @ts-expect-error we just mock the return type and ignore the signature + vi.spyOn(CpuProfilerBindings, 'stopProfiling').mockImplementation(() => { + return { + samples: [ + { + stack_id: 0, + thread_id: '0', + elapsed_since_start_ns: '10', + }, + { + stack_id: 0, + thread_id: '0', + elapsed_since_start_ns: '10', }, - }), - integrations: [_nodeProfilingIntegration()], + ], + measurements: {}, + resources: ['filename.js', 'filename2.js'], + stacks: [[0]], + frames: [], + profiler_logging_mode: 'lazy', + }; }); + const [client, transport] = makeLegacySpanProfilingClient(); Sentry.setCurrentClient(client); client.init(); - const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); - const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + const transportSpy = vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); - Sentry.profiler.startProfiler(); - expect(startProfilingSpy).not.toHaveBeenCalled(); - Sentry.profiler.stopProfiler(); - expect(stopProfilingSpy).not.toHaveBeenCalled(); + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + await wait(500); + transaction.end(); + + await Sentry.flush(1000); + + expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[1]?.[1]).toMatchObject({ + debug_meta: { + images: [ + { + type: 'sourcemap', + debug_id: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa', + code_file: 'filename.js', + }, + { + type: 'sourcemap', + debug_id: 'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbb', + code_file: 'filename2.js', + }, + ], + }, + }); }); - describe('continuous profiling', () => { + describe('legacy continuous profiling', () => { beforeEach(() => { vi.useFakeTimers(); // We will mock the carrier as if it has been initialized by the SDK, else everything is short circuited @@ -463,7 +456,7 @@ describe('ProfilingIntegration', () => { }; }); - const [client, transport] = makeContinuousProfilingClient(); + const [client, transport] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -481,7 +474,7 @@ describe('ProfilingIntegration', () => { it('initializes the continuous profiler', () => { const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); - const [client] = makeContinuousProfilingClient(); + const [client] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -495,7 +488,7 @@ describe('ProfilingIntegration', () => { it('starts a continuous profile', () => { const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); - const [client] = makeContinuousProfilingClient(); + const [client] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -508,7 +501,7 @@ describe('ProfilingIntegration', () => { const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); - const [client] = makeContinuousProfilingClient(); + const [client] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -524,7 +517,7 @@ describe('ProfilingIntegration', () => { const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); - const [client] = makeContinuousProfilingClient(); + const [client] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -540,7 +533,7 @@ describe('ProfilingIntegration', () => { const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); - const [client] = makeContinuousProfilingClient(); + const [client] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -557,7 +550,7 @@ describe('ProfilingIntegration', () => { it('explicit calls to stop clear profilerId', async () => { const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); - const [client] = makeContinuousProfilingClient(); + const [client] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -575,7 +568,7 @@ describe('ProfilingIntegration', () => { const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); - const [client] = makeContinuousProfilingClient(); + const [client] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -590,7 +583,7 @@ describe('ProfilingIntegration', () => { const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); - const [client] = makeContinuousProfilingClient(); + const [client] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -609,7 +602,7 @@ describe('ProfilingIntegration', () => { it('continuous mode does not instrument spans', () => { const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); - const [client] = makeContinuousProfilingClient(); + const [client] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -641,7 +634,7 @@ describe('ProfilingIntegration', () => { }; }); - const [client, transport] = makeContinuousProfilingClient(); + const [client, transport] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -655,7 +648,7 @@ describe('ProfilingIntegration', () => { }); it('sets global profile context', async () => { - const [client, transport] = makeContinuousProfilingClient(); + const [client, transport] = makeLegacyContinuousProfilingClient(); Sentry.setCurrentClient(client); client.init(); @@ -694,14 +687,8 @@ describe('ProfilingIntegration', () => { }, }); }); - }); - describe('continuous profiling does not start in span profiling mode', () => { - it.each([ - ['profilesSampleRate=1', makeClientOptions({ profilesSampleRate: 1 })], - ['profilesSampler is defined', makeClientOptions({ profilesSampler: () => 1 })], - ])('%s', async (_label, options) => { - const logSpy = vi.spyOn(logger, 'log'); + it.each([['no option is set', makeClientOptions({})]])('%s', async (_label, options) => { const client = new Sentry.NodeClient({ ...options, dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', @@ -727,85 +714,279 @@ describe('ProfilingIntegration', () => { } vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + Sentry.profiler.startProfiler(); + const callCount = startProfilingSpy.mock.calls.length; + expect(startProfilingSpy).toHaveBeenCalled(); + Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + expect(startProfilingSpy).toHaveBeenCalledTimes(callCount); + }); + }); +}); - expect(startProfilingSpy).toHaveBeenCalled(); - const integration = client.getIntegrationByName>('ProfilingIntegration'); +describe('current manual continuous profiling', () => { + it('start and stops a profile session', () => { + const [client] = makeCurrentSpanProfilingClient({ + profileLifecycle: 'manual', + profileSessionSampleRate: 1, + }); + Sentry.setCurrentClient(client); + client.init(); - if (!integration) { - throw new Error('Profiling integration not found'); - } + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); - integration._profiler.start(); - expect(logSpy).toHaveBeenLastCalledWith( - '[Profiling] Failed to start, sentry client was never attached to the profiler.', - ); + Sentry.profiler.startProfileSession(); + Sentry.profiler.stopProfileSession(); + + expect(startProfilingSpy).toHaveBeenCalled(); + expect(stopProfilingSpy).toHaveBeenCalled(); + }); + + it('calling start and stop while profile session is running does nothing', () => { + const [client] = makeCurrentSpanProfilingClient({ + profileLifecycle: 'manual', + profileSessionSampleRate: 1, }); + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + + Sentry.profiler.startProfileSession(); + Sentry.profiler.startProfileSession(); + + expect(startProfilingSpy).toHaveBeenCalledTimes(1); + + Sentry.profiler.stopProfileSession(); + Sentry.profiler.stopProfileSession(); + + expect(stopProfilingSpy).toHaveBeenCalledTimes(1); }); - describe('continuous profiling mode', () => { - beforeEach(() => { - vi.clearAllMocks(); + + it('profileSessionSamplingRate is respected', () => { + const [client] = makeCurrentSpanProfilingClient({ + profileSessionSampleRate: 0, + profileLifecycle: 'manual', }); + Sentry.setCurrentClient(client); + client.init(); - it.each([['no option is set', makeClientOptions({})]])('%s', async (_label, options) => { - const client = new Sentry.NodeClient({ - ...options, - dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', - tracesSampleRate: 1, - transport: _opts => - Sentry.makeNodeTransport({ - url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', - recordDroppedEvent: () => { - return undefined; - }, - }), - integrations: [_nodeProfilingIntegration()], - }); + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); - Sentry.setCurrentClient(client); - client.init(); + Sentry.profiler.startProfileSession(); + Sentry.profiler.stopProfileSession(); - const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); - const transport = client.getTransport(); + expect(startProfilingSpy).not.toHaveBeenCalled(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + }); +}); - if (!transport) { - throw new Error('Transport not found'); - } +describe('trace profile lifecycle', () => { + it('trace profile lifecycle ignores manual calls to start and stop', () => { + const [client] = makeCurrentSpanProfilingClient({ + profileLifecycle: 'trace', + }); - vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); - Sentry.profiler.startProfiler(); - const callCount = startProfilingSpy.mock.calls.length; - expect(startProfilingSpy).toHaveBeenCalled(); + Sentry.setCurrentClient(client); + client.init(); - Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); - expect(startProfilingSpy).toHaveBeenCalledTimes(callCount); + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + + Sentry.profiler.startProfileSession(); + Sentry.profiler.stopProfileSession(); + + expect(startProfilingSpy).not.toHaveBeenCalled(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + }); + + it('starts profiler when first span is created', () => { + const [client] = makeCurrentSpanProfilingClient({ + profileLifecycle: 'trace', }); - it('top level methods proxy to integration', () => { - const client = new Sentry.NodeClient({ - ...makeClientOptions({}), - dsn: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', - tracesSampleRate: 1, - transport: _opts => - Sentry.makeNodeTransport({ - url: 'https://7fa19397baaf433f919fbe02228d5470@o1137848.ingest.sentry.io/6625302', - recordDroppedEvent: () => { - return undefined; - }, - }), - integrations: [_nodeProfilingIntegration()], + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const span = Sentry.startInactiveSpan({ forceTransaction: true, name: 'test' }); + + expect(startProfilingSpy).toHaveBeenCalled(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + + span.end(); + expect(stopProfilingSpy).toHaveBeenCalled(); + }); + + it('waits for the tail span to end before stopping the profiler', () => { + const [client] = makeCurrentSpanProfilingClient({ + profileLifecycle: 'trace', + }); + + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const first = Sentry.startInactiveSpan({ forceTransaction: true, name: 'test' }); + const second = Sentry.startInactiveSpan({ forceTransaction: true, name: 'child' }); + + expect(startProfilingSpy).toHaveBeenCalled(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + + first.end(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + + second.end(); + expect(stopProfilingSpy).toHaveBeenCalled(); + }); + + it('ending last span does not stop the profiler if first span is not ended', () => { + const [client] = makeCurrentSpanProfilingClient({ + profileLifecycle: 'trace', + }); + + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const first = Sentry.startInactiveSpan({ forceTransaction: true, name: 'test' }); + const second = Sentry.startInactiveSpan({ forceTransaction: true, name: 'child' }); + + expect(startProfilingSpy).toHaveBeenCalled(); + + second.end(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + + first.end(); + expect(stopProfilingSpy).toHaveBeenCalled(); + }); + it('multiple calls to span.end do not restart the profiler', () => { + const [client] = makeCurrentSpanProfilingClient({ + profileLifecycle: 'trace', + }); + + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + + const first = Sentry.startInactiveSpan({ forceTransaction: true, name: 'test' }); + const second = Sentry.startInactiveSpan({ forceTransaction: true, name: 'child' }); + + expect(startProfilingSpy).toHaveBeenCalled(); + + first.end(); + first.end(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + + second.end(); + expect(stopProfilingSpy).toHaveBeenCalled(); + }); +}); + +describe('Legacy vs Current API compat', () => { + describe('legacy', () => { + describe('span profiling', () => { + it('profiler.start, profiler.stop, profiler.startProfileSession, profiler.stopProfileSession void in automated span profiling mode', () => { + const [client] = makeLegacySpanProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + + // Profiler calls void + Sentry.profiler.startProfiler(); + Sentry.profiler.stopProfiler(); + + expect(startProfilingSpy).not.toHaveBeenCalled(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + + // This API is not supported in legacy mode + Sentry.profiler.startProfileSession(); + Sentry.profiler.stopProfileSession(); + + expect(startProfilingSpy).not.toHaveBeenCalled(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + + // Only starting and stopping the profiler is supported in legacy mode + const span = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + span.end(); + + expect(startProfilingSpy).toHaveBeenCalled(); + expect(stopProfilingSpy).toHaveBeenCalled(); }); + }); - Sentry.setCurrentClient(client); - client.init(); + describe('continuous profiling', () => { + it('profiler.start and profiler.stop start and stop the profiler, calls to profiler.startProfileSession and profiler.stopProfileSession are ignored', () => { + const [client] = makeLegacyContinuousProfilingClient(); + Sentry.setCurrentClient(client); + client.init(); - const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); - const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); - Sentry.profiler.startProfiler(); - expect(startProfilingSpy).toHaveBeenCalledTimes(1); - Sentry.profiler.stopProfiler(); - expect(stopProfilingSpy).toHaveBeenCalledTimes(1); + // Creating a span will not invoke the profiler + const span = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + span.end(); + + expect(startProfilingSpy).not.toHaveBeenCalled(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + + // This API is not supported in legacy mode + Sentry.profiler.startProfileSession(); + Sentry.profiler.stopProfileSession(); + + expect(startProfilingSpy).not.toHaveBeenCalled(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + + // Only the old signature is supported + Sentry.profiler.startProfiler(); + Sentry.profiler.stopProfiler(); + + expect(startProfilingSpy).toHaveBeenCalled(); + expect(stopProfilingSpy).toHaveBeenCalled(); + }); + }); + }); + + describe('current', () => { + describe('span profiling', () => { + it('profiler.start, profiler.stop, profiler.startProfileSession, profiler.stopProfileSession void in automated span profiling mode', () => { + const [client] = makeCurrentSpanProfilingClient({ + profileLifecycle: 'trace', + }); + Sentry.setCurrentClient(client); + client.init(); + + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + const stopProfilingSpy = vi.spyOn(CpuProfilerBindings, 'stopProfiling'); + + // Legacy mode is not supported under the new API + Sentry.profiler.startProfiler(); + Sentry.profiler.stopProfiler(); + + expect(startProfilingSpy).not.toHaveBeenCalled(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + + // This API is not supported in trace mode + Sentry.profiler.startProfileSession(); + Sentry.profiler.stopProfileSession(); + + expect(startProfilingSpy).not.toHaveBeenCalled(); + expect(stopProfilingSpy).not.toHaveBeenCalled(); + }); }); }); });