Skip to content
Merged
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([]);
});
});
7 changes: 7 additions & 0 deletions packages/core/src/utils/worldwide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ export type InternalGlobal = {
* `init()` and instantiates them.
*/
integrations?: Map<string, () => Integration>;
/**
* Set once `registerDiagnosticsChannelInjection()` has run but could not
* install the runtime module hooks — the Node runtime lacks the required
* module-hook API, or registration threw. Dedupes the one-time warning and
* short-circuits repeat calls.
*/
runtimeUnavailable?: boolean;
};
} & Carrier;

Expand Down
19 changes: 19 additions & 0 deletions packages/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 the
first time an instrumented dependency loads uninstrumented, so a build-time-instrumented app never
sees the warning.

## Links

- [Official SDK Docs](https://docs.sentry.io/quickstart/)
5 changes: 5 additions & 0 deletions packages/nuxt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@ functionality related to Nuxt.

## Troubleshoot

If your server-side auto-instrumentation stops recording spans after bundling (e.g. certain Nitro

Copy link
Copy Markdown
Member

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?

Copy link
Copy Markdown
Member Author

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!

presets), make sure `@sentry/server-utils` is kept **external** in the Nitro/server build rather than
inlined, as its runtime module hook must resolve from `node_modules`. `@sentry/node` logs a warning
the first time an instrumented dependency loads uninstrumented.

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.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ declare module '@apm-js-collab/tracing-hooks/lib/diagnostics.js' {

declare module '@apm-js-collab/tracing-hooks/hook-sync.mjs' {
import type { MessagePort } from 'node:worker_threads';
import type { InstrumentationConfig } from '@apm-js-collab/code-transformer';
import type { InstrumentationConfig } from '../apmTypes';

type DiagnosticsEvent = { url: string; moduleName: string; error?: Error };
type InitializeData = { instrumentations?: InstrumentationConfig[]; diagnosticsPort?: MessagePort };
Expand Down
72 changes: 63 additions & 9 deletions packages/server-utils/src/orchestrion/runtime/register.ts
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';
Expand All @@ -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.
Expand All @@ -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.
*
Expand All @@ -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;
}

Expand All @@ -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__ || {};
Expand All @@ -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 });
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a debug.warn before (gated with the debug flag). Is this on purpose, that this should now always be printed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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
Expand Up @@ -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,
Expand All @@ -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<string, unknown>)[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[]));

Expand Down
Loading
Loading