Skip to content
Merged
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
18 changes: 18 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,24 @@ Affected SDKs: `@sentry/node` and all dependents.

The new channel-based instrumentations (using `orchestrion` instead of `import-in-the-middle`) are now the default. They were available opt-in in v10. This unlocks instrumenting at run and build time, which enables instrumentation at deployment targets like Vercel and Netlify, as well as using instrumentations on non-Node runtimes like Cloudflare, Bun and Deno. For most users this requires no changes.

### Initializing via `--require` is no longer supported

Affected SDKs: `@sentry/node` and all dependents.

Node re-runs `--require` preloads on the internal module loader thread it spawns for `Module.register()` — which the SDK triggers itself when it installs its instrumentation hooks. A `--require`d instrument file therefore ran `Sentry.init()` a second time, on a thread that never executes any of your code. The SDK now skips initialization on that thread and warns when it detects that it was loaded through `--require`.

Use [`--import`](https://nodejs.org/api/cli.html#--importmodule) instead. It is not re-run on the loader thread, and it works for CommonJS apps too — the instrument file's extension (`.cjs`, or `.js` in a package without `"type": "module"`) is what decides that it loads as CommonJS:

```bash
# Before
node --require ./instrument.js app.js

# After
node --import ./instrument.js app.js
```

The same applies to the no-code entry points, e.g. `node --import=@sentry/node/init app.js` and `node --import @sentry/node/preload app.js`.

### Span streaming is now the default

Affected SDKs: All SDKs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"scripts": {
"build": "remix build",
"dev": "remix dev",
"start": "NODE_OPTIONS='--require=./instrument.server.cjs' remix-serve build/index.js",
"start": "NODE_OPTIONS='--import=./instrument.server.cjs' remix-serve build/index.js",
"typecheck": "tsc",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install && npx playwright install && pnpm build",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"build": "remix vite:build && pnpm typecheck",
"dev": "remix vite:dev",
"start": "NODE_OPTIONS='--require=./instrument.server.cjs' remix-serve build/server/index.js",
"start": "NODE_OPTIONS='--import=./instrument.server.cjs' remix-serve build/server/index.js",
"typecheck": "tsc",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install && pnpm build",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test';

// The `db.test.ts` runtime assertions prove orchestrion spans appear, but spans alone
// don't prove they came from the BUILD-time transform: if the Vite plugin silently
// failed to load, the deps would stay external and the runtime `--require` hook would
// failed to load, the deps would stay external and the runtime `--import` hook would
// inject the channels at runtime instead - the span tests would still pass. These
// assertions inspect the built server bundle directly so a broken plugin can't hide
// behind that runtime fallback. Only relevant in the orchestrion variant.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node --require @sentry/node/preload src/app.js",
"start": "node --import @sentry/node/preload src/app.js",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install",
"test:assert": "playwright test"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"scripts": {
"build": "remix vite:build && pnpm typecheck",
"dev": "remix vite:dev",
"start": "NODE_OPTIONS='--require=./instrument.server.cjs' remix-serve build/server/index.js",
"start": "NODE_OPTIONS='--import=./instrument.server.cjs' remix-serve build/server/index.js",
"typecheck": "tsc",
"clean": "npx rimraf node_modules pnpm-lock.yaml",
"test:build": "pnpm install && pnpm build",
Expand Down
3 changes: 0 additions & 3 deletions dev-packages/node-integration-tests/suites/no-code/app.js

This file was deleted.

9 changes: 0 additions & 9 deletions dev-packages/node-integration-tests/suites/no-code/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,6 @@ describe('no-code init', () => {
cleanupChildProcesses();
});

test('CJS', async () => {
await createRunner(__dirname, 'app.js')
.withFlags('--require=@sentry/node/init')
.withMockSentryServer()
.expect({ event: EVENT })
.start()
.completed();
});

describe('--import', () => {
test('ESM', async () => {
await createRunner(__dirname, 'app.mjs')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,11 @@ module.exports = { out_of_app_function };`,
.completed();
});

test('Should include local variables when instrumenting via --require', async () => {
const requirePath = path.resolve(__dirname, 'local-variables-instrument.js');
test('Should include local variables when instrumenting via --import', async () => {
const instrumentPath = path.resolve(__dirname, 'local-variables-instrument.cjs');

await createRunner(__dirname, 'local-variables-no-sentry.js')
.withFlags(`--require=${requirePath}`)
.withFlags(`--import=${instrumentPath}`)
.expect({ event: EXPECTED_LOCAL_VARIABLES_EVENT })
.start()
.completed();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,7 @@ describe('OnUncaughtException integration', () => {
conditionalTest({ min: 20 })('Worker thread error handling Node 20+', () => {
test.each(['mjs', 'js'])('should not interfere with worker thread error handling ".%s"', async extension => {
const runner = createRunner(__dirname, `worker-thread/caught-worker.${extension}`)
.withFlags(
extension === 'mjs' ? '--import' : '--require',
path.join(__dirname, `worker-thread/instrument.${extension}`),
)
.withFlags('--import', path.join(__dirname, `worker-thread/instrument.${extension}`))
.expect({
event: {
level: 'error',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe('mysql auto instrumentation', () => {

// Each case maps to one of the two documented use cases, in opt-in and
// non-opt-in form. `flags` are extra Node CLI flags; the instrument file is
// always loaded via `--import` (esm) / `--require` (cjs) by the runner.
// always loaded via `--import` by the runner.
const CASES = [
// OpenTelemetry default — no opt-in, no injection. (OTel does not support ESM.)
{ label: 'opentelemetry (default)', env: {}, flags: [], origin: undefined, failsOnEsm: true },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Opting in via `experimentalUseDiagnosticsChannelInjection()` before `init()`
// is all that's needed. Because this file is loaded
// (via `--import`/`--require`) before the scenario imports `pg`,
// (via `--import`) before the scenario imports `pg`,
// `Sentry.init()` synchronously installs the channel-injection hooks, so the
// OTel `Postgres` instrumentation is swapped for the diagnostics-channel one.
import * as Sentry from '@sentry/node';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';
const Sentry = require('@sentry/node');
const { loggingTransport } = require('@sentry-internal/node-integration-tests');

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as Sentry from '@sentry/node';
import { startExpressServerAndSendPortToRunner } from '@sentry-internal/node-integration-tests';
import express from 'express';
const Sentry = require('@sentry/node');
const { startExpressServerAndSendPortToRunner } = require('@sentry-internal/node-integration-tests');
const express = require('express');

const app = express();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ test('errors and transactions get a unique traceId per request, when tracing is
const eventTraceIds: string[] = [];
const transactionTraceIds: string[] = [];

const runner = createRunner(__dirname, 'server.ts')
.withFlags('--require', join(__dirname, 'instrument.ts'))
const runner = createRunner(__dirname, 'server.js')
.withFlags('--import', join(__dirname, 'instrument.cjs'))
.expect({
event: event => {
eventTraceIds.push(event.contexts?.trace?.trace_id || '');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,10 @@ function prepareTmpDir(
const cjsScenarioPath = join(tmpDirPath, esmScenarioBasename.replace('.mjs', '.cjs'));
const cjsInstrumentPath = join(tmpDirPath, esmInstrumentBasename.replace('.mjs', '.cjs'));

const cjsFlags: string[] = ['--require', cjsInstrumentPath];
// `--import` for both: the `.cjs` extension is what makes Node load the instrument file (and
// therefore the SDK) as CommonJS. `--require` would additionally re-run the preload on Node's
// module loader thread, which the SDK no longer supports.
const cjsFlags: string[] = ['--import', cjsInstrumentPath];
const esmFlags: string[] = ['--import', esmInstrumentPathForRun];

async function createTmpDir(): Promise<void> {
Expand Down Expand Up @@ -274,7 +277,7 @@ experimentalUseDiagnosticsChannelInjection();`,
);

esmFlags.unshift('--import', mjsInjectOrchetrionPath);
cjsFlags.unshift('--require', cjsInjectOrchetrionPath);
cjsFlags.unshift('--import', cjsInjectOrchetrionPath);
}

// Copy any additional files/dirs into tmp dir
Expand Down
82 changes: 60 additions & 22 deletions dev-packages/node-integration-tests/utils/runner/createRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,12 @@ const NODE_MAJOR = Number(process.versions.node.split('.')[0]);
const COMPILE_CACHE_ENV: Record<string, string> =
NODE_MAJOR >= 22 ? { NODE_COMPILE_CACHE: join(tmpdir(), 'sentry-node-it-compile-cache') } : {};

/** Node flags that preload a module before the entry point. */
const PRELOAD_FLAGS = ['--import', '--require', '-r'];

/** tsx's CommonJS require hook, preloaded for `.ts` scenarios. */
const TS_LOADER = 'tsx/cjs';

export const CLEANUP_STEPS = new Set<VoidFunction>();

export function cleanupChildProcesses(): void {
Expand Down Expand Up @@ -147,7 +153,7 @@ export function createRunner(...paths: string[]) {
// 22+ gives the scenario a different `@sentry/node` instance than the CJS instrument/auto-flush,
// so instrumentation and flushing target the wrong SDK object. The require hook keeps one CJS
// instance, matching how ts-node loaded them.
flags.push('-r', 'tsx/cjs');
flags.push('-r', TS_LOADER);
}

// Cleanup steps registered by this specific runner (child process, docker, mock server). They are
Expand Down Expand Up @@ -417,19 +423,17 @@ export function createRunner(...paths: string[]) {
// flush keeps the event loop alive until queued envelopes reach the
// transport, then the process exits naturally.
//
// We inject the matching loader for the scenario's module system
// (detected by whether `flags` already contains `--import` for the
// instrument file). For ESM scenarios we use `--import auto-flush.mjs`
// so the `import * as Sentry` resolves to the same SDK instance the
// scenario uses; for CJS we use `--require auto-flush.cjs`.
// We inject the loader matching the scenario's module system, so that its
// `Sentry` reference resolves to the same SDK instance the scenario uses —
// see `buildAutoFlushFlags`.
//
// Skipped when no envelopes are expected — these tests (e.g. ANR
// `should-exit`, `ensureNoErrorOutput`) verify the child exits
// naturally and auto-flush would delay that with retrying HTTP
// requests to the fake DSN.
const wantsAutoFlush =
!ensureNoErrorOutput && (expectedEnvelopes.length > 0 || (expectedEnvelopeHeaders?.length ?? 0) > 0);
const childFlags = wantsAutoFlush ? [...buildAutoFlushFlags(flags), ...flags] : flags;
const childFlags = wantsAutoFlush ? [...buildAutoFlushFlags(flags, testPath), ...flags] : flags;

child = spawn('node', [...childFlags, testPath], { env });

Expand Down Expand Up @@ -651,25 +655,59 @@ function log(...args: unknown[]): void {
console.log(...args.map(arg => normalize(arg)));
}

/**
* Extracts the preloaded module paths from Node flags, accepting both the
* two-element form (`--import foo`, e.g. `withInstrument`) and the single-element
* form (`--import=foo`, e.g. `withFlags('--import=@sentry/node/init')` in
* `suites/no-code/test.ts`).
*/
function getPreloadPaths(flags: readonly string[]): string[] {
const paths: string[] = [];

for (let i = 0; i < flags.length; i++) {
const flag = flags[i] as string;
const [name, ...rest] = flag.split('=');

if (!PRELOAD_FLAGS.includes(name as string)) {
continue;
}

const path = rest.length ? rest.join('=') : flags[++i];
if (path) {
paths.push(path);
}
}

return paths;
}

/**
* Returns Node flags that inject the auto-flush loader matching the scenario's
* module system. ESM scenarios already have `--import` for the instrument
* file — we mirror that with `--import auto-flush.mjs` so both resolve to the
* same `@sentry/node` instance. Otherwise we fall back to `--require
* auto-flush.cjs`.
* module system.
*
* Which of the two SDK builds (CJS or ESM) holds the queued envelopes is decided by
* the file that calls `Sentry.init()` — the last preloaded instrument file if there is
* one, otherwise the scenario itself. Getting this wrong is silent: the flush targets
* the other build's client, which has nothing queued, and no envelope ever arrives.
*
* Node accepts both `--import foo` (two array elements, e.g. `withInstrument`
* or `withFlags('--import', foo)`) and `--import=foo` (one element, e.g.
* `withFlags('--import=@sentry/node/init')` in `suites/no-code/test.ts`); we
* have to recognise both, otherwise the missed form silently gets
* `auto-flush.cjs` and the flush targets the wrong SDK instance.
* The flag name is no longer a usable signal, since CJS instrument files are preloaded
* with `--import` too (`--require` re-runs the preload on Node's module loader thread).
* The extension is: `.mjs` and extensionless package specifiers such as
* `@sentry/node/init` resolve as ESM, while `.cjs`, `.js` and `.ts` are all CommonJS
* here because the test package sets no `"type"`.
*
* The CJS loader stays on `--require`: any `--import` makes Node resolve the entry point
* through the ESM loader, which rejects the `.ts` scenarios that `tsx/cjs` handles. Unlike
* an instrument file it never calls `Sentry.init()`, so the loader thread is not a concern.
*/
function buildAutoFlushFlags(existingFlags: readonly string[]): string[] {
const isEsm = existingFlags.some(flag => flag === '--import' || flag.startsWith('--import='));
if (isEsm) {
return ['--import', join(__dirname, 'auto-flush.mjs')];
}
return ['--require', join(__dirname, 'auto-flush.cjs')];
function buildAutoFlushFlags(existingFlags: readonly string[], testPath: string): string[] {
const initPath =
getPreloadPaths(existingFlags)
.filter(path => path !== TS_LOADER)
.at(-1) ?? testPath;
const isEsm = initPath.endsWith('.mjs') || !/\.[cm]?[jt]s$/.test(initPath);

return isEsm ? ['--import', join(__dirname, 'auto-flush.mjs')] : ['--require', join(__dirname, 'auto-flush.cjs')];
}

function expectErrorEvent(item: Event, expected: ExpectedEvent): void {
Expand Down
2 changes: 1 addition & 1 deletion packages/node/src/init.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { init } from './sdk';

/**
* The @sentry/node/init export can be used with the node --import and --require args to initialize the SDK entirely via
* The @sentry/node/init export can be used with the node --import arg to initialize the SDK entirely via
* environment variables.
*
* > SENTRY_DSN=https://examplePublicKey@o0.ingest.sentry.io/0 SENTRY_TRACES_SAMPLE_RATE=1.0 node --import=@sentry/node/init app.mjs
Expand Down
2 changes: 1 addition & 1 deletion packages/node/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const integrationsStr = process.env.SENTRY_PRELOAD_INTEGRATIONS;
const integrations = integrationsStr ? integrationsStr.split(',').map(integration => integration.trim()) : undefined;

/**
* The @sentry/node/preload export can be used with the node --import and --require args to preload the OTEL
* The @sentry/node/preload export can be used with the node --import arg to preload the OTEL
* instrumentation, without initializing the Sentry SDK.
*
* This is useful if you cannot initialize the SDK immediately, but still want to preload the instrumentation,
Expand Down
20 changes: 20 additions & 0 deletions packages/node/src/sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
setOpenTelemetryContextAsyncContextStrategy,
setupEventContextTrace,
} from '@sentry/opentelemetry';
import { isMainThread, parentPort } from 'node:worker_threads';
import { DEBUG_BUILD } from '../debug-build';
import { childProcessIntegration } from '../integrations/childProcess';
import { consoleIntegration } from '../integrations/console';
Expand All @@ -38,6 +39,7 @@ import { systemErrorIntegration } from '../integrations/systemError';
import { getAutoPerformanceIntegrations } from '../integrations/tracing';
import { makeNodeTransport } from '../transports';
import type { NodeClientOptions, NodeOptions } from '../types';
import { getEntryPointType } from '../utils/entry-point';
import { getSpotlightConfig } from '../utils/spotlight';
import { defaultStackParser, getSentryRelease } from './api';
import { NodeClient } from './client';
Expand Down Expand Up @@ -150,6 +152,24 @@ function _init(
options: NodeOptions | undefined = {},
getDefaultIntegrationsImpl: (options: Options) => Integration[],
): NodeClient | undefined {
// Node re-runs `--require` preloads (though not `--import` ones) on the module loader thread it
// spawns for `Module.register()`, which `init()` itself triggers (channel injection, the ESM
// loader hook). So a `--require`d instrument file re-enters `init()` there, on a thread that
// never runs app code. It is recognizable as the only thread without a `parentPort`:
// user-created workers always have one and are legitimately instrumented.
if (!isMainThread && !parentPort) {
return undefined;
}

if (getEntryPointType() === 'require') {
consoleSandbox(() => {
// eslint-disable-next-line no-console
console.warn(
'[Sentry] Initializing the SDK via the Node `--require` flag is no longer supported, because Node re-runs `--require` preloads on its module loader thread. Use `--import` instead: `node --import ./instrument.js app.js`',
);
});
}

applySdkMetadata(options, 'node');

// Resolve the tracing-affecting options (e.g. `SENTRY_TRACES_SAMPLE_RATE`) up front so that both
Expand Down
2 changes: 1 addition & 1 deletion packages/node/src/utils/entry-point.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function parseProcessPaths(proc: ProcessInterface): ProcessArgs {

const joinedArgs = execArgv.join(' ');
const importPaths = Array.from(joinedArgs.matchAll(/--import[ =](\S+)/g)).map(e => resolve(cwd, e[1] || ''));
const requirePaths = Array.from(joinedArgs.matchAll(/--require[ =](\S+)/g)).map(e => resolve(cwd, e[1] || ''));
const requirePaths = Array.from(joinedArgs.matchAll(/(?:--require|-r)[ =](\S+)/g)).map(e => resolve(cwd, e[1] || ''));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Require warning misses package preloads

Medium Severity

The new --require migration warning relies on getEntryPointType(), but parseProcessPaths resolves preload targets with path.resolve(cwd, ...). Package specifiers like @sentry/node/init never match real stack filenames under node_modules, so the documented no-code --require entry points silently skip the warning.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 143a4cc. Configure here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

That's ok


return { appPath, importPaths, requirePaths };
}
Expand Down
Loading
Loading