-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
fix(node): Detect + warn when the orchestrion runtime hook is bundled #23675
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
120dcb8
ca87750
1b79791
e14be17
fd16275
7aeb861
ec2f696
48e19fb
e0278fb
427a26c
851e59c
52f79d6
8d1dc92
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import * as Sentry from '@sentry/node'; | ||
|
|
||
| Sentry.init({ tracesSampleRate: 0 }); | ||
|
|
||
| // `dataloader` is left external by the build, so it loads through Node's module | ||
| // loader and the runtime hook has to transform it. That is the only path on | ||
| // which a stripped code transformer actually costs the user instrumentation. | ||
| const { default: DataLoader } = await import('dataloader'); | ||
| await new DataLoader(async keys => keys).load(1); | ||
|
|
||
| // `runtime` lists the modules the runtime hook actually transformed. It stays empty when the | ||
| // transformer was stripped (dep loaded uninstrumented) and lists `dataloader` when the hook ran — | ||
| // so the tests can assert on the outcome, not just on whether a warning printed. | ||
| const runtime = JSON.stringify(globalThis.__SENTRY_ORCHESTRION__?.runtime ?? []); | ||
| // eslint-disable-next-line no-console | ||
| console.log( | ||
| `DEP_LOADED bundler_marker=${globalThis.__SENTRY_ORCHESTRION__?.bundler instanceof Set} runtime=${runtime}`, | ||
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import * as Sentry from '@sentry/node'; | ||
|
|
||
| // No DSN: nothing is sent. `init()` installs the runtime diagnostics-channel | ||
| // hook regardless, and that is the code path under test. | ||
| Sentry.init({ tracesSampleRate: 0 }); | ||
|
|
||
| // The marker is printed for diagnosis only. The test asserts on whether the SDK | ||
| // warned, not on how it decided to, so it does not pin one implementation. | ||
| // eslint-disable-next-line no-console | ||
| console.log(`APP_STARTED bundler_marker=${globalThis.__SENTRY_ORCHESTRION__?.bundler instanceof Set}`); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| // Second entry point for the code-splitting case: sharing `app.mjs` with | ||
| // `entry-b.mjs` forces esbuild to move it into a shared chunk. | ||
| import './app.mjs'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| import './app.mjs'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| import { spawnSync } from 'child_process'; | ||
| import { rmSync } from 'fs'; | ||
| import { join } from 'path'; | ||
| import { sentryEsbuildPlugin } from '@sentry/node/esbuild'; | ||
| import { build } from 'esbuild'; | ||
| import type { Plugin } from 'esbuild'; | ||
| import { afterAll, describe, expect, test } from 'vitest'; | ||
|
|
||
| // `@sentry/node` installs its diagnostics-channel instrumentation through a runtime module hook | ||
| // that ships in `@sentry/server-utils` and only works from `node_modules`. Bundling that package | ||
| // strips its vendored code transformer, so the SDK warns that auto-instrumentation is off. | ||
| // | ||
| // Using the Sentry bundler plugin is the supported alternative: instrumentation is injected at | ||
| // build time, the runtime hook is redundant, and the SDK must stay quiet. | ||
| // | ||
| // The assertions are on "did the SDK warn", not on how it decided to, so this test does not pin | ||
| // one implementation of the check. | ||
| const OUT_DIR = join(__dirname, 'tmp_build'); | ||
|
|
||
| /** Every always-on `[Sentry]` line the SDK printed at startup. */ | ||
| function sentryWarnings(stderr: string): string[] { | ||
| return stderr.split('\n').filter(line => line.startsWith('[Sentry]')); | ||
| } | ||
|
|
||
| /** | ||
| * Keep debug-ID injection on — that is part of what the plugin normally does to a build, and it is | ||
| * what rewrites the entry point — while skipping release creation and upload, so the test needs no | ||
| * auth token and makes no network calls. | ||
| */ | ||
| function sentryPlugin(): Plugin { | ||
| return sentryEsbuildPlugin({ | ||
| telemetry: false, | ||
| release: { create: false }, | ||
| sourcemaps: { disable: 'disable-upload' }, | ||
| }) as Plugin; | ||
| } | ||
|
|
||
| function run(entry: string): { stdout: string; stderr: string; status: number | null } { | ||
| const result = spawnSync('node', [entry], { encoding: 'utf-8' }); | ||
| return { stdout: result.stdout, stderr: result.stderr, status: result.status }; | ||
| } | ||
|
|
||
| describe('esbuild + orchestrion build-time instrumentation', () => { | ||
| afterAll(() => { | ||
| rmSync(OUT_DIR, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| test('stays quiet when the Sentry esbuild plugin ran', async () => { | ||
| const outfile = join(OUT_DIR, 'single', 'app.mjs'); | ||
|
|
||
| await build({ | ||
| entryPoints: [join(__dirname, 'app.mjs')], | ||
| outfile, | ||
| platform: 'node', | ||
| format: 'esm', | ||
| bundle: true, | ||
| logLevel: 'silent', | ||
| plugins: [sentryPlugin()], | ||
| }); | ||
|
|
||
| const { stdout, stderr, status } = run(outfile); | ||
|
|
||
| expect(status).toBe(0); | ||
| // Build-time instrumentation is in place, so telling the user to set up build-time | ||
| // instrumentation would be wrong. | ||
| expect(sentryWarnings(stderr)).toEqual([]); | ||
| expect(stdout).toContain('APP_STARTED'); | ||
| }); | ||
|
|
||
| test('stays quiet when the bundle is code-split and `init()` runs from a shared chunk', async () => { | ||
| const splitDir = join(OUT_DIR, 'split'); | ||
|
|
||
| await build({ | ||
| entryPoints: [join(__dirname, 'entry-a.mjs'), join(__dirname, 'entry-b.mjs')], | ||
| outdir: splitDir, | ||
| platform: 'node', | ||
| format: 'esm', | ||
| bundle: true, | ||
| splitting: true, | ||
| logLevel: 'silent', | ||
| plugins: [sentryPlugin()], | ||
| }); | ||
|
|
||
| const { stdout, stderr, status } = run(join(splitDir, 'entry-a.js')); | ||
|
|
||
| expect(status).toBe(0); | ||
| expect(sentryWarnings(stderr)).toEqual([]); | ||
| expect(stdout).toContain('APP_STARTED'); | ||
| }); | ||
|
|
||
| // The positive control. Without it the two tests above would still pass on a build where the SDK | ||
| // never warns at all, which is the regression they are meant to catch. `dataloader` is left | ||
| // external so it loads through Node's loader, which is where a stripped transformer actually | ||
| // loses the user instrumentation. | ||
| test('warns when an external instrumented dependency loads and no plugin ran', async () => { | ||
| const outfile = join(OUT_DIR, 'no-plugin', 'app.mjs'); | ||
|
|
||
| await build({ | ||
| entryPoints: [join(__dirname, 'app-external-dep.mjs')], | ||
| outfile, | ||
| platform: 'node', | ||
| format: 'esm', | ||
| bundle: true, | ||
| external: ['dataloader'], | ||
| logLevel: 'silent', | ||
| }); | ||
|
|
||
| const { stdout, stderr, status } = run(outfile); | ||
|
|
||
| expect(status).toBe(0); | ||
| expect(stdout).toContain('DEP_LOADED'); | ||
| // The stripped transformer left `dataloader` uninstrumented — nothing recorded on `runtime`. | ||
| expect(stdout).toContain('runtime=[]'); | ||
| // Nothing instrumented `dataloader`, so the user has to be told, and the warning names the | ||
| // module that was lost. | ||
| const warning = sentryWarnings(stderr).join('\n'); | ||
| expect(warning).toContain('@sentry/server-utils'); | ||
| expect(warning).toContain('dataloader'); | ||
| // One broken transformer breaks every module, so the fix is stated exactly once. | ||
| expect(sentryWarnings(stderr)).toHaveLength(1); | ||
| }); | ||
|
|
||
| // The counterpart to the warning: it tells the user to keep `@sentry/server-utils` external, so | ||
| // that remedy has to actually work. External, the package loads from `node_modules` with its | ||
| // vendored transformer intact, instruments the (also external) `dataloader`, and stays quiet. | ||
| test('instruments the dependency and stays quiet when `@sentry/server-utils` is kept external', async () => { | ||
| const outfile = join(OUT_DIR, 'external-server-utils', 'app.mjs'); | ||
|
|
||
| await build({ | ||
| entryPoints: [join(__dirname, 'app-external-dep.mjs')], | ||
| outfile, | ||
| platform: 'node', | ||
| format: 'esm', | ||
| bundle: true, | ||
| external: ['dataloader', '@sentry/server-utils'], | ||
| logLevel: 'silent', | ||
| }); | ||
|
|
||
| const { stdout, stderr, status } = run(outfile); | ||
|
|
||
| expect(status).toBe(0); | ||
| // The runtime hook transformed `dataloader`, so it is recorded and there is nothing to warn about. | ||
| expect(stdout).toContain('runtime=["dataloader"]'); | ||
| expect(sentryWarnings(stderr)).toEqual([]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core'; | ||
| import { consoleSandbox, debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core'; | ||
| import * as Module from 'node:module'; | ||
| import { pathToFileURL } from 'node:url'; | ||
| import { SENTRY_INSTRUMENTATIONS } from '../config'; | ||
|
|
@@ -12,6 +12,9 @@ type NodeModule = { | |
| register?: typeof register; | ||
| }; | ||
|
|
||
| // Surfaced in the always-on warnings below so users can find the fix. | ||
| const BUNDLING_DOCS_URL = 'https://docs.sentry.io/platforms/javascript/guides/node/troubleshooting/'; | ||
|
|
||
| /** `Module.registerHooks` only became stable in Node 24.13 / 25.1. */ | ||
| function hasStableSyncModuleHooks(isDeno: boolean): boolean { | ||
| // The minimum supported Deno (2.8.3) always has stable sync module hooks. | ||
|
|
@@ -23,6 +26,48 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean { | |
| return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13); | ||
| } | ||
|
|
||
| /** | ||
| * Emit a single, always-on warning that runtime channel injection is disabled, with the actionable | ||
| * fix. Unlike `debug.warn` (gated behind `debug: true`), this reaches every user — otherwise the | ||
| * SDK silently records no channel-based spans. | ||
| */ | ||
| function warnRuntimeUnavailable(message: string): void { | ||
| consoleSandbox(() => { | ||
| // oxlint-disable-next-line no-console | ||
| console.warn(`[Sentry] ${message} See ${BUNDLING_DOCS_URL}`); | ||
| }); | ||
| } | ||
|
|
||
| // One broken transformer breaks every module, so state the fix once. | ||
| let warnedTransformerUnavailable = false; | ||
|
|
||
| /** | ||
| * Warn that the vendored code transformer could not run, so `moduleName` loaded uninstrumented. | ||
| * | ||
| * This package ships the transformer (meriyah/astring/source-map) inline and is meant to run from | ||
| * `node_modules`. A bundler that inlines and tree-shakes `@sentry/server-utils` strips it, so every | ||
| * transform throws `TypeError: parse is not a function` — swallowed inside the loader, once per | ||
| * module, visible only with `debug: true`. | ||
| * | ||
| * Warning from here rather than probing the transformer at `init()` keeps the check honest. A | ||
| * module only reaches this callback by coming through Node's loader, which means the build-time | ||
| * bundler plugin did not cover it, which means the instrumentation really is lost. Probing at | ||
| * `init()` instead has to guess at that from a global the plugin's entry banner may not have | ||
| * written yet. | ||
| */ | ||
| function warnTransformerUnavailable(moduleName: string): void { | ||
| if (warnedTransformerUnavailable) { | ||
| return; | ||
| } | ||
| warnedTransformerUnavailable = true; | ||
|
|
||
| warnRuntimeUnavailable( | ||
| `\`@sentry/server-utils\` was bundled into your application, so ${moduleName} and any other ` + | ||
| 'instrumented dependency load uninstrumented. Keep `@sentry/server-utils` external in your ' + | ||
| 'server bundle, or use the Sentry bundler plugin for build-time instrumentation.', | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * Synchronously register the diagnostics-channel injection module hooks. | ||
| * | ||
|
|
@@ -36,7 +81,10 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean { | |
| * the channel-based integrations subscribe to. | ||
| */ | ||
| export function registerDiagnosticsChannelInjection(): void { | ||
| if (GLOBAL_OBJ?.__SENTRY_ORCHESTRION__?.runtime) { | ||
| const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {}); | ||
|
|
||
| // Already hooked, or we already ran and found runtime injection unavailable (and warned once). | ||
| if (marker.runtime || marker.runtimeUnavailable) { | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -49,6 +97,12 @@ export function registerDiagnosticsChannelInjection(): void { | |
|
|
||
| setDiagnosticsHook(({ moduleName, error }): void => { | ||
| if (error) { | ||
| // A stripped transformer surfaces as a `TypeError` (`parse`/`generate` are `undefined`) and | ||
| // costs the user this module's instrumentation, so it is worth an always-on warning. Every | ||
| // other transform failure stays debug-only. | ||
| if (error instanceof TypeError) { | ||
| warnTransformerUnavailable(moduleName); | ||
| } | ||
| debug.warn(`[instrumentation] failed to inject diagnostics-channel into ${moduleName}:`, error); | ||
| } else { | ||
| GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; | ||
|
|
@@ -64,8 +118,7 @@ export function registerDiagnosticsChannelInjection(): void { | |
| // runs both at `--import` time and (synchronously) inside `Sentry.init()`, | ||
| // so an unguarded throw would either abort startup or make `init()` throw. | ||
| // On any failure (e.g. dep resolution, `require(esm)` / Node-compat | ||
| // incompatibility) we warn (DEBUG only) and continue without channel | ||
| // injection | ||
| // incompatibility) we warn and continue without channel injection. | ||
| try { | ||
| if (typeof mod.registerHooks === 'function' && stableSyncHooks) { | ||
| initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); | ||
|
|
@@ -102,17 +155,18 @@ export function registerDiagnosticsChannelInjection(): void { | |
| new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); | ||
| debug.log('Registered diagnostics-channel injection via Module.register()'); | ||
| } else { | ||
| marker.runtimeUnavailable = true; | ||
| debug.warn('No available Node API to register diagnostics-channel injection hooks; skipping.'); | ||
| return; | ||
| } | ||
| } catch (error) { | ||
| debug.warn( | ||
| 'Failed to register diagnostics-channel injection hooks; channel-based integrations will not record spans.', | ||
| error, | ||
|
Comment on lines
-109
to
-111
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was a
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this should be a proper console warn, this means nothing will work really so users should know 😅 |
||
| marker.runtimeUnavailable = true; | ||
| warnRuntimeUnavailable( | ||
| 'Failed to register diagnostics-channel injection hooks, so channel-based integrations will not record spans.', | ||
| ); | ||
| debug.warn('Diagnostics-channel injection registration error:', error); | ||
| return; | ||
| } | ||
|
|
||
| GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {}; | ||
| GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || []; | ||
| marker.runtime = marker.runtime || []; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should this also be added to the Nitro SDK readme?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i'd look into a follow up here overall to try to fix this in nitro, if possible!