From 27647001fa160eaa7666c68603f67f5c8afc9346 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 19 Oct 2026 21:18:15 -0700 Subject: [PATCH 1/2] feat(deno): Register channel injection hooks from `init()` Node's `init()` registers the diagnostics-channel injection hooks, but Deno's did not. A Deno user had to run with `--preload=npm:@sentry/deno/import`, and got no warning without it. The channel integrations still reported that they were installed, but recorded no spans. `DenoClient` now registers the hooks in its constructor, as `NodeClient` does, behind the same `enableRuntimeChannelInjection` option. `init()` then calls `detectOrchestrionSetup()`, which warns under `debug: true` when no injection is active. The hooks only apply to modules imported after `init()`. `@sentry/deno/import` still covers modules imported before it, such as static imports in the entry module. The injection package could not load on Deno without env permission. Its vendored `debug` package, and the `supports-color` that `debug` loads, read `process.env` at import time, and Deno throws on that read. The build now replaces `debug` with a small shim that logs through the Sentry logger. Because of this, tracing-hooks messages on Node now appear under `debug: true` and no longer under `DEBUG=@apm-js-collab/*`. The build also wraps the `process.env.TRACING_DUMP` read in tracing-hooks in a try/catch, so a transformed module does not count as failed when the read throws. Fixes #23892 Fixes JS-3518 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../orchestrion-mysql/scenario-init.mjs | 42 ++++++++++++++ .../suites/orchestrion-mysql/test.ts | 44 +++++++++++++++ packages/deno/src/client.ts | 12 ++++ packages/deno/src/import.mjs | 6 +- packages/deno/src/sdk.ts | 5 +- packages/deno/src/types.ts | 13 +++++ .../rollup.npm.config.mjs | 55 ++++++++++--------- .../server-runtime-injection/src/debug.ts | 14 +++++ .../test/envAccess.test.ts | 47 ++++++++++++++++ 9 files changed, 209 insertions(+), 29 deletions(-) create mode 100644 dev-packages/deno-integration-tests/suites/orchestrion-mysql/scenario-init.mjs create mode 100644 packages/server-runtime-injection/src/debug.ts create mode 100644 packages/server-runtime-injection/test/envAccess.test.ts diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-mysql/scenario-init.mjs b/dev-packages/deno-integration-tests/suites/orchestrion-mysql/scenario-init.mjs new file mode 100644 index 000000000000..c1d6db36d5d3 --- /dev/null +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mysql/scenario-init.mjs @@ -0,0 +1,42 @@ +// Spawned by test.ts via `deno run`, without `@sentry/deno/import`. +// +// `init()` registers the orchestrion module hook, so the `mysql` import that +// follows it is transformed to publish to the `orchestrion:mysql:query` +// tracing channel. Pass `disabled` to opt out with +// `enableRuntimeChannelInjection: false`. +import { init } from '@sentry/deno'; +import { tracingChannel } from 'node:diagnostics_channel'; + +init({ + debug: true, + enableRuntimeChannelInjection: Deno.args[0] !== 'disabled', +}); + +const { default: mysql } = await import('mysql'); + +const events = []; +tracingChannel('orchestrion:mysql:query').subscribe({ + start() { + events.push('start'); + }, + end() {}, + asyncStart() {}, + asyncEnd() {}, + error() {}, +}); + +const conn = mysql.createConnection({ host: '127.0.0.1', user: 'root' }); +try { + conn.query('SELECT 1 AS solution', () => {}); +} catch { + // No live server — `start` has already published synchronously by now. +} +try { + conn.destroy(); +} catch { + // ignore +} + +const marker = globalThis.__SENTRY_ORCHESTRION__ ?? null; +// eslint-disable-next-line no-console +console.log(`SCENARIO events=${events.join(',')} marker=${JSON.stringify(marker)}`); diff --git a/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts b/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts index 6b46f548debb..f06d7f0111ee 100644 --- a/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts +++ b/dev-packages/deno-integration-tests/suites/orchestrion-mysql/test.ts @@ -51,6 +51,50 @@ Deno.test('@sentry/deno/import: transforms mysql so it publishes the orchestrion assert(line.includes('"runtime":["mysql"]'), `expected runtime marker, got: ${line}`); }); +async function runInitScenario(...args: string[]): Promise<{ line: string; out: string; err: string }> { + const scenario = new URL('./scenario-init.mjs', import.meta.url); + const cwd = new URL('../../', import.meta.url); + + // Deno throws on a `process.env` read without permission. Grant only the + // variable `mysql` itself reads, so any read by the SDK fails the run. + const command = new Deno.Command('deno', { + args: ['run', '--allow-read', '--allow-net', '--allow-env=READABLE_STREAM', scenario.pathname, ...args], + cwd: cwd.pathname, + stdout: 'piped', + stderr: 'piped', + }); + + const { code, stdout, stderr } = await command.output(); + const out = new TextDecoder().decode(stdout); + const err = new TextDecoder().decode(stderr); + + assertEquals(code, 0, `scenario exited ${code}\nstdout:\n${out}\nstderr:\n${err}`); + + const line = out.split('\n').find(l => l.startsWith('SCENARIO')) ?? ''; + assert(line, `no SCENARIO line in output:\n${out}\nstderr:\n${err}`); + return { line, out, err }; +} + +Deno.test('init(): transforms mysql imported after it', async () => { + const { line, out, err } = await runInitScenario(); + assert(line.includes('events=start'), `expected channel 'start' event, got: ${line}`); + assert(line.includes('"runtime":["mysql"]'), `expected runtime marker, got: ${line}`); + assert( + !`${out}${err}`.includes('No diagnostics-channel injection detected'), + `unexpected missing-injection warning:\n${out}\n${err}`, + ); +}); + +Deno.test('init(): enableRuntimeChannelInjection: false skips the hook and warns', async () => { + const { line, out, err } = await runInitScenario('disabled'); + assert(line.includes('events= '), `expected no channel events, got: ${line}`); + assert(!line.includes('"runtime"'), `expected no runtime marker, got: ${line}`); + assert( + `${out}${err}`.includes('No diagnostics-channel injection detected'), + `expected missing-injection warning:\n${out}\n${err}`, + ); +}); + Deno.test('mysql instrumentation: orchestrion:mysql:query channel produces a nested db span', async () => { resetGlobals(); const sink = spanSink(); diff --git a/packages/deno/src/client.ts b/packages/deno/src/client.ts index 295b7757b9fd..4e00cf8d83c9 100644 --- a/packages/deno/src/client.ts +++ b/packages/deno/src/client.ts @@ -1,10 +1,13 @@ import type { ServerRuntimeClientOptions } from '@sentry/core/server'; import { _INTERNAL_flushLogsBuffer, _INTERNAL_flushMetricsBuffer, SDK_VERSION } from '@sentry/core'; import { ServerRuntimeClient } from '@sentry/core/server'; +import { registerDiagnosticsChannelInjection } from '@sentry/server-runtime-injection/register'; import { setAsyncLocalStorageAsyncContextStrategy } from '@sentry/server-utils'; import process from 'node:process'; import type { DenoClientOptions } from './types'; +declare const __SENTRY_CHANNEL_INJECTION__: boolean | undefined; + function getHostName(): string | undefined { // Deno.permissions.querySync is not available on Deno Deploy if (!Deno.permissions.querySync) { @@ -75,6 +78,15 @@ export class DenoClient extends ServerRuntimeClient { process.on('beforeExit', this._metricsOnExitFlushListener); globalThis.addEventListener('unload', this._logOnExitFlushListener); globalThis.addEventListener('unload', this._metricsOnExitFlushListener); + + // In the constructor, as in `NodeClient`, so a directly constructed client installs the hooks + // too. Registration is idempotent. + if ( + (typeof __SENTRY_CHANNEL_INJECTION__ === 'undefined' || __SENTRY_CHANNEL_INJECTION__) && + options.enableRuntimeChannelInjection !== false + ) { + registerDiagnosticsChannelInjection(); + } } /** @inheritDoc */ diff --git a/packages/deno/src/import.mjs b/packages/deno/src/import.mjs index 85512d18a10a..15fa67a3dc42 100644 --- a/packages/deno/src/import.mjs +++ b/packages/deno/src/import.mjs @@ -1,8 +1,10 @@ /** * Orchestrion runtime hook for Deno. * - * Load this via the `--preload` argument to `deno run` to enable the - * channel-based instrumentations. + * `init()` registers the same hook, but it only applies to modules imported + * after `init()`. Load this via the `--preload` argument to `deno run` to also + * instrument modules imported before it, such as static imports in the entry + * module. * * For example: * diff --git a/packages/deno/src/sdk.ts b/packages/deno/src/sdk.ts index 8b277fb32453..96af20a57b87 100644 --- a/packages/deno/src/sdk.ts +++ b/packages/deno/src/sdk.ts @@ -14,7 +14,7 @@ import { requestDataIntegration, stackParserFromStackParserOptions, } from '@sentry/core'; -import { getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils'; +import { detectOrchestrionSetup, getTracingIntegrations, getErrorIntegrations } from '@sentry/server-utils'; import { DenoClient } from './client'; import { nodeStackLineParser } from '@sentry/core/server'; import { breadcrumbsIntegration } from './integrations/breadcrumbs'; @@ -138,5 +138,8 @@ function _init(options: DenoOptions, getDefaultIntegrationsImpl: (options: Optio setupOpenTelemetryTracer(); } + // After client creation, so the debug logger is enabled. + detectOrchestrionSetup(); + return client; } diff --git a/packages/deno/src/types.ts b/packages/deno/src/types.ts index 0c22e9004588..9e5a8cf53cd6 100644 --- a/packages/deno/src/types.ts +++ b/packages/deno/src/types.ts @@ -35,6 +35,19 @@ export interface BaseDenoOptions { */ enableOpenTelemetrySetup?: boolean; + /** + * Controls whether the SDK installs its runtime diagnostics-channel injection hooks. These hooks + * transform supported modules (e.g. Express) at load time so they emit the diagnostics channels + * that the channel-based integrations subscribe to. They only apply to modules imported after + * `init()`. To instrument modules imported before it, run Deno with + * `--preload=npm:@sentry/deno/import`. + * + * Set this to `false` to opt out. + * + * @default true + */ + enableRuntimeChannelInjection?: boolean; + /** Callback that is executed when a fatal global error occurs. */ onFatalError?(this: void, error: Error): void; } diff --git a/packages/server-runtime-injection/rollup.npm.config.mjs b/packages/server-runtime-injection/rollup.npm.config.mjs index 3f513c8e5cbc..49f2ddd2ba89 100644 --- a/packages/server-runtime-injection/rollup.npm.config.mjs +++ b/packages/server-runtime-injection/rollup.npm.config.mjs @@ -30,41 +30,44 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu // to the default itself, not a `{ default }` namespace — CJS callers use it as // `require('esquery').parse(...)`. // -// `strictRequires: false`: the default `'auto'` wraps conditionally-required modules (e.g. -// `debug`'s browser/node split) in lazy initializers exported as `__require` — an export name that -// downstream re-bundlers mishandle (Turbopack renames it, producing `.require is not a function` -// crashes in Next.js on Cloudflare). Hoisting is safe here: the vendored graph is closed (nothing +// `strictRequires: false`: the default `'auto'` wraps conditionally-required modules in lazy +// initializers exported as `__require` — an export name that downstream re-bundlers mishandle +// (Turbopack renames it, producing `.require is not a function` crashes in Next.js on Cloudflare). Hoisting is safe here: the vendored graph is closed (nothing // optional/missing) and has no require cycles that depend on lazy evaluation. const commonJSOptions = { transformMixedEsModules: true, requireReturnsDefault: 'auto', strictRequires: false }; const commonJSPlugin = commonjs(commonJSOptions); -// Always vendor `debug`'s Node build. Its default entry picks browser vs node at require time, -// which drags the browser build into this server-only bundle — and, hoisted by -// `strictRequires: false`, the browser build's storage detection probes `localStorage` at import -// time, which on Node >= 26 emits an ExperimentalWarning that pollutes stderr and console -// breadcrumbs in every user app. `order: 'pre'` because the base config's node-resolve plugin -// sorts ahead of package-specific plugins and would otherwise resolve `debug` first. -const debugNodeAlias = { - name: 'debug-node-alias', +// Replace `debug` with `src/debug.ts`, which logs through the Sentry logger. `debug` and the +// `supports-color` it loads read `process.env` at import time, which throws on Deno without env +// permission. `order: 'pre'` because the base config's node-resolve plugin sorts ahead of +// package-specific plugins and would otherwise resolve `debug` first. +const debugShim = { + name: 'debug-shim', resolveId: { order: 'pre', - handler(source, importer) { - return source === 'debug' ? this.resolve('debug/src/node.js', importer, { skipSelf: true }) : null; + handler(source) { + return source === 'debug' ? path.join(import.meta.dirname, 'src/debug.ts') : null; }, }, }; -// `debug` vendors the `supports-color` that it finds, and which version that is depends on how the package manager -// installs it. Resolve it from this package instead, so the vendored version is the one in its `devDependencies`. -const supportsColorFromPackage = { - name: 'supports-color-from-package', - resolveId: { - order: 'pre', - handler(source) { - return source === 'supports-color' - ? this.resolve(source, path.join(import.meta.dirname, 'package.json'), { skipSelf: true }) - : null; - }, +// `ModulePatch` reads `process.env.TRACING_DUMP` after each module it transforms. On Deno without +// env permission that read throws, and the module counts as failed. Treat the throw as unset. +const guardTracingDump = { + name: 'guard-tracing-dump', + transform(code, id) { + if (!id.includes('@apm-js-collab/tracing-hooks/index.js')) { + return null; + } + const read = 'process.env.TRACING_DUMP)'; + if (!code.includes(read)) { + this.error(`Expected \`${read}\` in ${id}`); + } + // `map: null` keeps the existing mappings, which stay right because no line moves. + return { + code: code.replace(read, '(() => { try { return process.env.TRACING_DUMP } catch { return undefined } })())'), + map: null, + }; }, }; @@ -113,7 +116,7 @@ export default [ // `import` condition, so the `build/cjs` copy is unused. entrypoints: ['src/register.ts', 'src/hook.mjs'], packageSpecificConfig: { - plugins: [debugNodeAlias, supportsColorFromPackage, commonJSPlugin, thirdPartyLicensePlugin], + plugins: [debugShim, guardTracingDump, commonJSPlugin, thirdPartyLicensePlugin], output: { exports: 'named', preserveModules: true, diff --git a/packages/server-runtime-injection/src/debug.ts b/packages/server-runtime-injection/src/debug.ts new file mode 100644 index 000000000000..da4386172b6b --- /dev/null +++ b/packages/server-runtime-injection/src/debug.ts @@ -0,0 +1,14 @@ +// Stands in for the `debug` package in the vendored dependencies (see `rollup.npm.config.mjs`). +// `debug` reads `process.env` when it loads, which throws on Deno without env permission, and the +// Deno SDK loads this package from `init()`. The messages go to the Sentry logger instead, so +// `debug: true` turns them on. +import { debug } from '@sentry/core'; +import { format } from 'node:util'; + +export default function createDebug(namespace: string): (...args: unknown[]) => void { + return (...args) => { + if (debug.isEnabled()) { + debug.log(`[${namespace}]`, format(...args)); + } + }; +} diff --git a/packages/server-runtime-injection/test/envAccess.test.ts b/packages/server-runtime-injection/test/envAccess.test.ts new file mode 100644 index 000000000000..079a1ca6b857 --- /dev/null +++ b/packages/server-runtime-injection/test/envAccess.test.ts @@ -0,0 +1,47 @@ +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +/** + * Deno throws `NotCapable` on any `process.env` access when the app has no env permission. The Deno + * SDK imports this package from `init()`, so loading it and registering the hooks must not touch + * `process.env`. The vendored dependencies are the risk here, so this runs on the built files. + */ +const buildDir = join(__dirname, '..', 'build'); + +// Stands in for Deno's permission check. Node's own loader reads `process.env` too, so only reads +// made from this package's built files throw. +const lockEnv = ` +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const env = process.env; +const guard = trap => (...args) => { + if (new Error().stack.includes(${JSON.stringify(buildDir)})) { + throw new Error('process.env access denied'); + } + return Reflect[trap](env, ...args.slice(1)); +}; +const traps = Object.fromEntries(['get', 'has', 'ownKeys', 'getOwnPropertyDescriptor', 'set'].map(t => [t, guard(t)])); +Object.defineProperty(process, 'env', { value: new Proxy(env, traps) }); +`; + +describe.each([ + ['cjs', `require(${JSON.stringify(join(buildDir, 'cjs', 'register.js'))})`], + ['esm', `await import(${JSON.stringify(pathToFileURL(join(buildDir, 'esm', 'register.js')).href)})`], +])('the %s build', (_variant, load) => { + it('loads and registers without reading process.env', { timeout: 30_000 }, () => { + const script = `${lockEnv} +const { registerDiagnosticsChannelInjection } = ${load}; +registerDiagnosticsChannelInjection(); +console.log(JSON.stringify(globalThis.__SENTRY_ORCHESTRION__)); +`; + + const out = execFileSync(process.execPath, ['--input-type=module', '--eval', script], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + expect(JSON.parse(out.trim())).toEqual({ runtime: [] }); + }); +}); From 125c9c03efea9a09d0f15cd2b319a5ed45d927d6 Mon Sep 17 00:00:00 2001 From: isaacs Date: Tue, 20 Oct 2026 07:57:50 -0700 Subject: [PATCH 2/2] fix: node 20.19 env access failure --- packages/server-runtime-injection/test/envAccess.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/server-runtime-injection/test/envAccess.test.ts b/packages/server-runtime-injection/test/envAccess.test.ts index 079a1ca6b857..fc98b25c775e 100644 --- a/packages/server-runtime-injection/test/envAccess.test.ts +++ b/packages/server-runtime-injection/test/envAccess.test.ts @@ -10,14 +10,15 @@ import { describe, expect, it } from 'vitest'; */ const buildDir = join(__dirname, '..', 'build'); -// Stands in for Deno's permission check. Node's own loader reads `process.env` too, so only reads -// made from this package's built files throw. +// Stands in for Deno's permission check. Node's own loader reads `process.env` too, even while it +// loads a file that one of ours required, so only reads whose direct caller is one of this +// package's built files throw. Stack lines: the `Error` line, this trap, then the caller. const lockEnv = ` import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const env = process.env; const guard = trap => (...args) => { - if (new Error().stack.includes(${JSON.stringify(buildDir)})) { + if (new Error().stack.split('\\n')[2].includes(${JSON.stringify(buildDir)})) { throw new Error('process.env access denied'); } return Reflect[trap](env, ...args.slice(1));