Skip to content
Closed
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
6 changes: 3 additions & 3 deletions .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: '180 KB',
disablePlugins: ['@size-limit/esbuild'],
},
{
Expand All @@ -430,7 +430,7 @@ module.exports = [
path: 'packages/node/build/esm/index.js',
import: createImport('initWithoutDefaultIntegrations', 'getDefaultIntegrationsWithoutPerformance'),
gzip: true,
limit: '92 KB',
limit: '145 KB',
disablePlugins: ['@size-limit/esbuild'],
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
modifyWebpackConfig: function (config) {
Expand Down Expand Up @@ -473,7 +473,7 @@ module.exports = [
import: createImport('init'),
ignore: [...builtinModules, ...nodePrefixedBuiltinModules],
gzip: true,
limit: '99 KB',
limit: '153 KB',
disablePlugins: ['@size-limit/esbuild'],
},
// Cloudflare SDK (ESM) - compressed, minified to match `wrangler deploy --dry-run --minify` output
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
/**
* Asserts the orchestrion subtree is bundled by default. Channel-based (orchestrion
* diagnostics-channel) instrumentation is the v11 default, so `Sentry.init()` pulls in the
* orchestrion code path unconditionally — there is no longer an opt-in to tree-shake it away.
* Asserts that a webpack-bundled server still gets orchestrion instrumentation.
*
* Two things are checked, and they fail for different reasons:
*
* 1. The orchestrion subtree is bundled at all. Channel-based (orchestrion diagnostics-channel)
* instrumentation is the v11 default, so `Sentry.init()` pulls in the orchestrion code path
* unconditionally, and there is no longer an opt-in to tree-shake it away.
* 2. The bundle, when run, actually records channel-based spans for an external dependency. The
* string check above passes even when the bundler has stripped the vendored code transformer to
* an empty object, which leaves auto-instrumentation dead and silent
* (https://github.com/getsentry/sentry-javascript/issues/23664).
*
* @module
*/
import { execFileSync } from 'node:child_process';
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand All @@ -23,6 +32,17 @@ function bundleText(name) {
.join('\n');
}

function runBundle(name) {
const stdout = execFileSync('node', [join(__dirname, 'dist', name, 'main.mjs')], { encoding: 'utf-8' });
const line = stdout.split('\n').find(l => l.startsWith('SENTRY_RESULT='));

if (!line) {
throw new Error(`${name} did not print SENTRY_RESULT (stdout: ${stdout})`);
}

return JSON.parse(line.slice('SENTRY_RESULT='.length));
}

let failed = false;
function check(condition, message) {
// eslint-disable-next-line no-console
Expand All @@ -34,6 +54,15 @@ const app = bundleText('entry');

check(app.includes(MARKER), 'orchestrion is bundled by default when Sentry.init() runs');

const { injected, spans } = runBundle('entry');
const detail = `injected: ${JSON.stringify(injected)}, spans: ${JSON.stringify(spans)}`;

check(injected.runtime.includes('graphql'), `the runtime hook injected channels into graphql (${detail})`);
check(
spans.some(span => span.origin === 'auto.graphql.diagnostic_channel'),
`the bundled app recorded channel-based graphql spans (${detail})`,
);

if (failed) {
process.exit(1);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ function build(name) {
library: { type: 'module' },
chunkFormat: 'module',
},
// graphql is the module the runtime hook has to instrument, so it has to stay out of the
// bundle. Everything else, `@sentry/server-utils` included, is inlined: that is the setup
// where downstream tree-shaking used to silently strip the code transformer
// (https://github.com/getsentry/sentry-javascript/issues/23664).
externals: { graphql: 'import graphql' },
// Keep output readable; tree-shaking (module elimination via
// `sideEffects: false`) happens regardless of minification, and
// it's important to be able to debug when it messes up.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "node-orchestrion-webpack",
"description": "ensure that orchestrion is not bundled inappropriately",
"description": "ensure that orchestrion is bundled and still instruments external dependencies",
"version": "1.0.0",
"private": true,
"type": "module",
Expand All @@ -11,7 +11,8 @@
},
"dependencies": {
"@sentry/node": "file:../../packed/sentry-node-packed.tgz",
"@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz"
"@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz",
"graphql": "16.14.2"
},
"devDependencies": {
"webpack": "5.107.2"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,2 +1,12 @@
// eslint-disable-next-line no-console
console.log('this is the application');
// Loaded *after* `Sentry.init()` so the runtime module hooks are already installed when graphql is
// compiled. graphql is deliberately left out of the bundle (see build.mjs): if it were bundled there
// would be no module load left for the hook to intercept and the assertion would prove nothing.
const { buildSchema, parse, execute } = await import('graphql');

const schema = buildSchema('type Query { hello: String }');

export async function runQuery() {
const document = parse('{ hello }');

await execute({ schema, document, rootValue: { hello: () => 'world' } });
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
import * as Sentry from '@sentry/node';

const spans = [];

Sentry.init({
traceLifecycle: 'static',
dsn: 'https://public@dsn.ingest.sentry.io/1337',
tracesSampleRate: 1,
// Nothing leaves the process: spans are collected here and the transport is a no-op, so the
// bundle runs offline against a fake DSN.
transport: () => ({ send: async () => ({}), flush: async () => true }),
beforeSendSpan(span) {
spans.push({ name: span.name, origin: span.attributes?.['sentry.origin'] });

return span;
},
});

await import('./app.mjs');
const { runQuery } = await import('./app.mjs');

await Sentry.startSpan({ name: 'graphql-work', op: 'test' }, runQuery);
await Sentry.flush(2000);

const { runtime = [], bundler = [] } = globalThis.__SENTRY_ORCHESTRION__ ?? {};

// eslint-disable-next-line no-console
console.log(
`SENTRY_RESULT=${JSON.stringify({
injected: { runtime, bundler: Array.isArray(bundler) ? bundler : [...bundler] },
spans,
})}`,
);
30 changes: 28 additions & 2 deletions dev-packages/rollup-utils/plugins/make-esm-plugin.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from 'node:fs';
import path from 'node:path';

/**
* Outputs a package.json file with {type: module} in the root of the output directory so that Node
Expand All @@ -7,14 +8,17 @@ import fs from 'node:fs';
export function makePackageNodeEsm() {
return {
name: 'make-package-node-esm',
async generateBundle() {
async generateBundle(options) {
// We need to keep the `sideEffects` value from the original package.json,
// as e.g. webpack seems to depend on this
// without this, tree shaking does not work as expected
const packageJSONPath = (await this.resolve('package.json')).id;

const packageJSON = JSON.parse(fs.readFileSync(packageJSONPath, 'utf-8'));
const sideEffects = packageJSON.sideEffects;
const sideEffects = scopeSideEffectsToOutputDir(
packageJSON.sideEffects,
path.relative(path.dirname(packageJSONPath), options.dir),
);
// For module federation we need to keep the version of the package
const version = packageJSON.version;

Expand All @@ -32,3 +36,25 @@ export function makePackageNodeEsm() {
},
};
}

/**
* Bundlers resolve `sideEffects` globs against the *nearest* package.json, and the file we emit here
* is nearer than the package's own for everything under the output directory. So a path list written
* relative to the package root (`./build/esm/vendored/foo/**`) would silently match nothing once it
* lands here. Re-anchor the entries that point into this output directory and drop the rest, which
* belong to sibling outputs (`./build/cjs/...`) still covered by the package's own package.json.
*
* A boolean `sideEffects` needs none of this and is passed through untouched.
*
* @param {boolean | string[] | undefined} sideEffects The package's own `sideEffects` value.
* @param {string} outputDir The output directory, relative to the package root.
*/
function scopeSideEffectsToOutputDir(sideEffects, outputDir) {
if (!Array.isArray(sideEffects)) {
return sideEffects;
}

const prefix = `./${outputDir}/`;

return sideEffects.filter(entry => entry.startsWith(prefix)).map(entry => `./${entry.slice(prefix.length)}`);
}
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
23 changes: 23 additions & 0 deletions packages/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,29 @@ 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`. Bundling the SDK into your server is supported: the hook
keeps instrumenting the dependencies you leave external.

Two setups still need a deliberate choice:

- **You bundle your dependencies too.** Once a library is inlined there is no module load left for
the runtime hook to intercept, so 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.
- **You are on Node.js older than 24.13.** Those versions install the hook through an API that
resolves `@sentry/server-utils` from disk against a path baked into the bundle at build time. If
that path no longer resolves at runtime (a single-file deploy without `node_modules`, or a webpack
bundle run on a different machine than it was built on) the hook cannot install. Either keep
`@sentry/server-utils` external, or use the build-time plugins above. Node.js 24.13 and newer
install the hook entirely in-process and are unaffected.

`@sentry/node` warns at startup whenever it ends up without runtime instrumentation, so you do not
have to guess which case you are in. (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/)
7 changes: 7 additions & 0 deletions packages/nuxt/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,11 @@ functionality related to Nuxt.

## Troubleshoot

If your server-side auto-instrumentation stops recording spans after bundling (e.g. certain Nitro
presets), check whether the libraries you expect spans for were inlined into the server bundle: once
a library is bundled there is no module load left for the runtime hook to intercept, and it has to be
instrumented at build time instead. `@sentry/node` logs a warning at startup whenever it ends up
without runtime instrumentation. See
[Bundling your server](https://github.com/getsentry/sentry-javascript/tree/master/packages/node#bundling-your-server).

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.
5 changes: 4 additions & 1 deletion packages/server-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@
"volta": {
"extends": "../../package.json"
},
"sideEffects": false,
"sideEffects": [
"./build/esm/vendored/source-map/**",
"./build/cjs/vendored/source-map/**"
],
"nx": {
"targets": {
"build:transpile": {
Expand Down
27 changes: 26 additions & 1 deletion packages/server-utils/rollup.npm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,31 @@ const debugNodeAlias = {
},
};

// `@apm-js-collab/code-transformer` reaches meriyah and astring through `require()`, so node-resolve
// picks each package's CJS build and `@rollup/plugin-commonjs` emits it as an empty
// `_virtual/<dep>.js` proxy that a *separate* module fills in through cross-module property writes
// (`meriyah.parse = parse`), reachable only via a bare side-effect import. Downstream tree-shakers
// delete that import, so `parse`/`generate` are `undefined` and every instrumented module load
// throws (https://github.com/getsentry/sentry-javascript/issues/23664).
//
// Both packages also publish an ESM build. Re-resolving the bare specifier *without* the `require`
// condition that `@rollup/plugin-commonjs` asks for picks that build instead, and the transformer
// then binds `parse`/`generate` through a plain value import that no tree-shaker can drop. It also
// deduplicates meriyah: `src/orchestrion/bundler/moduleInjectedTransform.ts` already imports it as
// ESM, so before this the build shipped meriyah's CJS *and* ESM copy, ~324 kB each.
//
// source-map (0.6.1) publishes no ESM build and so keeps the fragile shape. That is what the
// `sideEffects` allowlist in this package's `package.json` covers.
const esmVendorAlias = {
name: 'esm-vendor-alias',
resolveId: {
order: 'pre',
handler(source, importer) {
return source === 'meriyah' || source === 'astring' ? this.resolve(source, importer, { skipSelf: true }) : null;
},
},
};

// Bundling files from the repo-root `node_modules` moves rollup's common source ancestor up to the
// repo root, so `preserveModules` names our own files `packages/server-utils/src/...` — strip that
// prefix to keep the `build/cjs/index.js` layout the `exports` map points at. And npm never packs
Expand Down Expand Up @@ -123,7 +148,7 @@ export default [
'src/orchestrion/bundler/esbuild.ts',
],
packageSpecificConfig: {
plugins: [debugNodeAlias, commonJSPlugin, thirdPartyLicensePlugin],
plugins: [debugNodeAlias, esmVendorAlias, commonJSPlugin, thirdPartyLicensePlugin],
output: {
// set exports to 'named' or 'auto' so that rollup doesn't warn
exports: 'named',
Expand Down
Loading
Loading