Skip to content

Commit 499b1ef

Browse files
committed
feat(server-utils): Warn when the orchestrion runtime hook was bundled
`@sentry/node`'s `init()` installs a runtime module-transform hook from `@sentry/server-utils/orchestrion/register`, which drives a vendored code transformer (meriyah/astring/source-map) and is designed to run from `node_modules`. If a downstream bundler inlines and tree-shakes `@sentry/server-utils`, that transformer is stripped to empty objects, so at runtime `parse`/`generate` are `undefined` and the first module the hook tries to transform throws `TypeError: parse is not a function` — deep in the loader, once per module, and only when `debug: true` (otherwise it fails silently). Detect this once, up front: run a throwaway in-memory transform over a synthetic snippet before installing any hook. A healthy build returns normally; a tree-shaken one throws a `TypeError`. On detection, emit a single, always-on, actionable warning (via `consoleSandbox`, deduped on a global marker) and skip installing hooks that can't work, instead of letting the cryptic per-module error surface. The existing registration `catch` is likewise upgraded to an always-on warning. All of this lives inside `registerDiagnosticsChannelInjection`, so it tree-shakes away with the whole block when `bundleSizeOptimizations.excludeChannelInjection` sets `__SENTRY_CHANNEL_INJECTION__` to `false`. Ref #23664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(server-utils): Keep @sentry/node external in the vite orchestrion plugin The runtime hook (reached via `@sentry/node`) must stay external so it resolves from `node_modules`; bundling it strips the transformer and breaks the `Module.register` self-reference. `@sentry/node` is a different package from the `@sentry/server-utils` barrel the plugin force-bundles (`ssr.noExternal`), so the vite plugin now also adds `@sentry/node` to `ssr.external`. Explicit `ssr.external` entries win over `noExternal`, so this holds even against a preset that sets `ssr.noExternal: true` — verified with a real vite SSR build. This covers the vite-based frameworks (SvelteKit, Astro, React Router, TanStack); the nitro/rollup frameworks (Nuxt, SolidStart) rely on the runtime warning above, with a nitro-level externalization guard as a follow-up. Ref #23664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> docs(node): Document keeping @sentry/server-utils external when bundling Add a "Bundling your server" note to the Node README (and a Nuxt troubleshoot note) explaining that the runtime instrumentation hook must stay external, and pointing to the build-time bundler-plugin instrumentation as the alternative. Ref #23664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> fix(server-utils): Don't force @sentry/node external in the vite plugin Forcing `@sentry/node` into `ssr.external` broke Cloudflare/worker builds: the shared vite orchestrion plugin also runs under `@cloudflare/vite-plugin` (and frameworks deploying to workerd), where `@sentry/node` is unused and setting `resolve.external` on a worker environment is rejected outright — and the worker environment is even named `ssr`, so there's no reliable node-vs-worker discriminator in the `config()` hook. Vite already externalizes `@sentry/node` for node SSR by default anyway, and the runtime probe in `orchestrion/register` covers the cases where it does get bundled, so drop the forced externalization. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> feat(server-utils): Stay quiet when build-time instrumentation covers a bundled hook If `@sentry/server-utils` was bundled AND the build-time bundler plugin ran (a defined `__SENTRY_ORCHESTRION__.bundler` Set), instrumentation is already injected at build time and the runtime hook is redundant — a supported setup. In that case downgrade the "bundled" message to a debug log instead of an always-on warning. The always-on warning now fires only when nothing instrumented the app (bundled and no build-time plugin). Also corrects the Node README: bundling doesn't disable auto-instrumentation when the build-time plugin is used. Ref #23664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> better comment just use console better comment small fixes bump size limit fix test
1 parent 604f142 commit 499b1ef

7 files changed

Lines changed: 203 additions & 11 deletions

File tree

‎packages/core/src/utils/worldwide.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ export type InternalGlobal = {
7777
* `init()` and instantiates them.
7878
*/
7979
integrations?: Map<string, () => Integration>;
80+
/**
81+
* Set once `registerDiagnosticsChannelInjection()` has run but could not
82+
* install the runtime module hooks — most commonly because
83+
* `@sentry/server-utils` was bundled into the app (which strips its vendored
84+
* code transformer) or the Node runtime lacks the required module-hook API.
85+
* Dedupes the one-time warning and short-circuits repeat calls.
86+
*/
87+
runtimeUnavailable?: boolean;
8088
};
8189
} & Carrier;
8290

‎packages/node/README.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,25 @@ If it is not possible for you to pass the `--import` flag to the Node.js binary,
7272
NODE_OPTIONS="--import ./instrument.mjs" npm run start
7373
```
7474

75+
### Bundling your server
76+
77+
`@sentry/node` installs its automatic (diagnostics-channel) instrumentation through a runtime module
78+
hook that ships in `@sentry/server-utils` and is designed to run from `node_modules`. There are two
79+
supported ways to keep auto-instrumentation working when you bundle your server:
80+
81+
1. **Keep `@sentry/server-utils` external** (do not inline it into the bundle) so the runtime hook
82+
loads from `node_modules`. Most bundlers externalize `node_modules` for a Node target by default;
83+
if yours inlines everything, mark `@sentry/server-utils` as external explicitly.
84+
2. **Instrument at build time** with the Sentry bundler plugins (`@sentry/node/esbuild`,
85+
`@sentry/node/webpack`, `@sentry/node/vite`, `@sentry/node/rollup`), which inject the
86+
instrumentation into your bundled dependencies during the build. In this mode the runtime hook is
87+
not needed.
88+
89+
If you bundle `@sentry/server-utils` **and** don't use the build-time plugin, its internal code
90+
transformer is stripped and runtime auto-instrumentation is disabled — `@sentry/node` warns at
91+
startup when it detects this. (When the build-time plugin is used, there is no warning, since
92+
instrumentation is already in place.)
93+
7594
## Links
7695

7796
- [Official SDK Docs](https://docs.sentry.io/quickstart/)

‎packages/nuxt/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,9 @@ functionality related to Nuxt.
2828

2929
## Troubleshoot
3030

31+
If your server-side auto-instrumentation stops recording spans after bundling (e.g. certain Nitro
32+
presets), make sure `@sentry/server-utils` is kept **external** in the Nitro/server build rather than
33+
inlined — its runtime module hook must resolve from `node_modules`. `@sentry/node` logs a warning at
34+
startup when it detects it was bundled.
35+
3136
If you encounter any issues with error tracking or integrations, refer to the official [Sentry Nuxt SDK documentation](https://docs.sentry.io/platforms/javascript/guides/nuxt/). If the documentation does not provide the necessary information, consider opening an issue on GitHub.

‎packages/server-utils/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@
9999
"@sentry/core": "10.67.0"
100100
},
101101
"devDependencies": {
102+
"@apm-js-collab/code-transformer": "^0.18.1",
102103
"@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4",
103104
"@apm-js-collab/tracing-hooks": "^0.13.0",
104105
"@types/node": "^18.19.1",

‎packages/server-utils/src/orchestrion/runtime/register.ts‎

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core';
1+
import { consoleSandbox, debug, getClient, GLOBAL_OBJ, parseSemver } from '@sentry/core';
22
import * as Module from 'node:module';
33
import { pathToFileURL } from 'node:url';
4+
import { create } from '@apm-js-collab/code-transformer';
45
import { SENTRY_INSTRUMENTATIONS } from '../config';
56
import type { register } from 'node:module';
67
import ModulePatch from '@apm-js-collab/tracing-hooks';
@@ -12,6 +13,9 @@ type NodeModule = {
1213
register?: typeof register;
1314
};
1415

16+
// Surfaced in the always-on warnings below so users can find the fix.
17+
const BUNDLING_DOCS_URL = 'https://docs.sentry.io/platforms/javascript/guides/node/troubleshooting/';
18+
1519
/** `Module.registerHooks` only became stable in Node 24.13 / 25.1. */
1620
function hasStableSyncModuleHooks(isDeno: boolean): boolean {
1721
// The minimum supported Deno (2.8.3) always has stable sync module hooks.
@@ -23,6 +27,53 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean {
2327
return major > 25 || (major === 25 && minor >= 1) || (major === 24 && minor >= 13);
2428
}
2529

30+
/**
31+
* Detect whether the vendored code-transformer chain (meriyah/astring/source-map, bundled into this
32+
* package) survived downstream bundling.
33+
*
34+
* This package ships the transformer inline and is meant to run from `node_modules` (external). When
35+
* an app bundler instead inlines `@sentry/server-utils` and tree-shakes it, those vendored deps are
36+
* stripped to empty objects, so `parse`/`generate` become `undefined` and the FIRST module the hook
37+
* tries to transform throws `TypeError: parse is not a function` — deep in the loader, once per
38+
* module, only visible with `debug: true`. Running one throwaway in-memory transform up front turns
39+
* that into a single, actionable, always-on warning (see `warnRuntimeUnavailable`). A healthy build
40+
* returns normally; a tree-shaken one throws a `TypeError`.
41+
*/
42+
function isTransformerTreeShaken(): boolean {
43+
try {
44+
create(
45+
[
46+
{
47+
channelName: 'probe',
48+
module: { name: '@sentry/orchestrion-probe', versionRange: '*', filePath: 'probe.js' },
49+
functionQuery: { className: 'C', methodName: 'm', kind: 'Async' },
50+
},
51+
],
52+
'node:diagnostics_channel',
53+
)
54+
.getTransformer('@sentry/orchestrion-probe', '0.0.0', 'probe.js')
55+
?.transform('class C { async m(x) { return x; } }', 'esm');
56+
return false;
57+
} catch (error) {
58+
// Tree-shaken: `parse`/`generate`/`create` are `undefined` → TypeError. A healthy build either
59+
// succeeds or throws a domain `Error` (e.g. "Failed to find injection points"), never a TypeError.
60+
return error instanceof TypeError;
61+
}
62+
}
63+
64+
/**
65+
* Emit a single, always-on warning that runtime channel injection is disabled, with the actionable
66+
* fix. Unlike `debug.warn` (gated behind `debug: true`), this reaches every user — otherwise the
67+
* SDK silently records no channel-based spans. Deduped via a a global marker (carrier.runtimeAvailable)
68+
* so repeat calls (e.g. `init()` plus `--import`) warn at most once.
69+
*/
70+
function warnRuntimeUnavailable(message: string): void {
71+
consoleSandbox(() => {
72+
// oxlint-disable-next-line no-console
73+
console.warn(`[Sentry] ${message} See ${BUNDLING_DOCS_URL}`);
74+
});
75+
}
76+
2677
/**
2778
* Synchronously register the diagnostics-channel injection module hooks.
2879
*
@@ -36,7 +87,34 @@ function hasStableSyncModuleHooks(isDeno: boolean): boolean {
3687
* the channel-based integrations subscribe to.
3788
*/
3889
export function registerDiagnosticsChannelInjection(): void {
39-
if (GLOBAL_OBJ?.__SENTRY_ORCHESTRION__?.runtime) {
90+
const marker = (GLOBAL_OBJ.__SENTRY_ORCHESTRION__ ??= {});
91+
92+
// Already hooked, or we already ran and found runtime injection unavailable (and warned once).
93+
if (marker.runtime || marker.runtimeUnavailable) {
94+
return;
95+
}
96+
97+
// A downstream bundler that inlined + tree-shook this package strips the vendored transformer, so
98+
// every runtime transform would throw a cryptic `TypeError` deep in the loader. Detect that once
99+
// and don't install hooks that can't work.
100+
if (isTransformerTreeShaken()) {
101+
marker.runtimeUnavailable = true;
102+
// If the build-time bundler plugin ran (a defined `bundler` marker Set, set by its entry banner),
103+
// instrumentation was already injected at build time and the runtime hook is redundant — this is
104+
// an expected, supported setup, so stay quiet (debug-only). Otherwise nothing is instrumented, so
105+
// surface an always-on, actionable warning.
106+
if (marker.bundler instanceof Set) {
107+
debug.log(
108+
'Runtime diagnostics-channel injection is disabled because `@sentry/server-utils` was bundled; ' +
109+
'build-time instrumentation is active.',
110+
);
111+
} else {
112+
warnRuntimeUnavailable(
113+
'`@sentry/server-utils` was bundled into your application, so diagnostics-channel ' +
114+
'auto-instrumentation is disabled. Keep `@sentry/server-utils` external in your server bundle, ' +
115+
'or use the Sentry bundler plugin for build-time instrumentation.',
116+
);
117+
}
40118
return;
41119
}
42120

@@ -102,17 +180,18 @@ export function registerDiagnosticsChannelInjection(): void {
102180
new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch();
103181
debug.log('Registered diagnostics-channel injection via Module.register()');
104182
} else {
183+
marker.runtimeUnavailable = true;
105184
debug.warn('No available Node API to register diagnostics-channel injection hooks; skipping.');
106185
return;
107186
}
108187
} catch (error) {
109-
debug.warn(
110-
'Failed to register diagnostics-channel injection hooks; channel-based integrations will not record spans.',
111-
error,
188+
marker.runtimeUnavailable = true;
189+
warnRuntimeUnavailable(
190+
'Failed to register diagnostics-channel injection hooks, so channel-based integrations will not record spans.',
112191
);
192+
debug.warn('Diagnostics-channel injection registration error:', error);
113193
return;
114194
}
115195

116-
GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = GLOBAL_OBJ.__SENTRY_ORCHESTRION__ || {};
117-
GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime = GLOBAL_OBJ.__SENTRY_ORCHESTRION__.runtime || [];
196+
marker.runtime = marker.runtime || [];
118197
}

‎packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
55
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
6+
import * as barrel from '../../src/index';
7+
import { SENTRY_INSTRUMENTATIONS } from '../../src/orchestrion/config';
68
import {
79
CHANNEL_INTEGRATION_DEFINITIONS,
810
subscriberExportForModule,
@@ -28,17 +30,15 @@ describe('channel integration definitions', () => {
2830
expect(subscriberExportForModule('not-a-package')).toBeUndefined();
2931
});
3032

31-
it('references only real named exports of @sentry/server-utils', async () => {
33+
it('references only real named exports of @sentry/server-utils', () => {
3234
// The injected snippet imports each factory from `@sentry/server-utils`
3335
// (the `DEFAULT_IMPORT_SPECIFIER`), so the export must exist on that entry.
34-
const barrel = await import('../../src/index');
3536
for (const { exportName } of CHANNEL_INTEGRATION_DEFINITIONS) {
3637
expect(typeof (barrel as Record<string, unknown>)[exportName]).toBe('function');
3738
}
3839
});
3940

40-
it('covers every instrumented module that has a channel-subscriber integration', async () => {
41-
const { SENTRY_INSTRUMENTATIONS } = await import('../../src/orchestrion/config');
41+
it('covers every instrumented module that has a channel-subscriber integration', () => {
4242
const configured = new Set(SENTRY_INSTRUMENTATIONS.map(c => c.module.name));
4343
const defined = new Set(CHANNEL_INTEGRATION_DEFINITIONS.flatMap(d => d.modules as readonly string[]));
4444

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type * as SentryCore from '@sentry/core';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
// Simulate the vendored code-transformer chain. A tree-shaken build (this package bundled into an
5+
// app and stripped) throws a `TypeError` from `create(...).getTransformer(...).transform(...)`; a
6+
// healthy build does not. See `isTransformerTreeShaken` in `runtime/register.ts`.
7+
const createMock = vi.fn();
8+
vi.mock('@apm-js-collab/code-transformer', () => ({
9+
create: (...args: unknown[]) => createMock(...args),
10+
}));
11+
12+
// Neutralise `consoleSandbox` (it swaps in the pristine console during its callback, which would
13+
// bypass a spy) so we can assert the always-on warning directly.
14+
vi.mock('@sentry/core', async importOriginal => {
15+
const actual = await importOriginal<typeof SentryCore>();
16+
return { ...actual, consoleSandbox: (cb: () => unknown) => cb() };
17+
});
18+
19+
import { GLOBAL_OBJ } from '@sentry/core';
20+
import { registerDiagnosticsChannelInjection } from '../../src/orchestrion/runtime/register';
21+
22+
describe('registerDiagnosticsChannelInjection - bundled/tree-shaken detection', () => {
23+
let warnSpy: ReturnType<typeof vi.spyOn>;
24+
25+
beforeEach(() => {
26+
delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__;
27+
createMock.mockReset();
28+
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
29+
});
30+
31+
afterEach(() => {
32+
delete GLOBAL_OBJ.__SENTRY_ORCHESTRION__;
33+
warnSpy.mockRestore();
34+
});
35+
36+
it('warns once and disables runtime injection when the transformer was tree-shaken', () => {
37+
// A tree-shaken chain: `parse`/`generate` are `undefined`, so a transform throws a TypeError.
38+
createMock.mockImplementation(() => {
39+
throw new TypeError('parse is not a function');
40+
});
41+
42+
registerDiagnosticsChannelInjection();
43+
44+
expect(warnSpy).toHaveBeenCalledTimes(1);
45+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('was bundled into your application'));
46+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('docs.sentry.io'));
47+
// Marked unavailable, and NOT marked as runtime-hooked (hooks were never installed).
48+
expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtimeUnavailable).toBe(true);
49+
expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtime).toBeUndefined();
50+
});
51+
52+
it('does not warn when build-time instrumentation is active (bundler marker present)', () => {
53+
createMock.mockImplementation(() => {
54+
throw new TypeError('parse is not a function');
55+
});
56+
// A defined `bundler` Set signals the build-time plugin ran, so the runtime hook is redundant.
57+
GLOBAL_OBJ.__SENTRY_ORCHESTRION__ = { bundler: new Set() };
58+
59+
registerDiagnosticsChannelInjection();
60+
61+
// No user-facing warning — this is an expected, supported setup.
62+
expect(warnSpy).not.toHaveBeenCalled();
63+
expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtimeUnavailable).toBe(true);
64+
expect(GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.runtime).toBeUndefined();
65+
});
66+
67+
it('does not warn again on subsequent calls (deduped)', () => {
68+
createMock.mockImplementation(() => {
69+
throw new TypeError('parse is not a function');
70+
});
71+
72+
registerDiagnosticsChannelInjection();
73+
registerDiagnosticsChannelInjection();
74+
registerDiagnosticsChannelInjection();
75+
76+
expect(warnSpy).toHaveBeenCalledTimes(1);
77+
// The probe runs only on the first call; the marker short-circuits the rest.
78+
expect(createMock).toHaveBeenCalledTimes(1);
79+
});
80+
});

0 commit comments

Comments
 (0)