From 0ad7da4f2333aa5beaa16b5fd3dc2cb40463eee3 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 11 Jul 2025 01:46:50 +0200 Subject: [PATCH 1/3] feat(node-native): Add option to selectively disable blocked detection --- .../thread-blocked-native/basic-disabled.mjs | 28 ++ .../suites/thread-blocked-native/long-work.js | 11 +- .../suites/thread-blocked-native/test.ts | 17 +- packages/node-native/package.json | 2 +- .../src/event-loop-block-integration.ts | 262 ++++++++++++------ .../src/event-loop-block-watchdog.ts | 2 +- packages/node-native/src/index.ts | 7 +- yarn.lock | 8 +- 8 files changed, 247 insertions(+), 90 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/thread-blocked-native/basic-disabled.mjs diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/basic-disabled.mjs b/dev-packages/node-integration-tests/suites/thread-blocked-native/basic-disabled.mjs new file mode 100644 index 000000000000..15a1f496de61 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/basic-disabled.mjs @@ -0,0 +1,28 @@ +import * as Sentry from '@sentry/node'; +import { disableBlockDetectionForCallback, eventLoopBlockIntegration } from '@sentry/node-native'; +import { longWork, longWorkOther } from './long-work.js'; + +global._sentryDebugIds = { [new Error().stack]: 'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaa' }; + +setTimeout(() => { + process.exit(); +}, 15000); + +Sentry.init({ + debug: true, + dsn: process.env.SENTRY_DSN, + release: '1.0', + integrations: [eventLoopBlockIntegration()], +}); + +setTimeout(() => { + disableBlockDetectionForCallback(() => { + // This wont be captured + longWork(); + }); + + setTimeout(() => { + // But this will be captured + longWorkOther(); + }, 2000); +}, 2000); diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/long-work.js b/dev-packages/node-integration-tests/suites/thread-blocked-native/long-work.js index 55f5358a10fe..fdf6b537126d 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/long-work.js +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/long-work.js @@ -1,7 +1,15 @@ const crypto = require('crypto'); const assert = require('assert'); -function longWork() { +function longWork(count = 100) { + for (let i = 0; i < count; i++) { + const salt = crypto.randomBytes(128).toString('base64'); + const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); + assert.ok(hash); + } +} + +function longWorkOther() { for (let i = 0; i < 200; i++) { const salt = crypto.randomBytes(128).toString('base64'); const hash = crypto.pbkdf2Sync('myPassword', salt, 10000, 512, 'sha512'); @@ -10,3 +18,4 @@ function longWork() { } exports.longWork = longWork; +exports.longWorkOther = longWorkOther; diff --git a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts index 6798882015f1..d168b8ce75d5 100644 --- a/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts +++ b/dev-packages/node-integration-tests/suites/thread-blocked-native/test.ts @@ -3,7 +3,7 @@ import type { Event } from '@sentry/core'; import { afterAll, describe, expect, test } from 'vitest'; import { cleanupChildProcesses, createRunner } from '../../utils/runner'; -function EXCEPTION(thread_id = '0') { +function EXCEPTION(thread_id = '0', fn = 'longWork') { return { values: [ { @@ -24,7 +24,7 @@ function EXCEPTION(thread_id = '0') { colno: expect.any(Number), lineno: expect.any(Number), filename: expect.any(String), - function: 'longWork', + function: fn, in_app: true, }), ]), @@ -155,6 +155,19 @@ describe('Thread Blocked Native', { timeout: 30_000 }, () => { expect(runner.childHasExited()).toBe(true); }); + test('can be disabled with disableBlockDetectionForCallback', async () => { + await createRunner(__dirname, 'basic-disabled.mjs') + .withMockSentryServer() + .expect({ + event: { + ...ANR_EVENT, + exception: EXCEPTION('0', 'longWorkOther'), + }, + }) + .start() + .completed(); + }); + test('worker thread', async () => { const instrument = join(__dirname, 'instrument.mjs'); await createRunner(__dirname, 'worker-main.mjs') diff --git a/packages/node-native/package.json b/packages/node-native/package.json index c63b6b63004b..86b627890adf 100644 --- a/packages/node-native/package.json +++ b/packages/node-native/package.json @@ -63,7 +63,7 @@ "build:tarball": "npm pack" }, "dependencies": { - "@sentry-internal/node-native-stacktrace": "^0.1.0", + "@sentry-internal/node-native-stacktrace": "^0.2.0", "@sentry/core": "9.37.0", "@sentry/node": "9.37.0" }, diff --git a/packages/node-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts index 6b643e944adf..127c66d538e9 100644 --- a/packages/node-native/src/event-loop-block-integration.ts +++ b/packages/node-native/src/event-loop-block-integration.ts @@ -1,15 +1,26 @@ -import { Worker } from 'node:worker_threads'; -import type { Contexts, Event, EventHint, IntegrationFn } from '@sentry/core'; -import { defineIntegration, getFilenameToDebugIdMap, getIsolationScope, logger } from '@sentry/core'; +import { isPromise } from 'node:util/types'; +import { isMainThread, Worker } from 'node:worker_threads'; +import type { + Client, + ClientOptions, + Contexts, + DsnComponents, + Event, + EventHint, + Integration, + IntegrationFn, +} from '@sentry/core'; +import { defineIntegration, getClient, getFilenameToDebugIdMap, getIsolationScope, logger } from '@sentry/core'; import type { NodeClient } from '@sentry/node'; import { registerThread, threadPoll } from '@sentry-internal/node-native-stacktrace'; import type { ThreadBlockedIntegrationOptions, WorkerStartData } from './common'; import { POLL_RATIO } from './common'; +const INTEGRATION_NAME = 'ThreadBlocked'; const DEFAULT_THRESHOLD_MS = 1_000; function log(message: string, ...args: unknown[]): void { - logger.log(`[Sentry Block Event Loop] ${message}`, ...args); + logger.log(`[Sentry Event Loop Blocked] ${message}`, ...args); } /** @@ -27,68 +38,61 @@ async function getContexts(client: NodeClient): Promise { return event?.contexts || {}; } -const INTEGRATION_NAME = 'ThreadBlocked'; +type IntegrationInternal = { start: () => void; stop: () => void }; -const _eventLoopBlockIntegration = ((options: Partial = {}) => { - return { - name: INTEGRATION_NAME, - afterAllSetup(client: NodeClient) { - registerThread(); - _startWorker(client, options).catch(err => { - log('Failed to start event loop block worker', err); - }); - }, - }; -}) satisfies IntegrationFn; +function poll(enabled: boolean, clientOptions: ClientOptions): void { + try { + const currentSession = getIsolationScope().getSession(); + // We need to copy the session object and remove the toJSON method so it can be sent to the worker + // serialized without making it a SerializedSession + const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; + // message the worker to tell it the main event loop is still running + threadPoll({ session, debugImages: getFilenameToDebugIdMap(clientOptions.stackParser) }, !enabled); + } catch (_) { + // we ignore all errors + } +} /** - * Monitors the Node.js event loop for blocking behavior and reports blocked events to Sentry. - * - * Uses a background worker thread to detect when the main thread is blocked for longer than - * the configured threshold (default: 1 second). - * - * When instrumenting via the `--import` flag, this integration will - * automatically monitor all worker threads as well. - * - * ```js - * // instrument.mjs - * import * as Sentry from '@sentry/node'; - * import { eventLoopBlockIntegration } from '@sentry/node-native'; - * - * Sentry.init({ - * dsn: '__YOUR_DSN__', - * integrations: [ - * eventLoopBlockIntegration({ - * threshold: 500, // Report blocks longer than 500ms - * }), - * ], - * }); - * ``` - * - * Start your application with: - * ```bash - * node --import instrument.mjs app.mjs - * ``` + * Starts polling */ -export const eventLoopBlockIntegration = defineIntegration(_eventLoopBlockIntegration); +function startPolling( + client: Client, + integrationOptions: Partial, +): IntegrationInternal | undefined { + registerThread(); + + let enabled = true; + + const initOptions = client.getOptions(); + const pollInterval = (integrationOptions.threshold || DEFAULT_THRESHOLD_MS) / POLL_RATIO; + + // unref so timer does not block exit + setInterval(() => poll(enabled, initOptions), pollInterval).unref(); + + return { + start: () => { + enabled = true; + }, + stop: () => { + enabled = false; + // poll immediately because the timer above might not get a chance to run + // before the event loop gets blocked + poll(enabled, initOptions); + }, + }; +} /** - * Starts the worker thread + * Starts the worker thread that will monitor the other threads. * - * @returns A function to stop the worker + * This function is only called in the main thread. */ -async function _startWorker( +async function startWorker( + dsn: DsnComponents, client: NodeClient, integrationOptions: Partial, -): Promise<() => void> { - const dsn = client.getDsn(); - - if (!dsn) { - return () => { - // - }; - } - +): Promise { const contexts = await getContexts(client); // These will not be accurate if sent later from the worker thread @@ -117,8 +121,6 @@ async function _startWorker( contexts, }; - const pollInterval = options.threshold / POLL_RATIO; - const worker = new Worker(new URL('./event-loop-block-watchdog.js', import.meta.url), { workerData: options, // We don't want any Node args like --import to be passed to the worker @@ -131,37 +133,137 @@ async function _startWorker( worker.terminate(); }); - const timer = setInterval(() => { - try { - const currentSession = getIsolationScope().getSession(); - // We need to copy the session object and remove the toJSON method so it can be sent to the worker - // serialized without making it a SerializedSession - const session = currentSession ? { ...currentSession, toJSON: undefined } : undefined; - // message the worker to tell it the main event loop is still running - threadPoll({ session, debugImages: getFilenameToDebugIdMap(initOptions.stackParser) }); - } catch (_) { - // - } - }, pollInterval); - // Timer should not block exit - timer.unref(); - worker.once('error', (err: Error) => { - clearInterval(timer); log('watchdog worker error', err); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + worker.terminate(); }); worker.once('exit', (code: number) => { - clearInterval(timer); log('watchdog worker exit', code); }); // Ensure this thread can't block app exit worker.unref(); +} - return () => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - worker.terminate(); - clearInterval(timer); - }; +const _eventLoopBlockIntegration = ((options: Partial = {}) => { + let polling: IntegrationInternal | undefined; + + return { + name: INTEGRATION_NAME, + async afterAllSetup(client: NodeClient): Promise { + const dsn = client.getDsn(); + + if (!dsn) { + log('No DSN configured, skipping starting integration'); + return; + } + + try { + polling = await startPolling(client, options); + + if (isMainThread) { + await startWorker(dsn, client, options); + } + } catch (err) { + log('Failed to start integration', err); + } + }, + start() { + polling?.start(); + }, + stop() { + polling?.stop(); + }, + } as Integration & IntegrationInternal; +}) satisfies IntegrationFn; + +/** + * Monitors the Node.js event loop for blocking behavior and reports blocked events to Sentry. + * + * Uses a background worker thread to detect when the main thread is blocked for longer than + * the configured threshold (default: 1 second). + * + * When instrumenting via the `--import` flag, this integration will + * automatically monitor all worker threads as well. + * + * ```js + * // instrument.mjs + * import * as Sentry from '@sentry/node'; + * import { eventLoopBlockIntegration } from '@sentry/node-native'; + * + * Sentry.init({ + * dsn: '__YOUR_DSN__', + * integrations: [ + * eventLoopBlockIntegration({ + * threshold: 500, // Report blocks longer than 500ms + * }), + * ], + * }); + * ``` + * + * Start your application with: + * ```bash + * node --import instrument.mjs app.mjs + * ``` + */ +export const eventLoopBlockIntegration = defineIntegration(_eventLoopBlockIntegration); + +export function disableBlockDetectionForCallback(callback: () => T): T; +export function disableBlockDetectionForCallback(callback: () => Promise): Promise; +/** + * Disables Event Loop Block detection for the current thread for the duration + * of the callback. + * + * This utility function allows you to disable block detection during operations that + * are expected to block the event loop, such as intensive computational tasks or + * synchronous I/O operations. + */ +export function disableBlockDetectionForCallback(callback: () => T | Promise): T | Promise { + const integration = getClient()?.getIntegrationByName(INTEGRATION_NAME) as IntegrationInternal | undefined; + + if (!integration) { + return callback(); + } + + integration.stop(); + + const result = callback(); + if (isPromise(result)) { + return result.finally(() => integration.start()); + } + + integration.start(); + return result; +} + +/** + * Pauses the block detection integration. + * + * This function pauses event loop block detection for the current thread. + */ +export function pauseEventLoopBlockDetection(): void { + const integration = getClient()?.getIntegrationByName(INTEGRATION_NAME) as IntegrationInternal | undefined; + + if (!integration) { + return; + } + + integration.stop(); +} + +/** + * Restarts the block detection integration. + * + * This function restarts event loop block detection for the current thread. + */ +export function restartEventLoopBlockDetection(): void { + const integration = getClient()?.getIntegrationByName(INTEGRATION_NAME) as IntegrationInternal | undefined; + + if (!integration) { + return; + } + + integration.stop(); } diff --git a/packages/node-native/src/event-loop-block-watchdog.ts b/packages/node-native/src/event-loop-block-watchdog.ts index 8909c00d1ea7..26b9bb683930 100644 --- a/packages/node-native/src/event-loop-block-watchdog.ts +++ b/packages/node-native/src/event-loop-block-watchdog.ts @@ -37,7 +37,7 @@ const triggeredThreads = new Set(); function log(...msg: unknown[]): void { if (debug) { // eslint-disable-next-line no-console - console.log('[Sentry Block Event Loop Watchdog]', ...msg); + console.log('[Sentry Event Loop Blocked Watchdog]', ...msg); } } diff --git a/packages/node-native/src/index.ts b/packages/node-native/src/index.ts index 454be4eb8ad2..2d7cab39ff10 100644 --- a/packages/node-native/src/index.ts +++ b/packages/node-native/src/index.ts @@ -1 +1,6 @@ -export { eventLoopBlockIntegration } from './event-loop-block-integration'; +export { + eventLoopBlockIntegration, + disableBlockDetectionForCallback, + pauseEventLoopBlockDetection, + restartEventLoopBlockDetection, +} from './event-loop-block-integration'; diff --git a/yarn.lock b/yarn.lock index df78dd913611..91f97624598b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6959,10 +6959,10 @@ detect-libc "^2.0.3" node-abi "^3.73.0" -"@sentry-internal/node-native-stacktrace@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.1.0.tgz#fa0eaf1e66245f463ca2294ff63da74c56d1a052" - integrity sha512-dWkxhDdjcRdEOTk1acrdBledqIroaYJrOSbecx5tJ/m9DiWZ1Oa4eNi/sI2SHLT+hKmsBBxrychf6+Iitz5Bzw== +"@sentry-internal/node-native-stacktrace@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@sentry-internal/node-native-stacktrace/-/node-native-stacktrace-0.2.0.tgz#d759d9ba62101aea46829c436aec490d4a63f9f7" + integrity sha512-MPkjcXFUaBVxbpx8whvqQu7UncriCt3nUN7uA+ojgauHF2acvSp5nJCqKM2a4KInFWNiI1AxJ6tLE7EuBJ4WBQ== dependencies: detect-libc "^2.0.4" node-abi "^3.73.0" From 6980de1531b86a7899a348ff5be54e15a27d0fb4 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Fri, 11 Jul 2025 11:29:15 +0200 Subject: [PATCH 2/3] oops --- packages/node-native/src/event-loop-block-integration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts index 127c66d538e9..40be10439750 100644 --- a/packages/node-native/src/event-loop-block-integration.ts +++ b/packages/node-native/src/event-loop-block-integration.ts @@ -265,5 +265,5 @@ export function restartEventLoopBlockDetection(): void { return; } - integration.stop(); + integration.start(); } From 13c8f0040bd2676aadeb48fa12b02ce844033e36 Mon Sep 17 00:00:00 2001 From: Tim Fish Date: Mon, 14 Jul 2025 12:35:44 +0200 Subject: [PATCH 3/3] Restart even if callback throws --- dev-packages/rollup-utils/npmHelpers.mjs | 2 +- .../src/event-loop-block-integration.ts | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 83053aaeea98..cff113d622d6 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -93,7 +93,7 @@ export function makeBaseNPMConfig(options = {}) { } return true; - } + }, }, plugins: [nodeResolvePlugin, sucrasePlugin, debugBuildStatementReplacePlugin, rrwebBuildPlugin, cleanupPlugin], diff --git a/packages/node-native/src/event-loop-block-integration.ts b/packages/node-native/src/event-loop-block-integration.ts index 40be10439750..177713eccde6 100644 --- a/packages/node-native/src/event-loop-block-integration.ts +++ b/packages/node-native/src/event-loop-block-integration.ts @@ -229,13 +229,18 @@ export function disableBlockDetectionForCallback(callback: () => T | Promise< integration.stop(); - const result = callback(); - if (isPromise(result)) { - return result.finally(() => integration.start()); + try { + const result = callback(); + if (isPromise(result)) { + return result.finally(() => integration.start()); + } + + integration.start(); + return result; + } catch (error) { + integration.start(); + throw error; } - - integration.start(); - return result; } /**