From 9305cf8802bde3e87a7b77fa1b475f4073af9cc0 Mon Sep 17 00:00:00 2001 From: Charly Gomez Date: Mon, 30 Mar 2026 13:52:09 +0200 Subject: [PATCH] skip profiling on worker threads --- packages/profiling-node/src/integration.ts | 9 ++ .../profiling-node/src/spanProfileUtils.ts | 8 ++ .../test/integration.worker.test.ts | 92 ++++++++++++------- 3 files changed, 74 insertions(+), 35 deletions(-) diff --git a/packages/profiling-node/src/integration.ts b/packages/profiling-node/src/integration.ts index 9b4fd1601420..4cb51ac540b5 100644 --- a/packages/profiling-node/src/integration.ts +++ b/packages/profiling-node/src/integration.ts @@ -14,6 +14,7 @@ import { } from '@sentry/core'; import type { NodeClient, NodeOptions } from '@sentry/node'; import { CpuProfilerBindings, ProfileFormat, type RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; +import { isMainThread } from 'worker_threads'; import { DEBUG_BUILD } from './debug-build'; import { NODE_MAJOR } from './nodeVersion'; import { MAX_PROFILE_DURATION_MS, maybeProfileSpan, stopSpanProfile } from './spanProfileUtils'; @@ -62,6 +63,14 @@ class ContinuousProfiler { * @param client */ public initialize(client: NodeClient): void { + if (!isMainThread) { + DEBUG_BUILD && + debug.warn( + '[Profiling] nodeProfilingIntegration() does not support worker threads — profiling will be disabled for this thread.', + ); + return; + } + this._client = client; const options = client.getOptions(); diff --git a/packages/profiling-node/src/spanProfileUtils.ts b/packages/profiling-node/src/spanProfileUtils.ts index bf1be3a0bf44..436e741ff7a2 100644 --- a/packages/profiling-node/src/spanProfileUtils.ts +++ b/packages/profiling-node/src/spanProfileUtils.ts @@ -3,6 +3,7 @@ import type { CustomSamplingContext, Span } from '@sentry/core'; import { debug, spanIsSampled, spanToJSON, uuid4 } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; import { CpuProfilerBindings, type RawThreadCpuProfile } from '@sentry-internal/node-cpu-profiler'; +import { isMainThread } from 'worker_threads'; import { DEBUG_BUILD } from './debug-build'; import { isValidSampleRate } from './utils'; @@ -17,6 +18,13 @@ export function maybeProfileSpan( span: Span, customSamplingContext?: CustomSamplingContext, ): string | undefined { + // Profiling is not supported in worker threads as the native CPU profiler's + // sampling thread can race with V8's GC across isolates, causing heap corruption. + if (!isMainThread) { + DEBUG_BUILD && debug.log('[Profiling] Skipping span profiling in worker thread.'); + return; + } + // 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. diff --git a/packages/profiling-node/test/integration.worker.test.ts b/packages/profiling-node/test/integration.worker.test.ts index 1d34c03b33cb..fea3c1eb4c4f 100644 --- a/packages/profiling-node/test/integration.worker.test.ts +++ b/packages/profiling-node/test/integration.worker.test.ts @@ -1,6 +1,7 @@ import type { ProfilingIntegration, Transport } from '@sentry/core'; import * as Sentry from '@sentry/node'; -import { expect, it, vi } from 'vitest'; +import { CpuProfilerBindings } from '@sentry-internal/node-cpu-profiler'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { _nodeProfilingIntegration } from '../src/integration'; // Mock the modules before the import, so that the value is initialized before the module is loaded @@ -12,7 +13,7 @@ vi.mock('worker_threads', () => { }); vi.setConfig({ testTimeout: 10_000 }); -function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] { +function makeClient(options: Partial = {}): [Sentry.NodeClient, Transport] { const integration = _nodeProfilingIntegration(); const client = new Sentry.NodeClient({ stackParser: Sentry.defaultStackParser, @@ -28,48 +29,69 @@ function makeContinuousProfilingClient(): [Sentry.NodeClient, Transport] { return undefined; }, }), + ...options, }); return [client, client.getTransport() as Transport]; } -it('worker threads context', () => { - const [client, transport] = makeContinuousProfilingClient(); - Sentry.setCurrentClient(client); - client.init(); +describe('worker threads', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not start continuous profiling in worker threads', () => { + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeClient(); + Sentry.setCurrentClient(client); + client.init(); + + const integration = client.getIntegrationByName>('ProfilingIntegration'); + if (!integration) { + throw new Error('Profiling integration not found'); + } - const transportSpy = vi.spyOn(transport, 'send').mockReturnValue(Promise.resolve({})); + // Calling start should be a no-op in a worker thread + integration._profiler.start(); - const nonProfiledTransaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); - nonProfiledTransaction.end(); + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + transaction.end(); - expect(transportSpy.mock.calls?.[0]?.[0]?.[1]?.[0]?.[1]).not.toMatchObject({ - contexts: { - profile: {}, - }, + // The native profiler should never have been called + expect(startProfilingSpy).not.toHaveBeenCalled(); + + integration._profiler.stop(); }); - const integration = client.getIntegrationByName>('ProfilingIntegration'); - if (!integration) { - throw new Error('Profiling integration not found'); - } - - integration._profiler.start(); - const profiledTransaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); - profiledTransaction.end(); - integration._profiler.stop(); - - expect(transportSpy.mock.calls?.[1]?.[0]?.[1]?.[0]?.[1]).toMatchObject({ - contexts: { - trace: { - data: expect.objectContaining({ - ['thread.id']: '9999', - ['thread.name']: 'worker', - }), - }, - profile: { - profiler_id: expect.any(String), - }, - }, + it('does not start span profiling in worker threads', () => { + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeClient({ profilesSampleRate: 1 }); + Sentry.setCurrentClient(client); + client.init(); + + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + transaction.end(); + + // The native profiler should never have been called even with profilesSampleRate set + expect(startProfilingSpy).not.toHaveBeenCalled(); + }); + + it('does not start trace lifecycle profiling in worker threads', () => { + const startProfilingSpy = vi.spyOn(CpuProfilerBindings, 'startProfiling'); + + const [client] = makeClient({ + profileSessionSampleRate: 1.0, + profileLifecycle: 'trace', + }); + Sentry.setCurrentClient(client); + client.init(); + + const transaction = Sentry.startInactiveSpan({ forceTransaction: true, name: 'profile_hub' }); + transaction.end(); + + // The native profiler should never have been called + expect(startProfilingSpy).not.toHaveBeenCalled(); }); });