-
-
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
1797686
77b5375
34084c7
14c8a99
a3e6fe2
f94b60f
4140c0a
e1f15a7
da0de0d
7bd9c46
2d039c7
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 |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| 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 { create } from '@apm-js-collab/code-transformer'; | ||
|
mydea marked this conversation as resolved.
|
||
| import { SENTRY_INSTRUMENTATIONS } from '../config'; | ||
| import type { register } from 'node:module'; | ||
| import ModulePatch from '@apm-js-collab/tracing-hooks'; | ||
|
|
@@ -12,6 +13,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 +27,53 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean { | |
| return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13); | ||
| } | ||
|
|
||
| /** | ||
| * Detect whether the vendored code-transformer chain (meriyah/astring/source-map, bundled into this | ||
| * package) survived downstream bundling. | ||
| * | ||
| * This package ships the transformer inline and is meant to run from `node_modules` (external). When | ||
| * an app bundler instead inlines `@sentry/server-utils` and tree-shakes it, those vendored deps are | ||
| * stripped to empty objects, so `parse`/`generate` become `undefined` and the FIRST module the hook | ||
| * tries to transform throws `TypeError: parse is not a function` — deep in the loader, once per | ||
| * module, only visible with `debug: true`. Running one throwaway in-memory transform up front turns | ||
| * that into a single, actionable, always-on warning (see `warnRuntimeUnavailable`). A healthy build | ||
| * returns normally; a tree-shaken one throws a `TypeError`. | ||
| */ | ||
| function isTransformerTreeShaken(): boolean { | ||
| try { | ||
| create( | ||
| [ | ||
| { | ||
| channelName: 'probe', | ||
| module: { name: '@sentry/orchestrion-probe', versionRange: '*', filePath: 'probe.js' }, | ||
| functionQuery: { className: 'C', methodName: 'm', kind: 'Async' }, | ||
| }, | ||
| ], | ||
| 'node:diagnostics_channel', | ||
| ) | ||
| .getTransformer('@sentry/orchestrion-probe', '0.0.0', 'probe.js') | ||
| ?.transform('class C { async m(x) { return x; } }', 'esm'); | ||
| return false; | ||
|
Comment on lines
+54
to
+56
Contributor
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. Bug: The tree-shaking detection can fail silently. If Suggested FixRemove the optional chaining from the Prompt for AI Agent |
||
| } catch (error) { | ||
|
sentry[bot] marked this conversation as resolved.
|
||
| // Tree-shaken: `parse`/`generate`/`create` are `undefined` → TypeError. A healthy build either | ||
| // succeeds or throws a domain `Error` (e.g. "Failed to find injection points"), never a TypeError. | ||
| return error instanceof TypeError; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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. Deduped via a a global marker (carrier.runtimeAvailable) | ||
| * so repeat calls (e.g. `init()` plus `--import`) warn at most once. | ||
| */ | ||
| function warnRuntimeUnavailable(message: string): void { | ||
| consoleSandbox(() => { | ||
| // oxlint-disable-next-line no-console | ||
| console.warn(`[Sentry] ${message} See ${BUNDLING_DOCS_URL}`); | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Synchronously register the diagnostics-channel injection module hooks. | ||
| * | ||
|
|
@@ -36,7 +87,34 @@ 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; | ||
| } | ||
|
|
||
| // A downstream bundler that inlined + tree-shook this package strips the vendored transformer, so | ||
| // every runtime transform would throw a cryptic `TypeError` deep in the loader. Detect that once | ||
| // and don't install hooks that can't work. | ||
| if (isTransformerTreeShaken()) { | ||
| marker.runtimeUnavailable = true; | ||
| // If the build-time bundler plugin ran (a defined `bundler` marker Set, set by its entry banner), | ||
| // instrumentation was already injected at build time and the runtime hook is redundant — this is | ||
| // an expected, supported setup, so stay quiet (debug-only). Otherwise nothing is instrumented, so | ||
| // surface an always-on, actionable warning. | ||
| if (marker.bundler instanceof Set) { | ||
|
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. We already have some
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. hmm I think most other places I've seen (or found at least) are more type-guarding this which seems a bit different of a use case then this?
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. 👀
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. There's an interesting issue here that'll lead to false alarms, because the esbuild can fail to add the banner, and then print a warning telling the user to add the bundler plugin they're already using. The root cause is that the upstream This is a pre-existing gap, but as of this PR, we're depending on it for the user-visible alarm, so it becomes a tangible problem that we should fix, imo. It wasn't a problem before, because nothing read the banner, we always did stuff like We have some tests in node-integration-tests that could've caught this, but didn't check for this situation specifically. Applying this patch adds a test that reproduces the issue: https://gist.github.com/isaacs/0f0633e3f8af0b078a973448abcf0955 Also, ESM import hoisting will effectively put the banner after the call to ;(function(){…g.bundler=g.bundler||new Set();})();import "./chunk-cafebad0.js";We can address both of these, though, by removing the probe, and then moving the check out of this function and down into the That callback fires when a module actually fails to transform, by which point every banner has run, so the ordering problem disappears. It also drops the init cost, and can name the failing module. The one behavior change is that there's no warning if the transformer is stripped but no instrumented module ever loads. But I think that's the correct outcome, since nothing was lost in that case. These two patches would implement the test and suggested fix: https://gist.github.com/isaacs/8f58c5e1f9bac3479be2e9f2c6ee578d or pull the top two commits from |
||
| debug.log( | ||
| 'Runtime diagnostics-channel injection is disabled because `@sentry/server-utils` was bundled; ' + | ||
| 'build-time instrumentation is active.', | ||
| ); | ||
| } else { | ||
| warnRuntimeUnavailable( | ||
| '`@sentry/server-utils` was bundled into your application, so diagnostics-channel ' + | ||
| 'auto-instrumentation is disabled. Keep `@sentry/server-utils` external in your server bundle, ' + | ||
| 'or use the Sentry bundler plugin for build-time instrumentation.', | ||
| ); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| return; | ||
| } | ||
|
|
||
|
|
@@ -102,17 +180,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 || []; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| import type * as SentryCore from '@sentry/core'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
|
|
||
| // Simulate the vendored code-transformer chain. A tree-shaken build (this package bundled into an | ||
| // app and stripped) throws a `TypeError` from `create(...).getTransformer(...).transform(...)`; a | ||
| // healthy build does not. See `isTransformerTreeShaken` in `runtime/register.ts`. | ||
| const createMock = vi.fn(); | ||
| vi.mock('@apm-js-collab/code-transformer', () => ({ | ||
| create: (...args: unknown[]) => createMock(...args), | ||
| })); | ||
|
|
||
| // Neutralise `consoleSandbox` (it swaps in the pristine console during its callback, which would | ||
| // bypass a spy) so we can assert the always-on warning directly. | ||
| vi.mock('@sentry/core', async importOriginal => { | ||
| const actual = await importOriginal<typeof SentryCore>(); | ||
| return { ...actual, consoleSandbox: (cb: () => unknown) => cb() }; | ||
| }); | ||
|
|
||
| import { GLOBAL_OBJ } from '@sentry/core'; | ||
| import { registerDiagnosticsChannelInjection } from '../../src/orchestrion/runtime/register'; | ||
|
|
||
| describe('registerDiagnosticsChannelInjection - bundled/tree-shaken detection', () => { | ||
| let warnSpy: ReturnType<typeof vi.spyOn>; | ||
|
|
||
| beforeEach(() => { | ||
| delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; | ||
| createMock.mockReset(); | ||
| warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__; | ||
| warnSpy.mockRestore(); | ||
| }); | ||
|
|
||
| it('warns once and disables runtime injection when the transformer was tree-shaken', () => { | ||
| // A tree-shaken chain: `parse`/`generate` are `undefined`, so a transform throws a TypeError. | ||
| createMock.mockImplementation(() => { | ||
| throw new TypeError('parse is not a function'); | ||
| }); | ||
|
|
||
| registerDiagnosticsChannelInjection(); | ||
|
|
||
| expect(warnSpy).toHaveBeenCalledTimes(1); | ||
| expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('was bundled into your application')); | ||
| expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('docs.sentry.io')); | ||
| // Marked unavailable, and NOT marked as runtime-hooked (hooks were never installed). | ||
| expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtimeUnavailable).toBe(true); | ||
| expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtime).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('does not warn when build-time instrumentation is active (bundler marker present)', () => { | ||
| createMock.mockImplementation(() => { | ||
| throw new TypeError('parse is not a function'); | ||
| }); | ||
| // A defined `bundler` Set signals the build-time plugin ran, so the runtime hook is redundant. | ||
| GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: new Set() }; | ||
|
|
||
| registerDiagnosticsChannelInjection(); | ||
|
|
||
| // No user-facing warning — this is an expected, supported setup. | ||
| expect(warnSpy).not.toHaveBeenCalled(); | ||
| expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtimeUnavailable).toBe(true); | ||
| expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtime).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('does not warn again on subsequent calls (deduped)', () => { | ||
| createMock.mockImplementation(() => { | ||
| throw new TypeError('parse is not a function'); | ||
| }); | ||
|
|
||
| registerDiagnosticsChannelInjection(); | ||
| registerDiagnosticsChannelInjection(); | ||
| registerDiagnosticsChannelInjection(); | ||
|
|
||
| expect(warnSpy).toHaveBeenCalledTimes(1); | ||
| // The probe runs only on the first call; the marker short-circuits the rest. | ||
| expect(createMock).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); |
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!