Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .size-limit.js
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '123 KB',
limit: '127 KB',
disablePlugins: ['@size-limit/esbuild'],
},
{
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/utils/worldwide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ export type InternalGlobal = {
* `init()` and instantiates them.
*/
integrations?: Map<string, () => 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;

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 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/)
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 — 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.
1 change: 1 addition & 0 deletions packages/server-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
93 changes: 86 additions & 7 deletions packages/server-utils/src/orchestrion/runtime/register.ts
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';
Comment thread
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';
Expand All @@ -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.
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: The tree-shaking detection can fail silently. If getTransformer() returns undefined, the optional chaining on .transform() will cause the check to pass incorrectly, leading to broken hooks being installed.
Severity: HIGH

Suggested Fix

Remove the optional chaining from the .transform() call. Instead, explicitly check if the value returned by getTransformer() is undefined. If it is, treat it as a failure case by throwing an error. This ensures that a non-matching probe configuration correctly signals a problem with the transformer.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: packages/server-utils/src/orchestrion/runtime/register.ts#L54-L56

Potential issue: The tree-shaking detection mechanism uses optional chaining on a
`.transform()` call. The preceding function, `getTransformer()`, can return `undefined`
if the synthetic probe configuration does not match. In this scenario, the optional
chaining (`?.`) will cause the `.transform()` call to be skipped silently, without
throwing an error. The function will then incorrectly determine that the transformer is
healthy and proceed to install broken runtime hooks. This defeats the purpose of the
check and will lead to the original `TypeError: parse is not a function` error during
module loading.

} catch (error) {
Comment thread
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.
*
Expand All @@ -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) {

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.

We already have some bundler instanceof Set guards in some files. Should we create a helper named bundlerPluginRun() or something? Then it's also more understandable what it does.

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.

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?

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.

👀
hm, yes then a method actually does not really make sense. disregard this :D

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.

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 entryOutputPaths/configuredEntryPoints in node_modules/@apm-js-collab/code-transformer-bundler-plugins/dist/esm/esbuild.mjs match metafile.outputs[].entryPoint against the configured entry point paths. @sentry/bundler-plugins/esbuild debug-id injection rewrites the entry, so eg a.mjs becomes a.mjs?sentryDebugIdProxy=true, which never matches, and the banner is skipped.

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 (g.bundler ??= new Set()).add(module), so the point where it was created didn't ever matter before.

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 Sentry.init() in a code-split build, so we'll get chunks like:

;(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 setDiagnosticsHook error callback function on line 128, which also reduces out a layer.

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 isaacs/review-23675

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.',
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
return;
}

Expand Down Expand Up @@ -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

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
80 changes: 80 additions & 0 deletions packages/server-utils/test/orchestrion/register.test.ts
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);
});
});
Loading