diff --git a/.size-limit.js b/.size-limit.js index dfdbd49bfbe9..6ad5eb831484 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -406,7 +406,7 @@ module.exports = [ import: createImport('init'), ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, - limit: '123 KB', + limit: '127 KB', disablePlugins: ['@size-limit/esbuild'], }, { diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 78f31d194911..ca88dd225db6 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -77,6 +77,14 @@ export type InternalGlobal = { * `init()` and instantiates them. */ integrations?: Map Integration>; + /** + * Set once `registerDiagnosticsChannelInjection()` has run but could not + * install the runtime module hooks — most commonly because + * `@sentry/server-utils` was bundled into the app (which strips its vendored + * code transformer) or the Node runtime lacks the required module-hook API. + * Dedupes the one-time warning and short-circuits repeat calls. + */ + runtimeUnavailable?: boolean; }; } & Carrier; diff --git a/packages/node/README.md b/packages/node/README.md index 6471538fb4f0..a2a32c69ab9f 100644 --- a/packages/node/README.md +++ b/packages/node/README.md @@ -72,6 +72,25 @@ If it is not possible for you to pass the `--import` flag to the Node.js binary, NODE_OPTIONS="--import ./instrument.mjs" npm run start ``` +### Bundling your server + +`@sentry/node` installs its automatic (diagnostics-channel) instrumentation through a runtime module +hook that ships in `@sentry/server-utils` and is designed to run from `node_modules`. There are two +supported ways to keep auto-instrumentation working when you bundle your server: + +1. **Keep `@sentry/server-utils` external** (do not inline it into the bundle) so the runtime hook + loads from `node_modules`. Most bundlers externalize `node_modules` for a Node target by default; + if yours inlines everything, mark `@sentry/server-utils` as external explicitly. +2. **Instrument at build time** with the Sentry bundler plugins (`@sentry/node/esbuild`, + `@sentry/node/webpack`, `@sentry/node/vite`, `@sentry/node/rollup`), which inject the + instrumentation into your bundled dependencies during the build. In this mode the runtime hook is + not needed. + +If you bundle `@sentry/server-utils` **and** don't use the build-time plugin, its internal code +transformer is stripped and runtime auto-instrumentation is disabled — `@sentry/node` warns at +startup when it detects this. (When the build-time plugin is used, there is no warning, since +instrumentation is already in place.) + ## Links - [Official SDK Docs](https://docs.sentry.io/quickstart/) diff --git a/packages/nuxt/README.md b/packages/nuxt/README.md index b7978c288ffd..13fe27528588 100644 --- a/packages/nuxt/README.md +++ b/packages/nuxt/README.md @@ -28,4 +28,9 @@ functionality related to Nuxt. ## Troubleshoot +If your server-side auto-instrumentation stops recording spans after bundling (e.g. certain Nitro +presets), make sure `@sentry/server-utils` is kept **external** in the Nitro/server build rather than +inlined — its runtime module hook must resolve from `node_modules`. `@sentry/node` logs a warning at +startup when it detects it was bundled. + 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. diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index d95d222c5f3c..51360b990abf 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -99,6 +99,7 @@ "@sentry/core": "10.67.0" }, "devDependencies": { + "@apm-js-collab/code-transformer": "^0.18.1", "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.4", "@apm-js-collab/tracing-hooks": "^0.13.0", "@types/node": "^18.19.1", diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts index 10865145b605..340bdaf066d4 100644 --- a/packages/server-utils/src/orchestrion/runtime/register.ts +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -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'; 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; + } catch (error) { + // 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) { + 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.', + ); + } 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, + 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 || []; } diff --git a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts index 581d9cf552fa..85ed0e6e11b6 100644 --- a/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts +++ b/packages/server-utils/test/orchestrion/moduleInjectedTransform.test.ts @@ -3,6 +3,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import * as barrel from '../../src/index'; +import { SENTRY_INSTRUMENTATIONS } from '../../src/orchestrion/config'; import { CHANNEL_INTEGRATION_DEFINITIONS, subscriberExportForModule, @@ -28,17 +30,15 @@ describe('channel integration definitions', () => { expect(subscriberExportForModule('not-a-package')).toBeUndefined(); }); - it('references only real named exports of @sentry/server-utils', async () => { + it('references only real named exports of @sentry/server-utils', () => { // The injected snippet imports each factory from `@sentry/server-utils` // (the `DEFAULT_IMPORT_SPECIFIER`), so the export must exist on that entry. - const barrel = await import('../../src/index'); for (const { exportName } of CHANNEL_INTEGRATION_DEFINITIONS) { expect(typeof (barrel as Record)[exportName]).toBe('function'); } }); - it('covers every instrumented module that has a channel-subscriber integration', async () => { - const { SENTRY_INSTRUMENTATIONS } = await import('../../src/orchestrion/config'); + it('covers every instrumented module that has a channel-subscriber integration', () => { const configured = new Set(SENTRY_INSTRUMENTATIONS.map(c => c.module.name)); const defined = new Set(CHANNEL_INTEGRATION_DEFINITIONS.flatMap(d => d.modules as readonly string[])); diff --git a/packages/server-utils/test/orchestrion/register.test.ts b/packages/server-utils/test/orchestrion/register.test.ts new file mode 100644 index 000000000000..b21e0227d9ae --- /dev/null +++ b/packages/server-utils/test/orchestrion/register.test.ts @@ -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(); + 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; + + 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); + }); +});