From 6570144e3888ab984fb8276d927fe01e2f357077 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 09:06:16 +0200 Subject: [PATCH 1/3] feat(node): POC to use orchestrion-js for instrumentation (#20900) This is a WIP POC trying out usage of orchestrion-js for node SDK instrumentation. 1. Built a general plan document outlining how this can/should work 2. Implemented the generic utilities and building blocks needed 3. Implemented a example integration for mysql package using the new pieces Honestly it seems pretty straightforward... Usage for this POC is: ```bash node --import @sentry/node/orchestrion app.mjs ``` And then ```js // app.mjs import * as Sentry from '@sentry/node'; const client = Sentry.init({ // regular setup... _experimentalUseOrchestrion: true, }); // Split this way for better tree shaking Sentry._experimentalSetupOrchestrion(client); ``` This will disable the otel instrumentation that is already converted to orchestrion (in this PR, only Mysql) and add the respective orchestrion-based integrations instead. The exact API here is WIP and really just geared towards experimentation, so could change, and it's easy to see how this would be easier in v11 with this being the default. Some general benefits of this approach: 1. preload becomes unnecessary as this approach generally behaves like preload - the `--import` script only registers the mappings for orchestrion, all actual code registering stuff etc. happens in `Sentry.init()`. This makes a bunch of things easier... 2. Not tested here, but this should generally work exactly the same if you add the respective vite (and others in the future) plugin, allowing you to skip the `--import`. This also works when deploying to e.g. cloudflare etc. as long as one of the bundler plugins is used. 3. The whole approach is much easier to reconcile with dual-system approaches where newer versions have native DC/TC support - just need to register different channel names mostly to get stuff working. --- .size-limit.js | 26 + ORCHESTRIONJS_PLAN.md | 482 ++++++++++++++++++ .../scripts/consistentExports.ts | 3 + .../node-express-orchestrion-cjs/.gitignore | 1 + .../node-express-orchestrion-cjs/package.json | 26 + .../playwright.config.mjs | 7 + .../node-express-orchestrion-cjs/src/app.js | 71 +++ .../start-event-proxy.mjs | 6 + .../tests/errors.test.ts | 29 ++ .../tests/transactions.test.ts | 154 ++++++ .../node-express-orchestrion-vite/.gitignore | 1 + .../package.json | 34 ++ .../playwright.config.mjs | 7 + .../node-express-orchestrion-vite/src/app.ts | 59 +++ .../src/instrument.ts | 12 + .../start-event-proxy.mjs | 6 + .../tests/errors.test.ts | 29 ++ .../tests/transactions.test.ts | 154 ++++++ .../tsconfig.json | 12 + .../vite.config.ts | 18 + .../node-express-orchestrion/.gitignore | 1 + .../node-express-orchestrion/package.json | 26 + .../playwright.config.mjs | 7 + .../node-express-orchestrion/src/app.mjs | 59 +++ .../src/instrument.mjs | 13 + .../start-event-proxy.mjs | 6 + .../tests/errors.test.ts | 29 ++ .../tests/transactions.test.ts | 154 ++++++ .../tracing/mysql/instrument-orchestrion.mjs | 14 + .../suites/tracing/mysql/test.ts | 35 +- packages/node/package.json | 50 +- packages/node/rollup.npm.config.mjs | 31 +- packages/node/src/index.ts | 1 + .../src/integrations/tracing-channel/mysql.ts | 281 ++++++++++ packages/node/src/orchestrion/bundler/vite.ts | 86 ++++ packages/node/src/orchestrion/channels.ts | 18 + packages/node/src/orchestrion/config.ts | 35 ++ packages/node/src/orchestrion/detect.ts | 31 ++ packages/node/src/orchestrion/index.ts | 3 + .../src/orchestrion/runtime/import-hook.mjs | 54 ++ packages/node/src/orchestrion/setup.ts | 68 +++ packages/node/src/sdk/index.ts | 20 +- packages/node/src/types.ts | 17 + packages/node/tsconfig.json | 8 + packages/node/tsconfig.types.json | 6 +- yarn.lock | 55 +- 46 files changed, 2215 insertions(+), 30 deletions(-) create mode 100644 ORCHESTRIONJS_PLAN.md create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/instrument.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/app.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/transactions.test.ts create mode 100644 dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs create mode 100644 packages/node/src/integrations/tracing-channel/mysql.ts create mode 100644 packages/node/src/orchestrion/bundler/vite.ts create mode 100644 packages/node/src/orchestrion/channels.ts create mode 100644 packages/node/src/orchestrion/config.ts create mode 100644 packages/node/src/orchestrion/detect.ts create mode 100644 packages/node/src/orchestrion/index.ts create mode 100644 packages/node/src/orchestrion/runtime/import-hook.mjs create mode 100644 packages/node/src/orchestrion/setup.ts diff --git a/.size-limit.js b/.size-limit.js index aeff0bd5d723..fc3fbcced896 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -394,6 +394,32 @@ module.exports = [ limit: '136 KB', disablePlugins: ['@size-limit/esbuild'], }, + { + name: '@sentry/node (with Orchestrion)', + path: 'packages/node/build/esm/index.js', + import: createImport('init', '_experimentalSetupOrchestrion'), + ignore: [...builtinModules, ...nodePrefixedBuiltinModules], + gzip: true, + limit: '173 KB', + disablePlugins: ['@size-limit/esbuild'], + }, + { + name: '@sentry/node/orchestrion (ESM hook)', + path: ['node_modules/@apm-js-collab/tracing-hooks/hook.mjs', 'packages/node/build/orchestrion/import-hook.mjs'], + ignore: [...builtinModules, ...nodePrefixedBuiltinModules], + gzip: true, + limit: '100 KB', + disablePlugins: ['@size-limit/esbuild'], + }, + { + name: '@sentry/node/light', + path: 'packages/node-core/build/esm/light/index.js', + import: createImport('init'), + ignore: [...builtinModules, ...nodePrefixedBuiltinModules], + gzip: true, + limit: '100 KB', + disablePlugins: ['@size-limit/esbuild'], + }, { name: '@sentry/node - without tracing', path: 'packages/node/build/esm/index.js', diff --git a/ORCHESTRIONJS_PLAN.md b/ORCHESTRIONJS_PLAN.md new file mode 100644 index 000000000000..3794535df0aa --- /dev/null +++ b/ORCHESTRIONJS_PLAN.md @@ -0,0 +1,482 @@ +# Orchestrion.js Auto-Instrumentation Experiment Plan + +> Experiment branch: `experiment/orchestrionjs-auto-instrumentation` +> +> Goal: prototype a future where `@sentry/node` does its own auto-instrumentation +> via Node.js [`TracingChannel`](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel), +> with channel injection driven by [orchestrion.js](https://github.com/nodejs/orchestrion-js) +> instead of OpenTelemetry's `require-in-the-middle` / `import-in-the-middle` machinery. +> +> First target: the `mysql` integration. + +## Background + +Orchestrion-JS is published as three coordinated packages: + +| Package | What it does | We use it for | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `@apm-js-collab/code-transformer` | Rust/WASM AST walker. Given an `InstrumentationConfig[]`, returns a `Transformer` that rewrites function bodies to publish to a `TracingChannel`. | Indirectly — via the two below. | +| `@apm-js-collab/tracing-hooks` | Node ESM loader (`register('@apm-js-collab/tracing-hooks/hook.mjs', ..., { data: { instrumentations } })`) + a CJS `ModulePatch` for `--require`. | **Runtime** channel injection. | +| `@apm-js-collab/code-transformer-bundler-plugins` | One plugin per bundler (`/vite`, `/webpack`, `/rollup`, `/esbuild`), all taking the same `{ instrumentations }` object. | **Build-time** channel injection. | + +All three accept the same `InstrumentationConfig` shape: + +```ts +type InstrumentationConfig = { + channelName: string; // diagnostics_channel TracingChannel name + module: { name: string; versionRange: string; filePath: string }; + functionQuery: FunctionQuery; // className+methodName / functionName / expressionName / ... +}; +``` + +This means **one config array** can drive both the runtime hook and every bundler plugin — that is the leverage point this plan is built around. + +## Architectural goals + +1. **Integrations only know channels.** A Sentry integration (e.g. `mysqlIntegration`) subscribes to a published channel name and creates spans. It never imports orchestrion, never knows how the channel got there, and would work identically against a native `diagnostics_channel` that some library already publishes itself. +2. **Single source of truth for orchestrion config.** Channel names + module matchers + function queries live in **one** TypeScript module. Both the runtime hook and the bundler plugin import from it. Adding a new instrumentation = one edit. +3. **Two equally good user paths, one of which must be active.** + - **Bundler path** (preferred when bundling): the user adds `sentryOrchestrionPlugin()` to their `vite.config.ts`. Nothing else. + - **Runtime path** (preferred for unbundled Node servers): the user runs `node --import @sentry/node/orchestrion app.js` (ESM) or `node --require @sentry/node/orchestrion app.js` (CJS). The same import path resolves to the ESM `import-hook.mjs` or the CJS `require-hook.cjs` based on the active loader condition, so the user doesn't have to know which one to pick. +4. **Loud about misconfiguration.** When orchestrion setup runs, the SDK must detect (a) "no orchestrion hook was set up at all" and (b) "both paths ran — code is double-wrapped" and warn clearly. +5. **No mixing with the existing OTel-based init, and tree-shakable.** The opt-in is split into two pieces so users who don't opt in never pull in any orchestrion code: + - A new `_experimentalUseOrchestrion: true` flag on `Sentry.init()` that does the _base_ adjustments — i.e. skip registering the OTel auto-instrumentations that have a channel-based replacement (mysql, …). This is all `init()` itself does; it pulls in zero orchestrion-specific code. + - A new top-level export `_experimentalSetupOrchestrion()` that the user calls **after** `Sentry.init()`. This is where all orchestrion-specific code lives: the channel subscribers, the integration registrations, and the runtime/bundler detection warnings. If the user never calls it, the bundler can drop everything under `orchestrion/` from their bundle. + When the flag is unset (the default), `init()` behaves exactly as today and `_experimentalSetupOrchestrion` — if imported — is a no-op that only warns. Existing users keep using `@opentelemetry/instrumentation-*` integrations untouched. + +## Repository layout + +All new code lives under `packages/node/`. The existing OTel-based mysql integration stays untouched so we can A/B them. + +``` +packages/node/ +├── package.json (NEW subpath exports — see below) +└── src/ + └── orchestrion/ (NEW directory — all experiment code) + ├── index.ts public re-exports for the integrations subdir + ├── setup.ts ★ _experimentalSetupOrchestrion() — the only user-facing entry into this dir + ├── config.ts ★ central InstrumentationConfig[] — single source of truth + ├── channels.ts channel-name string constants (imported by configs AND integrations) + ├── detect.ts globalThis marker + warning logic + ├── runtime/ + │ ├── import-hook.mjs --import target: register() + marker + │ └── require-hook.cjs --require target: ModulePatch.patch() + marker + └── bundler/ + ├── vite.ts sentryOrchestrionVitePlugin() — wraps code-transformer/vite + marker + └── marker-banner.ts shared "inject `globalThis.__SENTRY_ORCHESTRION__.bundler = true`" plugin +packages/node/src/integrations/tracing-channel/ + └── mysql.ts ★ subscribes to channels; creates Sentry spans +``` + +All channel-consumer integrations live together under `integrations/tracing-channel/` — one file per library (`mysql.ts`, future `pg.ts`, `redis.ts`, …). This mirrors the existing `integrations/tracing/` layout for the OTel path, keeps related code visually grouped, and makes the boundary the user wants explicit: a contributor adding a new channel-driven integration edits `orchestrion/config.ts` (one entry) + `integrations/tracing-channel/.ts` (one subscriber) + adds it to the default list in `orchestrion/setup.ts`. Nothing else. + +`orchestrion/setup.ts` is the **only** file under `orchestrion/` that user code imports from at runtime (via the top-level `@sentry/node` re-export of `_experimentalSetupOrchestrion`). Everything else under `orchestrion/` is reachable only transitively through that one entry point — which is what makes the experiment tree-shakable for opted-out users. + +## Central config — the load-bearing file + +`packages/node/src/orchestrion/channels.ts` + +```ts +// String constants shared between config.ts (producer) and integrations (consumer). +// Single source of truth for channel names — keeps the channel string from being +// misspelled in one place and silently never firing. +export const CHANNELS = { + MYSQL_QUERY: 'sentry:mysql:query', +} as const; +``` + +`packages/node/src/orchestrion/config.ts` + +```ts +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import { CHANNELS } from './channels'; + +export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ + { + channelName: CHANNELS.MYSQL_QUERY, + module: { name: 'mysql', versionRange: '>=2.0.0', filePath: 'lib/Connection.js' }, + functionQuery: { className: 'Connection', methodName: 'query', kind: 'Callback' }, + }, + // … future entries: mysql2, pg, redis, etc. One line per instrumented method. +]; +``` + +`config.ts` has **no side effects** — it is the only thing both `runtime/*` and `bundler/*` import. This is what makes it cheap to maintain: adding a new instrumented method is one entry here + one subscriber file. + +## The integration — channel consumer + +`packages/node/src/integrations/tracing-channel/mysql.ts` (sketch): + +```ts +import { channel, tracingChannel } from 'node:diagnostics_channel'; +import { defineIntegration, startSpan, SPAN_STATUS_ERROR } from '@sentry/core'; +import { CHANNELS } from '../../orchestrion/channels'; + +const _mysqlChannelIntegration = (() => { + const queryCh = tracingChannel(CHANNELS.MYSQL_QUERY); + // store per-context state on a WeakMap keyed by the `context` object + // that orchestrion passes to start/end/asyncStart/asyncEnd/error. + const spans = new WeakMap void }>(); + + return { + name: 'MysqlChannel', + setupOnce() { + queryCh.subscribe({ + start(ctx) { + // ctx.arguments contains the original call args — extract SQL for span name. + const sql = String((ctx as any).arguments?.[0] ?? 'mysql.query'); + // startSpan returns synchronously when we pass `{ forceTransaction: false }` semantics; + // for true async correlation we wrap startInactiveSpan + manual end here. + const span = startInactiveSpanForChannel(sql); + spans.set(ctx as object, { + finish: () => span.end(), + }); + }, + error(ctx) { + // pull error from ctx, mark span status + }, + asyncEnd(ctx) { + spans.get(ctx as object)?.finish(); + }, + // end() fires for sync paths; asyncEnd() for callback / promise paths + end(ctx) { + // only finish if asyncEnd hasn't (mysql Connection.query is callback-based — asyncEnd is the one) + }, + }); + }, + }; +}) satisfies IntegrationFn; + +export const mysqlChannelIntegration = defineIntegration(_mysqlChannelIntegration); +``` + +The integration imports **`CHANNELS.MYSQL_QUERY`, not the orchestrion config**. It is unaware orchestrion exists; if some day `mysql` publishes that channel natively we just stop injecting it. + +## Subpath exports + +Add to `packages/node/package.json`: + +```jsonc +"exports": { + // … existing entries … + "./orchestrion": { + // Single subpath, two condition arms — Node picks the right file based on + // whether the user passed `--import` (ESM hook) or `--require` (CJS hook). + "import": { "default": "./build/orchestrion/import-hook.mjs" }, + "require": { "default": "./build/orchestrion/require-hook.cjs" } + }, + "./orchestrion/vite": { + // Vite plugin factory. + "import": { "types": "./build/types/orchestrion/bundler/vite.d.ts", "default": "./build/esm/orchestrion/bundler/vite.js" }, + "require": { "types": "./build/types/orchestrion/bundler/vite.d.ts", "default": "./build/cjs/orchestrion/bundler/vite.js" } + } +} +``` + +End-user friction is minimized: either + +```bash +node --import @sentry/node/orchestrion app.js +``` + +or + +```ts +// vite.config.ts +import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; +export default { plugins: [sentryOrchestrionPlugin()] }; +``` + +No `instrumentations: [...]` array to copy-paste, no channel names to remember. + +## Runtime hook — `--import` ESM target + +`packages/node/src/orchestrion/runtime/import-hook.mjs` + +```js +import { register } from 'node:module'; +import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; + +// 1) Double-wrap guard. Set this BEFORE register() so even if a second --import +// is added, we won't double-register. +const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); +if (g.runtime) { + console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --import. Ignoring the second load.'); +} else { + g.runtime = true; + register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { + data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + }); +} +``` + +`packages/node/src/orchestrion/runtime/require-hook.cjs` + +```js +const ModulePatch = require('@apm-js-collab/tracing-hooks'); +const { SENTRY_INSTRUMENTATIONS } = require('@sentry/node/orchestrion/config'); + +const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); +if (g.runtime) { + console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --require. Ignoring.'); +} else { + g.runtime = true; + new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); +} +``` + +Both files set `globalThis.__SENTRY_ORCHESTRION__.runtime = true`. That marker is how `detect.ts` knows the runtime path is active later. + +## Vite plugin — build-time path + +`packages/node/src/orchestrion/bundler/vite.ts` + +```ts +import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; +import type { Plugin } from 'vite'; +import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; + +export function sentryOrchestrionPlugin(): Plugin[] { + return [ + // 1) Inject the runtime marker into the bundle so detect.ts can see it. + markerPlugin(), + // 2) The actual orchestrion transformer, fed our central config. + codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }), + ]; +} + +function markerPlugin(): Plugin { + // Emits/injects a one-liner into the bundle output: + // globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {}); + // if (globalThis.__SENTRY_ORCHESTRION__.bundler) { console.warn('[Sentry] orchestrion bundler plugin loaded twice'); } + // globalThis.__SENTRY_ORCHESTRION__.bundler = true; + return { + name: 'sentry-orchestrion-marker', + enforce: 'pre', + // Easiest: hook `renderChunk` and prepend to entry chunks. + // Alternative: emit a virtual module + use `banner` config injection. + // To be decided during implementation — both work; the renderChunk approach + // avoids requiring the user to import anything. + }; +} +``` + +**Design decision — where the marker comes from in the bundler path:** +the plugin injects runtime JS into the bundle, not just a build-time flag. Build-time markers (e.g. `define`) are useless to `detect.ts`, which runs at app start. The marker must execute when the bundled app boots. + +## Detection — `detect.ts` + +`packages/node/src/orchestrion/detect.ts` + +```ts +import { logger } from '@sentry/core'; + +declare global { + // eslint-disable-next-line no-var + var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; +} + +export function detectOrchestrionSetup(): void { + const marker = globalThis.__SENTRY_ORCHESTRION__; + const runtime = !!marker?.runtime; + const bundler = !!marker?.bundler; + + if (runtime && bundler) { + logger.warn( + '[Sentry] Detected BOTH the @sentry/node/orchestrion runtime hook AND the bundler plugin. ' + + 'Functions will be instrumented twice and produce duplicate spans. ' + + 'Remove `--import @sentry/node/orchestrion` if you are using the bundler plugin, or vice versa.', + ); + return; + } + + if (!runtime && !bundler) { + logger.warn( + '[Sentry] No auto-instrumentation hook detected. Channel-based integrations (mysql, …) will not record spans. ' + + 'Either run with `node --import @sentry/node/orchestrion app.js`, or add `sentryOrchestrionPlugin()` to your bundler config.', + ); + } +} +``` + +## Two-step user setup — flag on `init()` + `_experimentalSetupOrchestrion()` + +The opt-in is deliberately split so the orchestrion code path stays tree-shakable. `Sentry.init()` only learns about a boolean flag; it does **not** import anything from `orchestrion/`. The orchestrion-specific code only runs if the user explicitly imports and calls `_experimentalSetupOrchestrion()` after `init()`. + +### Step 1 — `_experimentalUseOrchestrion` flag on `NodeOptions` + +```ts +// packages/node-core/src/types.ts (or wherever NodeOptions lives) +export interface NodeOptions extends ClientOptions { + // … existing options … + /** + * EXPERIMENTAL — opt into the orchestrion.js-based auto-instrumentation path. + * When `true`, `Sentry.init()` will skip registering the default OTel + * auto-instrumentations for libraries that have a channel-based alternative + * (mysql, …). It does **not** install any channel subscribers on its own — + * call `_experimentalSetupOrchestrion()` after `init()` for that. + * + * Defaults to `false`. The flag name is intentionally underscore-prefixed and + * will be renamed or removed once the experiment graduates. + */ + _experimentalUseOrchestrion?: boolean; +} +``` + +```ts +// packages/node/src/sdk/index.ts (sketch of the additional lines in init()) +export function init(options: NodeOptions | undefined = {}): NodeClient | undefined { + // … existing init body, with one change: when assembling the default integrations + // list, skip entries whose libraries are covered by the orchestrion experiment. + if (options._experimentalUseOrchestrion) { + defaultIntegrations = defaultIntegrations.filter(i => !ORCHESTRION_REPLACED_INTEGRATIONS.has(i.name)); + } + // … the rest of init() is unchanged, and crucially does NOT import from ../orchestrion/* … +} + +// A tiny string-set constant — no orchestrion code imported. +const ORCHESTRION_REPLACED_INTEGRATIONS = new Set([ + 'Mysql', // matches the existing OTel mysql integration's `name` +]); +``` + +The list of replaced integration names is a plain string set defined alongside `init()` itself — it does not import from `orchestrion/`, so toggling the flag doesn't pull orchestrion code into a user's bundle. + +### Step 2 — `_experimentalSetupOrchestrion()` as a separate export + +```ts +// packages/node/src/orchestrion/setup.ts +import { logger } from '@sentry/core'; +import type { NodeClient } from '../sdk/client'; +import { detectOrchestrionSetup } from './detect'; +import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; + +export interface ExperimentalSetupOrchestrionOptions { + /** + * Override or extend the default set of channel-based integrations. + * If omitted, all orchestrion integrations shipped by @sentry/node are added. + */ + integrations?: Integration[]; +} + +export function _experimentalSetupOrchestrion( + client: NodeClient | undefined, + options: ExperimentalSetupOrchestrionOptions = {}, +): void { + if (!client) { + logger.warn( + '[Sentry] _experimentalSetupOrchestrion() was called without a client. ' + + 'Pass the value returned by `Sentry.init()`.', + ); + return; + } + if (!client.getOptions()._experimentalUseOrchestrion) { + logger.warn( + '[Sentry] _experimentalSetupOrchestrion() called but Sentry.init() was not given ' + + '`_experimentalUseOrchestrion: true`. The default OTel integrations are still active — ' + + 'you will get duplicate spans. Add the flag to Sentry.init().', + ); + } + + // 1) Verify the runtime/bundler hook actually ran. + detectOrchestrionSetup(); + + // 2) Register the channel-based integrations on the passed-in client. + const integrations = options.integrations ?? [ + mysqlChannelIntegration(), + // … future channel integrations default-on here. + ]; + for (const integration of integrations) { + client.addIntegration(integration); + } +} +``` + +Taking the client as an explicit argument (instead of pulling it from `getClient()`) makes the call order unambiguous, avoids surprises when multiple clients exist (tests, multi-tenant setups), and gives TypeScript users a clear type on what `_experimentalSetupOrchestrion` operates against. + +`_experimentalSetupOrchestrion` is the **only** export through which orchestrion-specific code is reachable from a user's app graph. Bundlers can statically determine that an app which never imports it has no live edges into `orchestrion/`, so all the channel subscribers, detection code, and integration factories drop out. + +The function is also where we sanity-check the user's setup: it warns if `init()` wasn't told about the flag, and it runs `detectOrchestrionSetup()` to confirm exactly one of the runtime / bundler paths is active. + +### Usage + +```ts +import * as Sentry from '@sentry/node'; +import { _experimentalSetupOrchestrion } from '@sentry/node'; + +const client = Sentry.init({ + dsn: '…', + _experimentalUseOrchestrion: true, +}); + +_experimentalSetupOrchestrion(client); +// Or, to override which integrations are registered: +// _experimentalSetupOrchestrion(client, { integrations: [mysqlChannelIntegration()] }); +``` + +This keeps the experiment self-contained — no parallel `init` function, no separate entry point — while still being fully tree-shakable for users who don't opt in. + +## End-user surface + +**Bundled app (Vite):** + +```ts +// vite.config.ts +import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; +export default { plugins: [sentryOrchestrionPlugin()] }; + +// app.ts +import * as Sentry from '@sentry/node'; +import { _experimentalSetupOrchestrion } from '@sentry/node'; + +const client = Sentry.init({ + dsn: '…', + _experimentalUseOrchestrion: true, +}); +_experimentalSetupOrchestrion(client); +``` + +**Unbundled Node ESM app:** + +```bash +node --import @sentry/node/orchestrion app.js +``` + +```ts +// app.ts — same two-step init + setup as above, no plugin needed. +``` + +**Unbundled Node CJS app:** + +```bash +node --require @sentry/node/orchestrion app.js +``` + +If the user does **neither** runtime nor bundler hook, `_experimentalSetupOrchestrion()` warns at startup. If they do **both**, it also warns. If they set `_experimentalUseOrchestrion: true` but never call `_experimentalSetupOrchestrion()`, they get no channel-based spans and no OTel-based spans for the replaced libraries — also a warning case (emitted lazily the first time the client tries to flush, since we can't observe the missing call directly at `init()` time). TBD whether this third warning is worth the complexity. + +## Double-wrap analysis — what orchestrion does and doesn't protect against + +| Failure mode | Who catches it | How | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Bundler plugin added twice in the same Vite config | orchestrion's bundler plugin itself? **Unverified** — needs a test during the spike. If not, our marker plugin warns. | `__SENTRY_ORCHESTRION__.bundler` already true at second plugin invocation. | +| `--import @sentry/node/orchestrion` passed twice on CLI | Our hook | Marker set before `register()`, second load short-circuits with a warn. | +| Bundler plugin + runtime hook both run | Our `detect.ts` at `Sentry.init` | Warn — this is the most likely real-world footgun, since a Vite-built app may still launch with a stray `--import` from prod tooling. | +| Neither runs | Our `detect.ts` | Warn — user thinks Sentry instruments their DB but it silently doesn't. | +| Orchestrion patches a function the user already patched manually | **Out of scope** for this experiment. Document it. | n/a | + +## Implementation phases + +1. **Plumbing first** — branch (done), add the three orchestrion packages to `packages/node/package.json` as `dependencies`, create `orchestrion/` directory with empty `config.ts`, `channels.ts`, `detect.ts`. No real channels yet. Build passes. +2. **Runtime path end-to-end** — wire `import-hook.mjs` + the rollup config in `packages/node/rollup.npm.config.mjs` to emit it. Verify with a throwaway script that has _one_ instrumentation in `config.ts` (a function in a tiny local fixture module) that publishing fires. +3. **Mysql channel integration** — write `integrations/tracing-channel/mysql.ts`. Plug into a `dev-packages/node-integration-tests/` scenario that runs against a real mysql container, asserts spans. +4. **Bundler path** — add `sentryOrchestrionPlugin()` for Vite, including marker injection. Test in a small fixture under `dev-packages/e2e-tests/` (Vite-built Node entry hitting mysql). +5. **Detection + setup entry point** — add `detect.ts` + `setup.ts` (exporting `_experimentalSetupOrchestrion`), wire the `_experimentalUseOrchestrion` flag into `init()` so it filters the default integrations, and re-export `_experimentalSetupOrchestrion` from the package root. Test all four hook states (runtime only / bundler only / both / neither) via the e2e fixtures, plus a bundler-size assertion that not importing `_experimentalSetupOrchestrion` drops `orchestrion/*` from the output. +6. **Decide & write up** — capture findings in a follow-up doc: does this beat the OTel path on (a) bundle size, (b) cold start, (c) reliability, (d) maintenance cost? + +## Open questions to settle during the spike + +- **Does `@apm-js-collab/tracing-hooks` ship its own double-register guard?** Cheap to test — register twice, see if it complains. If yes, our runtime-path warning is belt-and-suspenders; if no, our marker is the only guard. +- **Does `code-transformer-bundler-plugins/vite` work cleanly with Vite's SSR / library modes?** Our likely consumers (Next, Nuxt, SvelteKit server bundles) all go through SSR pipelines. +- **`TracingChannel` callback context shape** — orchestrion docs describe the channel name + the `kind` (Sync/Async/Callback) but not the exact `context` payload (what `arguments`, `this`, `result`, `error` keys are present). Needs a quick `subscribe` + `console.log` smoke test before writing `mysql.ts`. +- **CJS vs ESM coverage** — does the runtime require-hook see ESM imports of mysql? Does the import-hook see CJS requires? The mysql package itself is CJS, but the consuming app may be either. Likely we need to wire both hooks together in `--import @sentry/node/orchestrion` (the ESM hook also patches CJS via the require-hook path). +- **How do we keep `SENTRY_INSTRUMENTATIONS` tree-shakable?** If a user only wants mysql, the unused configs shouldn't ship. Probably each integration owns its config fragment and `config.ts` aggregates via barrel import — TBD during phase 1. diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 6ae689b80da3..86a717459850 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -23,6 +23,9 @@ const NODE_EXPORTS_IGNORE = [ 'preloadOpenTelemetry', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', + // Experimental + '_experimentalSetupOrchestrion', + 'mysqlChannelIntegration', ]; const nodeExports = Object.keys(SentryNode).filter(e => !NODE_EXPORTS_IGNORE.includes(e)); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json new file mode 100644 index 000000000000..4123b3e3d43b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json @@ -0,0 +1,26 @@ +{ + "name": "node-express-orchestrion-cjs-app", + "version": "1.0.0", + "private": true, + "type": "commonjs", + "scripts": { + "start": "node --import @sentry/node/orchestrion ./src/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "express": "^5.1.0", + "mysql": "2.18.1" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js new file mode 100644 index 000000000000..cc54b773b3c0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js @@ -0,0 +1,71 @@ +const Sentry = require('@sentry/node'); + +const client = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + _experimentalUseOrchestrion: true, +}); + +Sentry._experimentalSetupOrchestrion(client); + +const express = require('express'); +const mysql = require('mysql'); + +const connection = mysql.createConnection({ + user: 'root', + password: 'docker', +}); + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-mysql', function (req, res) { + connection.query('SELECT 1 + 1 AS solution', function () { + connection.query('SELECT NOW()', ['1', '2'], () => { + res.send({ status: 'ok' }); + }); + }); +}); + +app.get('/test-transaction', function (_req, res) { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + + res.send({ status: 'ok' }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +// @ts-ignore +app.use(function onError(err, req, res, next) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/start-event-proxy.mjs new file mode 100644 index 000000000000..d035fa533f1d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-orchestrion-cjs', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/errors.test.ts new file mode 100644 index 000000000000..c5b3bc45e109 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-orchestrion-cjs', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/transactions.test.ts new file mode 100644 index 000000000000..9dbce2a05ac9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/tests/transactions.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-express-orchestrion-cjs', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.http.otel.http', + }); + + expect(transactionEvent.contexts?.response).toEqual({ + status_code: 200, + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + const spans = transactionEvent.spans || []; + + // Manually started span + expect(spans).toContainEqual({ + data: { 'sentry.origin': 'manual' }, + description: 'test-span', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // auto instrumented span + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-transaction', + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + }, + description: '/test-transaction', + op: 'request_handler.express', + origin: 'auto.http.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-orchestrion-cjs', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/:id' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); +}); + +test('Instruments MySQL via Orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-orchestrion-cjs', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /test-mysql'; + }); + + await fetch(`${baseURL}/test-mysql`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-mysql'); + expect(transactionEvent.contexts?.trace?.status).toEqual('ok'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(200); + + const spans = transactionEvent.spans || []; + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT 1 + 1 AS solution', + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT NOW()', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/package.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/package.json new file mode 100644 index 000000000000..dfcf44b6b889 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/package.json @@ -0,0 +1,34 @@ +{ + "name": "node-express-orchestrion-vite-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "start": "node --import ./dist/instrument.js ./dist/app.js", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@types/express": "^4.17.21", + "@types/node": "^18.19.1", + "express": "^5.1.0", + "mysql": "2.18.1", + "typescript": "~5.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz", + "vite": "^5.4.11" + }, + "resolutions": { + "@types/qs": "6.9.17" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/app.ts new file mode 100644 index 000000000000..f34260393bb6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/app.ts @@ -0,0 +1,59 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import mysql from 'mysql'; + +const connection = mysql.createConnection({ + user: 'root', + password: 'docker', +}); + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-mysql', function (req, res) { + connection.query('SELECT 1 + 1 AS solution', function () { + connection.query('SELECT NOW()', ['1', '2'], () => { + res.send({ status: 'ok' }); + }); + }); +}); + +app.get('/test-transaction', function (_req, res) { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + + res.send({ status: 'ok' }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +// @ts-ignore +app.use(function onError(err, req, res, next) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/instrument.ts new file mode 100644 index 000000000000..109beefafba6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/instrument.ts @@ -0,0 +1,12 @@ +import * as Sentry from '@sentry/node'; + +const client = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + _experimentalUseOrchestrion: true, +}); + +Sentry._experimentalSetupOrchestrion(client); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/start-event-proxy.mjs new file mode 100644 index 000000000000..7cb02eee13af --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-orchestrion-vite', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/errors.test.ts new file mode 100644 index 000000000000..dd94052af1fe --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-orchestrion-vite', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/transactions.test.ts new file mode 100644 index 000000000000..1890a6af44ec --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/transactions.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-express-orchestrion-vite', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.http.otel.http', + }); + + expect(transactionEvent.contexts?.response).toEqual({ + status_code: 200, + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + const spans = transactionEvent.spans || []; + + // Manually started span + expect(spans).toContainEqual({ + data: { 'sentry.origin': 'manual' }, + description: 'test-span', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // auto instrumented span + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-transaction', + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + }, + description: '/test-transaction', + op: 'request_handler.express', + origin: 'auto.http.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-orchestrion-vite', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/:id' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); +}); + +test('Instruments MySQL via Orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-orchestrion-vite', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /test-mysql'; + }); + + await fetch(`${baseURL}/test-mysql`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-mysql'); + expect(transactionEvent.contexts?.trace?.status).toEqual('ok'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(200); + + const spans = transactionEvent.spans || []; + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT 1 + 1 AS solution', + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT NOW()', + }), + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tsconfig.json new file mode 100644 index 000000000000..c46f5dea4945 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "types": ["node"], + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "lib": ["es2020"], + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/vite.config.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/vite.config.ts new file mode 100644 index 000000000000..daa0417a5e3a --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/vite.config.ts @@ -0,0 +1,18 @@ +import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sentryOrchestrionPlugin()], + build: { + target: 'node18', + ssr: true, + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: ['src/app.ts', 'src/instrument.ts'], + output: { + format: 'esm', + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/package.json new file mode 100644 index 000000000000..0257fa5acd6c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/package.json @@ -0,0 +1,26 @@ +{ + "name": "node-express-orchestrion-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node --import ./src/instrument.mjs ./src/app.mjs", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml dist", + "test:build": "pnpm install", + "test:assert": "pnpm test" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "express": "^5.1.0", + "mysql": "2.18.1" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@sentry/core": "file:../../packed/sentry-core-packed.tgz" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/playwright.config.mjs @@ -0,0 +1,7 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm start`, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/app.mjs new file mode 100644 index 000000000000..f34260393bb6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/app.mjs @@ -0,0 +1,59 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; +import mysql from 'mysql'; + +const connection = mysql.createConnection({ + user: 'root', + password: 'docker', +}); + +const app = express(); +const port = 3030; + +app.get('/test-success', function (req, res) { + res.send({ version: 'v1' }); +}); + +app.get('/test-param/:param', function (req, res) { + res.send({ paramWas: req.params.param }); +}); + +app.get('/test-mysql', function (req, res) { + connection.query('SELECT 1 + 1 AS solution', function () { + connection.query('SELECT NOW()', ['1', '2'], () => { + res.send({ status: 'ok' }); + }); + }); +}); + +app.get('/test-transaction', function (_req, res) { + Sentry.startSpan({ name: 'test-span' }, () => undefined); + + res.send({ status: 'ok' }); +}); + +app.get('/test-error', async function (req, res) { + const exceptionId = Sentry.captureException(new Error('This is an error')); + + await Sentry.flush(2000); + + res.send({ exceptionId }); +}); + +app.get('/test-exception/:id', function (req, _res) { + throw new Error(`This is an exception with id ${req.params.id}`); +}); + +Sentry.setupExpressErrorHandler(app); + +// @ts-ignore +app.use(function onError(err, req, res, next) { + // The error id is attached to `res.sentry` to be returned + // and optionally displayed to the user for support. + res.statusCode = 500; + res.end(res.sentry + '\n'); +}); + +app.listen(port, () => { + console.log(`Example app listening on port ${port}`); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs new file mode 100644 index 000000000000..bdce1c09630c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs @@ -0,0 +1,13 @@ +import '@sentry/node/orchestrion'; +import * as Sentry from '@sentry/node'; + +const client = Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: process.env.E2E_TEST_DSN, + debug: !!process.env.DEBUG, + tunnel: `http://localhost:3031/`, // proxy server + tracesSampleRate: 1, + _experimentalUseOrchestrion: true, +}); + +Sentry._experimentalSetupOrchestrion(client); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/start-event-proxy.mjs new file mode 100644 index 000000000000..52c720f60ea5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-orchestrion', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/errors.test.ts new file mode 100644 index 000000000000..9dd4257ae02d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/errors.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import { waitForError } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ baseURL }) => { + const errorEventPromise = waitForError('node-express-orchestrion', event => { + return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; + }); + + await fetch(`${baseURL}/test-exception/123`); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); + + expect(errorEvent.request).toEqual({ + method: 'GET', + cookies: {}, + headers: expect.any(Object), + url: 'http://localhost:3030/test-exception/123', + }); + + expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/transactions.test.ts new file mode 100644 index 000000000000..fb11235943b2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/tests/transactions.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends an API route transaction', async ({ baseURL }) => { + const pageloadTransactionEventPromise = waitForTransaction('node-express-orchestrion', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + transactionEvent?.transaction === 'GET /test-transaction' + ); + }); + + await fetch(`${baseURL}/test-transaction`); + + const transactionEvent = await pageloadTransactionEventPromise; + + expect(transactionEvent.contexts?.trace).toEqual({ + data: { + 'sentry.source': 'route', + 'sentry.origin': 'auto.http.otel.http', + 'sentry.op': 'http.server', + 'sentry.sample_rate': 1, + url: 'http://localhost:3030/test-transaction', + 'otel.kind': 'SERVER', + 'http.response.status_code': 200, + 'http.url': 'http://localhost:3030/test-transaction', + 'http.host': 'localhost:3030', + 'net.host.name': 'localhost', + 'http.method': 'GET', + 'http.scheme': 'http', + 'http.target': '/test-transaction', + 'http.user_agent': 'node', + 'http.flavor': '1.1', + 'net.transport': 'ip_tcp', + 'net.host.ip': expect.any(String), + 'net.host.port': expect.any(Number), + 'net.peer.ip': expect.any(String), + 'net.peer.port': expect.any(Number), + 'http.status_code': 200, + 'http.status_text': 'OK', + 'http.route': '/test-transaction', + 'http.request.header.accept': '*/*', + 'http.request.header.accept_encoding': 'gzip, deflate', + 'http.request.header.accept_language': '*', + 'http.request.header.connection': 'keep-alive', + 'http.request.header.host': expect.any(String), + 'http.request.header.sec_fetch_mode': 'cors', + 'http.request.header.user_agent': 'node', + }, + op: 'http.server', + span_id: expect.stringMatching(/[a-f0-9]{16}/), + status: 'ok', + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + origin: 'auto.http.otel.http', + }); + + expect(transactionEvent.contexts?.response).toEqual({ + status_code: 200, + }); + + expect(transactionEvent).toEqual( + expect.objectContaining({ + transaction: 'GET /test-transaction', + type: 'transaction', + transaction_info: { + source: 'route', + }, + }), + ); + + const spans = transactionEvent.spans || []; + + // Manually started span + expect(spans).toContainEqual({ + data: { 'sentry.origin': 'manual' }, + description: 'test-span', + origin: 'manual', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); + + // auto instrumented span + expect(spans).toContainEqual({ + data: { + 'sentry.origin': 'auto.http.express', + 'sentry.op': 'request_handler.express', + 'http.route': '/test-transaction', + 'express.name': '/test-transaction', + 'express.type': 'request_handler', + }, + description: '/test-transaction', + op: 'request_handler.express', + origin: 'auto.http.express', + parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), + span_id: expect.stringMatching(/[a-f0-9]{16}/), + start_timestamp: expect.any(Number), + status: 'ok', + timestamp: expect.any(Number), + trace_id: expect.stringMatching(/[a-f0-9]{32}/), + }); +}); + +test('Sends an API route transaction for an errored route', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-orchestrion', transactionEvent => { + return ( + transactionEvent.contexts?.trace?.op === 'http.server' && + transactionEvent.transaction === 'GET /test-exception/:id' && + transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' + ); + }); + + await fetch(`${baseURL}/test-exception/777`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-exception/:id'); + expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); +}); + +test('Instruments MySQL via Orchestrion', async ({ baseURL }) => { + const transactionEventPromise = waitForTransaction('node-express-orchestrion', transactionEvent => { + return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /test-mysql'; + }); + + await fetch(`${baseURL}/test-mysql`); + + const transactionEvent = await transactionEventPromise; + + expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); + expect(transactionEvent.transaction).toEqual('GET /test-mysql'); + expect(transactionEvent.contexts?.trace?.status).toEqual('ok'); + expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(200); + + const spans = transactionEvent.spans || []; + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT 1 + 1 AS solution', + }), + ); + expect(spans).toContainEqual( + expect.objectContaining({ + op: 'db', + origin: 'auto.db.orchestrion.mysql', + description: 'SELECT NOW()', + }), + ); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs new file mode 100644 index 000000000000..0b1a81a0fb08 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs @@ -0,0 +1,14 @@ +import '@sentry/node/orchestrion'; +import * as Sentry from '@sentry/node'; +import { loggingTransport } from '@sentry-internal/node-integration-tests'; + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + _experimentalUseOrchestrion: true, + debug: true, +}); + +Sentry._experimentalSetupOrchestrion(client); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts index 2cb58e662a6b..a88ac0694adc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -32,45 +32,46 @@ describe('mysql auto instrumentation', () => { ]), }; - describe('with connection.connect()', () => { + describe.each([ + ['opentelemetry-based', 'instrument.mjs'], + ['orchestrion-based', 'instrument-orchestrion.mjs'], + ])('%s', (instrumentation, instrumentFile) => { + // esm is not supported for the otel instrumentation + const failsOnEsm = instrumentation === 'opentelemetry-based'; createEsmAndCjsTests( __dirname, 'scenario-withConnect.mjs', - 'instrument.mjs', - (createTestRunner, test) => { + instrumentFile, + (createRunner, test) => { test('should auto-instrument `mysql` package when using connection.connect()', async () => { - await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); }, - { failsOnEsm: true }, + { failsOnEsm }, ); - }); - describe('query without callback', () => { createEsmAndCjsTests( __dirname, 'scenario-withoutCallback.mjs', - 'instrument.mjs', - (createTestRunner, test) => { + instrumentFile, + (createRunner, test) => { test('should auto-instrument `mysql` package when using query without callback', async () => { - await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); }, - { failsOnEsm: true }, + { failsOnEsm }, ); - }); - describe('without connection.connect()', () => { createEsmAndCjsTests( __dirname, 'scenario-withoutConnect.mjs', - 'instrument.mjs', - (createTestRunner, test) => { + instrumentFile, + (createRunner, test) => { test('should auto-instrument `mysql` package without connection.connect()', async () => { - await createTestRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); }, - { failsOnEsm: true }, + { failsOnEsm }, ); }); }); diff --git a/packages/node/package.json b/packages/node/package.json index 6914dbb75ab0..92c287d2ea55 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -52,6 +52,27 @@ "require": { "default": "./build/cjs/preload.js" } + }, + "./orchestrion": { + "import": { + "default": "./build/orchestrion/import-hook.mjs" + } + }, + "./orchestrion/config": { + "import": { + "types": "./build/types/orchestrion/config.d.ts", + "default": "./build/esm/orchestrion/config.js" + }, + "require": { + "types": "./build/types/orchestrion/config.d.ts", + "default": "./build/cjs/orchestrion/config.js" + } + }, + "./orchestrion/vite": { + "import": { + "types": "./build/types/orchestrion/bundler/vite.d.ts", + "default": "./build/esm/orchestrion/bundler/vite.js" + } } }, "typesVersions": { @@ -65,6 +86,9 @@ "access": "public" }, "dependencies": { + "@apm-js-collab/code-transformer": "^0.13.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.3.0", + "@apm-js-collab/tracing-hooks": "^0.8.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", @@ -77,7 +101,16 @@ "import-in-the-middle": "^3.0.0" }, "devDependencies": { - "@types/node": "^18.19.1" + "@types/node": "^18.19.1", + "vite": "^5.0.0" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } }, "scripts": { "build": "run-p build:transpile build:types", @@ -103,5 +136,18 @@ "volta": { "extends": "../../package.json" }, - "sideEffects": false + "sideEffects": false, + "nx": { + "targets": { + "build:transpile": { + "outputs": [ + "{projectRoot}/build/esm", + "{projectRoot}/build/cjs", + "{projectRoot}/build/npm/esm", + "{projectRoot}/build/npm/cjs", + "{projectRoot}/build/orchestrion" + ] + } + } + } } diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 741c6ec27fe5..d870cbd883e3 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -1,10 +1,39 @@ +import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils'; +// EXPERIMENTAL — orchestrion.js runtime hook. A tiny hand-written `.mjs` shim +// that the user references via `node --import @sentry/node/orchestrion`. It +// installs both the ESM loader and a `Module.prototype._compile` patch, so it +// also covers CJS-internal `require()` calls — no separate `--require` hook is +// needed. We pass it through rollup only to copy it into `build/` at the path +// the package.json `exports` map expects; `external: /.*/` keeps every import +// (e.g. `@sentry/node/orchestrion/config`) as a runtime resolution against the +// installed package. +const orchestrionRuntimeHooks = [ + defineConfig({ + input: 'src/orchestrion/runtime/import-hook.mjs', + external: /.*/, + output: { format: 'esm', file: 'build/orchestrion/import-hook.mjs' }, + }), +]; + export default [ ...makeOtelLoaders('./build', 'otel'), + ...orchestrionRuntimeHooks, ...makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts'], + // `src/orchestrion/config.ts` and `src/orchestrion/bundler/vite.ts` are + // loaded via dedicated subpath exports (`@sentry/node/orchestrion/config`, + // `@sentry/node/orchestrion/vite`) — neither is reachable from `src/index.ts`, + // so we list them as separate entrypoints to guarantee they end up in + // build/esm and build/cjs. + entrypoints: [ + 'src/index.ts', + 'src/init.ts', + 'src/preload.ts', + 'src/orchestrion/config.ts', + 'src/orchestrion/bundler/vite.ts', + ], packageSpecificConfig: { external: [/^@sentry\/opentelemetry/], output: { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 791a8a8a5c6a..cff78492a37f 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -47,6 +47,7 @@ export { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations, } from './sdk'; +export { _experimentalSetupOrchestrion, mysqlChannelIntegration } from './orchestrion'; export { initOpenTelemetry, preloadOpenTelemetry } from './sdk/initOtel'; export { getAutoPerformanceIntegrations } from './integrations/tracing'; diff --git a/packages/node/src/integrations/tracing-channel/mysql.ts b/packages/node/src/integrations/tracing-channel/mysql.ts new file mode 100644 index 000000000000..690b6a904cf0 --- /dev/null +++ b/packages/node/src/integrations/tracing-channel/mysql.ts @@ -0,0 +1,281 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { + debug, + defineIntegration, + getActiveSpan, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SPAN_STATUS_ERROR, + startInactiveSpan, + withActiveSpan, +} from '@sentry/core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; + +const INTEGRATION_NAME = 'Mysql'; + +// OpenTelemetry "OLD" db/net semantic-conventions. We inline them rather than +// importing `@opentelemetry/semantic-conventions` to keep this integration's +// dependency surface free of OTel — orchestrion's whole point is to step away +// from the OTel auto-instrumentation stack. +// +// We emit the OLD conventions to match `@opentelemetry/instrumentation-mysql`'s +// default (it only emits the stable `db.system.name` / `db.query.text` set when +// `OTEL_SEMCONV_STABILITY_OPT_IN=database` is opted into) and the rest of the +// Sentry JS SDK, whose `inferDbSpanData` processor renames spans based on +// `db.statement`. +const ATTR_DB_SYSTEM = 'db.system'; +const ATTR_DB_CONNECTION_STRING = 'db.connection_string'; +const ATTR_DB_NAME = 'db.name'; +const ATTR_DB_USER = 'db.user'; +const ATTR_DB_STATEMENT = 'db.statement'; +const ATTR_NET_PEER_NAME = 'net.peer.name'; +const ATTR_NET_PEER_PORT = 'net.peer.port'; + +/** + * The shape orchestrion's wrapCallback transform attaches to the tracing-channel + * `context` object. Documented here rather than imported because orchestrion's + * runtime doesn't export it — see `node_modules/@apm-js-collab/code-transformer/lib/transforms.js`. + * + * `arguments` is the *live* args array the wrapper passes to the wrapped function: + * orchestrion splices the user's callback out and inserts its own wrapper at + * the same index before publishing `start`. We mutate that last entry again in + * our `start` hook so the callback (and any nested `connection.query(...)`) + * runs inside `withActiveSpan(parent, …)` — mysql v2 loses ALS state when it + * dispatches callbacks from its socket handler, which would otherwise cause + * nested queries to begin a fresh root trace. + */ +interface MysqlQueryChannelContext { + arguments: unknown[]; + self?: MysqlConnection; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +interface MysqlConnectionConfig { + host?: string; + port?: number | string; + database?: string; + user?: string; + // Pool connections nest the real config one level deeper. + connectionConfig?: MysqlConnectionConfig; +} + +interface MysqlConnection { + config?: MysqlConnectionConfig; +} + +const _mysqlChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + DEBUG_BUILD && debug.log(`[orchestrion:mysql] subscribing to channel "${CHANNELS.MYSQL_QUERY}"`); + const queryCh = tracingChannel(CHANNELS.MYSQL_QUERY); + + // Each `context` object is shared across start/end/asyncStart/asyncEnd/error + // for one call (orchestrion creates one per invocation). We key the span + // off the same identity. WeakMap so we don't leak if a path never reaches + // asyncEnd for some reason. + const spans = new WeakMap(); + + // `subscribe()` requires all five lifecycle hooks. The orchestrion + // `wrapAuto` transform fires events in one of four orders depending on + // call shape: + // - sync throw : start → error → end + // (NO asyncEnd) + // - async-callback error : start → end → error → + // asyncStart → asyncEnd + // - async-callback success : start → end → asyncStart → + // asyncEnd + // - no-callback (streamable Query) : start → end + // (ctx.result is the Query + // emitter, no async events) + // + // We end the span on `asyncEnd` for the two callback paths (so the span + // covers the full network round-trip + callback duration). For the + // sync-throw path, `end` finishes the span because `ctx.error` is set + // there. For the streamable no-callback path, `end` finishes by + // attaching `'end'`/`'error'` listeners to `ctx.result` (the returned + // `Query` emitter). + // + // The discriminator between "end fired before any error" and "end fired + // after a sync throw" is whether `ctx.error` is set when `end` runs — + // orchestrion populates it before publishing `error`. The discriminator + // between callback and no-callback is whether `ctx.result` is set — only + // the `wrapPromise` (no-callback) path stores it. + queryCh.subscribe({ + start(rawCtx) { + const ctx = rawCtx as MysqlQueryChannelContext; + const sql = extractSql(ctx.arguments[0]); + const { host, port, database, user } = getConnectionConfig(ctx.self); + const portNumber = typeof port === 'string' ? parseInt(port, 10) : port; + const portIsNumber = typeof portNumber === 'number' && !isNaN(portNumber); + + const span = startInactiveSpan({ + name: sql ?? 'mysql.query', + op: 'db', + attributes: { + [ATTR_DB_SYSTEM]: 'mysql', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.db.orchestrion.mysql', + [ATTR_DB_CONNECTION_STRING]: getJDBCString(host, portIsNumber ? portNumber : undefined, database), + ...(database ? { [ATTR_DB_NAME]: database } : {}), + ...(user ? { [ATTR_DB_USER]: user } : {}), + ...(sql ? { [ATTR_DB_STATEMENT]: sql } : {}), + ...(host ? { [ATTR_NET_PEER_NAME]: host } : {}), + ...(portIsNumber ? { [ATTR_NET_PEER_PORT]: portNumber } : {}), + }, + }); + spans.set(rawCtx, span); + + // Restore the Sentry/OTel context across mysql's internal callback + // dispatch. The orchestrion transform has already spliced the user's + // callback out of `ctx.arguments` and put its own wrapper + // (`__apm$wrappedCb`) at the same index. mysql v2 drains callbacks + // from a socket data handler — by the time the response arrives, the + // AsyncLocalStorage store backing `getActiveSpan()` no longer + // reflects the caller's context. We re-wrap orchestrion's wrapper so + // the user's callback (and any nested `connection.query(...)` inside + // it) runs with the parent span active again. + // + // This must happen at `start` (we're synchronously inside the + // caller's `connection.query` call, so OTel context is still + // correct). `asyncStart`/`asyncEnd` fire from the same lost context + // as the callback itself, so they're too late. + const parentSpan = getActiveSpan(); + if (parentSpan && ctx.arguments.length > 0) { + const cbIdx = ctx.arguments.length - 1; + const orchestrionWrappedCb = ctx.arguments[cbIdx]; + if (typeof orchestrionWrappedCb === 'function') { + const wrapped = orchestrionWrappedCb as (...a: unknown[]) => unknown; + ctx.arguments[cbIdx] = function (this: unknown, ...args: unknown[]): unknown { + return withActiveSpan(parentSpan, () => wrapped.apply(this, args)); + }; + } + } + }, + + end(rawCtx) { + const ctx = rawCtx as MysqlQueryChannelContext; + + // Sync throw: `end` fires AFTER `error` (both inside the wrapper's + // `try/catch/finally`), so `ctx.error` is already set. Close the + // span now since no `asyncEnd` will fire. + if (ctx.error !== undefined) { + finishSpan(rawCtx); + return; + } + + // No-callback (streamable Query) path: orchestrion's `wrapPromise` + // stores the synchronous return value on `ctx.result` and never + // fires `asyncStart`/`asyncEnd`. The returned `Query` is an + // `EventEmitter` that emits `'end'` on success and `'error'` on + // failure — hook those to close the span. + // TODO: streaming spans aren't finished on `connection.destroy()` — + // mysql guarantees no further events/callbacks for a destroyed + // connection, so neither `'end'` nor `'error'` fires and the span + // never ends (it's dropped, never reported). Closing this gap needs + // connection-level lifecycle hooks, which the per-query channel + // context doesn't expose here. The `WeakMap` still prevents a leak. + const result = ctx.result; + if (result && typeof result === 'object' && hasOnMethod(result)) { + const span = spans.get(rawCtx); + if (!span) return; + result.on('error', err => { + span.setStatus({ + code: SPAN_STATUS_ERROR, + message: err instanceof Error ? err.message : 'unknown_error', + }); + // Defensive: end the span here too in case `'end'` never fires + // (e.g. abrupt socket destruction). `finishSpan` is idempotent — + // `spans.delete` makes the subsequent `'end'` listener a no-op. + finishSpan(rawCtx); + }); + result.on('end', () => finishSpan(rawCtx)); + return; + } + + // Callback path: `asyncEnd` will close the span. Nothing to do here. + }, + + error(rawCtx) { + const ctx = rawCtx as MysqlQueryChannelContext; + const span = spans.get(rawCtx); + if (!span) return; + span.setStatus({ + code: SPAN_STATUS_ERROR, + message: ctx.error instanceof Error ? ctx.error.message : 'unknown_error', + }); + }, + + asyncStart() { + // No-op: we end on `asyncEnd` so the span covers the full callback duration. + }, + + asyncEnd(rawCtx) { + finishSpan(rawCtx); + }, + }); + + function finishSpan(rawCtx: object): void { + const span = spans.get(rawCtx); + if (!span) return; + span.end(); + spans.delete(rawCtx); + } + }, + }; +}) satisfies IntegrationFn; + +function hasOnMethod(obj: object): obj is { on: (event: string, listener: (arg?: unknown) => void) => unknown } { + return 'on' in obj && typeof (obj as { on?: unknown }).on === 'function'; +} + +function extractSql(firstArg: unknown): string | undefined { + if (typeof firstArg === 'string') { + return firstArg; + } + if (firstArg && typeof firstArg === 'object' && 'sql' in firstArg) { + const sql = (firstArg as { sql?: unknown }).sql; + return typeof sql === 'string' ? sql : undefined; + } + return undefined; +} + +function getConnectionConfig(connection: MysqlConnection | undefined): { + host?: string; + port?: number | string; + database?: string; + user?: string; +} { + // Pool connections nest the real config under `.connectionConfig`; single + // connections expose it directly. Matches `@opentelemetry/instrumentation-mysql`. + const config = connection?.config?.connectionConfig ?? connection?.config ?? {}; + return { + host: config.host, + port: config.port, + database: config.database, + user: config.user, + }; +} + +function getJDBCString(host: string | undefined, port: number | undefined, database: string | undefined): string { + let s = `jdbc:mysql://${host || 'localhost'}`; + if (typeof port === 'number') { + s += `:${port}`; + } + if (database) { + s += `/${database}`; + } + return s; +} + +/** + * EXPERIMENTAL — orchestrion-driven mysql integration. + * + * Subscribes to the `orchestrion:mysql:query` diagnostics_channel that the + * orchestrion code transform injects into `mysql/lib/Connection.js`'s + * `Connection.prototype.query`. Requires the orchestrion runtime hook or + * bundler plugin to be active — wire that up via `_experimentalSetupOrchestrion`. + */ +export const mysqlChannelIntegration = defineIntegration(_mysqlChannelIntegration); diff --git a/packages/node/src/orchestrion/bundler/vite.ts b/packages/node/src/orchestrion/bundler/vite.ts new file mode 100644 index 000000000000..7506f45912ec --- /dev/null +++ b/packages/node/src/orchestrion/bundler/vite.ts @@ -0,0 +1,86 @@ +// EXPERIMENTAL — Vite plugin that runs the orchestrion code transform at build +// time, injecting `diagnostics_channel.tracingChannel` calls into the libraries +// listed in `SENTRY_INSTRUMENTATIONS`. +// +// This file is published ESM-only via the `@sentry/node/orchestrion/vite` +// subpath export. `@apm-js-collab/code-transformer-bundler-plugins` is +// `"type": "module"`, so consuming it from a CJS build is intentionally +// unsupported — vite.config.ts is almost always ESM in practice. The CJS +// rollup variant still emits this file, but `package.json` only exposes the +// ESM entry, so attempts to `require('@sentry/node/orchestrion/vite')` will +// fail at resolution time rather than producing a half-broken plugin. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type UnknownPlugin = any; + +import { SENTRY_INSTRUMENTATIONS } from '../config'; +import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; + +// `vite` types live in the package's ESM-only subpath; under Node16 module +// resolution with TS treating @sentry/node as CJS, importing them produces a +// false positive. We don't need the runtime value for typing — `UnknownPlugin` +// is sufficient — so we omit the import entirely. + +/** + * Vite plugin that runs the orchestrion code transform on the bundled output. + * + * Use when bundling a Node app with Vite (e.g. Vite SSR builds, Nuxt's Nitro + * pipeline, SvelteKit). For unbundled Node processes use the runtime hook + * instead (`node --import @sentry/node/orchestrion app.js`). + * + * Returns two plugins: + * 1. `sentry-orchestrion-marker` — a `renderChunk` hook that prepends a + * single-line banner to entry chunks. The banner sets + * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at app boot, so the + * `_experimentalSetupOrchestrion()` detector can confirm the bundler path + * ran (rather than relying on a build-time flag that wouldn't be visible + * to the runtime). + * Also injects every instrumented package name into `ssr.noExternal` via + * the `config` hook, since externalized deps are `require()`d at runtime + * from `node_modules` and never pass through the transform. + * 2. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` + * plugin, fed our central `SENTRY_INSTRUMENTATIONS` config. + * + * @example + * ```ts + * // vite.config.ts + * import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; + * export default { plugins: [sentryOrchestrionPlugin()] }; + * ``` + */ +export function sentryOrchestrionPlugin(): UnknownPlugin[] { + const codeTransformerPlugins = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }); + const codeTransformerArray: UnknownPlugin[] = Array.isArray(codeTransformerPlugins) + ? codeTransformerPlugins + : [codeTransformerPlugins]; + return [bundlerMarkerPlugin(), ...codeTransformerArray]; +} + +function bundlerMarkerPlugin(): UnknownPlugin { + const banner = [ + 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', + 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', + '', + ].join('\n'); + + const instrumentedModules = Array.from(new Set(SENTRY_INSTRUMENTATIONS.map(i => i.module.name))); + + return { + name: 'sentry-orchestrion-marker', + enforce: 'pre' as const, + config(): { ssr: { noExternal: string[] } } { + // Force-bundle every instrumented package so the code transform actually + // sees its source. Vite externalizes dependencies in SSR builds by + // default, leaving them as bare `require()`/`import` calls resolved from + // `node_modules` at runtime — those copies are untouched and the + // diagnostics_channel calls never get injected. Vite merges array + // `noExternal` entries with the user's config, so we don't overwrite + // their additions. + return { ssr: { noExternal: instrumentedModules } }; + }, + renderChunk(code: string, chunk: { isEntry: boolean }): { code: string; map: null } | null { + if (!chunk.isEntry) return null; + return { code: banner + code, map: null }; + }, + }; +} diff --git a/packages/node/src/orchestrion/channels.ts b/packages/node/src/orchestrion/channels.ts new file mode 100644 index 000000000000..28dcf0c33468 --- /dev/null +++ b/packages/node/src/orchestrion/channels.ts @@ -0,0 +1,18 @@ +/** + * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. + * + * Orchestrion's transform always prefixes the configured `channelName` with + * `orchestrion:${module.name}:`. So a config of + * `{ channelName: 'query', module: { name: 'mysql' } }` + * publishes to `orchestrion:mysql:query`. + * + * Subscribers (`integrations//tracing-channel.ts`) consume the full + * prefixed string from this map; the config files set only the unprefixed + * suffix in `channelName`. Keeping both pieces in one file is what guarantees + * they don't drift apart and silently stop firing. + */ +export const CHANNELS = { + MYSQL_QUERY: 'orchestrion:mysql:query', +} as const; + +export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/node/src/orchestrion/config.ts b/packages/node/src/orchestrion/config.ts new file mode 100644 index 000000000000..03985cc64686 --- /dev/null +++ b/packages/node/src/orchestrion/config.ts @@ -0,0 +1,35 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +/** + * The central list of channel injections orchestrion should perform. + * + * This module has NO side effects — it's the only thing both the runtime hook + * (`runtime/import-hook.mjs`) and the bundler plugins (`bundler/vite.ts`, …) + * import from. Adding a new instrumented method is one entry here plus one + * subscriber in `integrations//tracing-channel.ts`. + * + * `channelName` here is the unprefixed suffix; the actual diagnostics_channel + * name is `orchestrion:${module.name}:${channelName}` (see `channels.ts`). + */ +export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ + { + channelName: 'query', + module: { name: 'mysql', versionRange: '>=2.0.0 <3', filePath: 'lib/Connection.js' }, + // `Connection` in mysql v2 is a constructor function (NOT a class): + // `function Connection(options) { ... }` + // `Connection.prototype.query = function query(sql, values, cb) { ... }` + // orchestrion's `className`+`methodName` query only matches `class` declarations. + // The named function expression on the right-hand side of the prototype + // assignment is what we want — that's matched by `expressionName: 'query'`, + // which produces the esquery selector + // `AssignmentExpression[left.property.name="query"] > FunctionExpression[async]`. + // `Auto` so both `connection.query(sql, cb)` and `connection.query(sql)` + // (streamable, no callback) get channel events. The transform picks + // `wrapCallback` when the last arg is a function and `wrapPromise` + // otherwise — for mysql's no-callback path the latter publishes + // `start`/`end` synchronously around the original call and stores the + // returned `Query` emitter on `ctx.result`, which the integration uses to + // attach `'end'`/`'error'` listeners that finish the span. + functionQuery: { expressionName: 'query', kind: 'Auto' }, + }, +]; diff --git a/packages/node/src/orchestrion/detect.ts b/packages/node/src/orchestrion/detect.ts new file mode 100644 index 000000000000..cb5dc13eff32 --- /dev/null +++ b/packages/node/src/orchestrion/detect.ts @@ -0,0 +1,31 @@ +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +declare global { + // eslint-disable-next-line no-var + var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; +} + +/** + * Verifies that orchestrion has been setup, either: + * - the runtime hook (`node --import @sentry/node/orchestrion app.js`), OR + * - the bundler plugin (`sentryOrchestrionPlugin()`) + */ +export function detectOrchestrionSetup(): void { + if (!DEBUG_BUILD) return; + + const marker = globalThis.__SENTRY_ORCHESTRION__; + const runtime = !!marker?.runtime; + const bundler = !!marker?.bundler; + + debug.log(`[orchestrion] detect: runtime=${runtime} bundler=${bundler}`); + + if (!runtime && !bundler) { + debug.warn( + '[Sentry] No orchestrion auto-instrumentation hook detected. Channel-based integrations ' + + '(mysql, …) will not record spans. Either run with ' + + '`node --import @sentry/node/orchestrion app.js`, or add `sentryOrchestrionPlugin()` ' + + 'to your bundler config.', + ); + } +} diff --git a/packages/node/src/orchestrion/index.ts b/packages/node/src/orchestrion/index.ts new file mode 100644 index 000000000000..4cd65c41648c --- /dev/null +++ b/packages/node/src/orchestrion/index.ts @@ -0,0 +1,3 @@ +export { _experimentalSetupOrchestrion } from './setup'; +export type { ExperimentalSetupOrchestrionOptions } from './setup'; +export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; diff --git a/packages/node/src/orchestrion/runtime/import-hook.mjs b/packages/node/src/orchestrion/runtime/import-hook.mjs new file mode 100644 index 000000000000..5c883005aa35 --- /dev/null +++ b/packages/node/src/orchestrion/runtime/import-hook.mjs @@ -0,0 +1,54 @@ +// EXPERIMENTAL — entry point for `node --import @sentry/node/orchestrion app.js`. +// +// Registers the orchestrion ESM loader with the central instrumentation config, +// and sets a global marker (`globalThis.__SENTRY_ORCHESTRION__.runtime`) so +// `detectOrchestrionSetup()` at `_experimentalSetupOrchestrion(client)` time can +// see that the runtime hook ran. +// +// This file is shipped as-is to `build/orchestrion/import-hook.mjs`. Keep it a +// single self-contained `.mjs` file with no relative-path imports — `--import` +// resolves it via Node's module resolution against the installed package. + +import { initialize, resolve, load } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'; +import ModulePatch from '@apm-js-collab/tracing-hooks'; +import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; + +const DEBUG = !!(process.env.DEBUG || process.env.debug || process.env.SENTRY_DEBUG); +// eslint-disable-next-line no-console +const debug = (...args) => DEBUG && console.log('[Sentry orchestrion]', ...args); + +debug('import-hook.mjs loaded, instrumentations:', SENTRY_INSTRUMENTATIONS); + +// detection to decide module loader hooks to use +// registerHooks was present but not stable until 24.13 and 25.1 +const version = (process.versions.node ?? '0.0.0').split('.').map(n => parseInt(n, 10)); +const stableSyncHooks = + version[0] > 25 || (version[0] === 25 && version[1] >= 1) || (version[0] === 24 && version[1] >= 13); + +const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); + +g.runtime = true; + +if (typeof Module.registerHooks === 'function' && stableSyncHooks) { + initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); + Module.registerHooks({ resolve, load }); + debug('Module.registerHooks() called for @apm-js-collab/tracing-hooks/hook-sync.mjs'); +} else if (typeof Module.register === 'function') { + Module.register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { + data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + }); + debug('Module.register() called for @apm-js-collab/tracing-hooks/hook.mjs'); + + // ALSO patch `Module.prototype._compile` for the CJS side: when + // an ESM file `import`s a CJS package, Node loads the package's + // entry through the ESM bridge but resolves the package's + // INTERNAL `require()` calls through the CJS machinery. + // Those internal requires never reach the ESM resolve hook, so + // without this patch the file we actually want to instrument is + // loaded untransformed. + // This isn't necessary in the registerHooks case, because Node + // applies those hooks to all CJS and ESM modules. + new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); +} else { + throw new Error('No available API to apply module load hooks'); +} diff --git a/packages/node/src/orchestrion/setup.ts b/packages/node/src/orchestrion/setup.ts new file mode 100644 index 000000000000..a4c405a63c84 --- /dev/null +++ b/packages/node/src/orchestrion/setup.ts @@ -0,0 +1,68 @@ +import type { Integration } from '@sentry/core'; +import { debug } from '@sentry/core'; +import type { NodeClient } from '@sentry/node-core'; +import { DEBUG_BUILD } from '../debug-build'; +import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; +import { detectOrchestrionSetup } from './detect'; + +export interface ExperimentalSetupOrchestrionOptions { + /** + * Override the default set of channel-based integrations. + * If omitted, all orchestrion integrations shipped by @sentry/node are added. + */ + integrations?: Integration[]; +} + +/** + * EXPERIMENTAL — wires up orchestrion-driven channel integrations. + * + * Must be called after `Sentry.init({ _experimentalUseOrchestrion: true })`, with + * the client returned by `init()`: + * + * ```ts + * const client = Sentry.init({ dsn: '…', _experimentalUseOrchestrion: true }); + * _experimentalSetupOrchestrion(client); + * ``` + * + * This is the ONLY exported entry into `packages/node/src/orchestrion/*`. Bundlers + * can statically determine that apps which never import this drop the entire + * `orchestrion/` subtree from their output — that is the tree-shaking guarantee. + */ +export function _experimentalSetupOrchestrion( + client: NodeClient | undefined, + options: ExperimentalSetupOrchestrionOptions = {}, +): void { + DEBUG_BUILD && debug.log('[orchestrion] _experimentalSetupOrchestrion() called'); + + if (!client) { + DEBUG_BUILD && + debug.warn( + '[Sentry] _experimentalSetupOrchestrion() was called without a client. ' + + 'Pass the value returned by `Sentry.init()`.', + ); + return; + } + + // Verify the user remembered to set the flag on init(). + const clientOptions = client.getOptions() as { _experimentalUseOrchestrion?: boolean }; + if (!clientOptions._experimentalUseOrchestrion) { + DEBUG_BUILD && + debug.warn( + '[Sentry] _experimentalSetupOrchestrion() called but Sentry.init() was not given ' + + '`_experimentalUseOrchestrion: true` — it will use default instrumentation instead of ' + + 'channel-based instrumentation. Add the flag to Sentry.init().', + ); + } + + detectOrchestrionSetup(); + + const integrations = options.integrations ?? [mysqlChannelIntegration()]; + DEBUG_BUILD && + debug.log( + '[orchestrion] registering channel integrations:', + integrations.map(i => i.name), + ); + for (const integration of integrations) { + client.addIntegration(integration); + } +} diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 6942c6500f84..ff9e174d02be 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -24,9 +24,22 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { .concat(httpIntegration(), nativeNodeFetchIntegration()); } +/** + * Names of OTel-based default integrations that the orchestrion experiment + * replaces with channel-based equivalents. When + * `_experimentalUseOrchestrion: true` is set on `Sentry.init()`, these are + * filtered out of the default integration list so the two systems don't both + * instrument the same library and produce duplicate spans. + * + * Kept as a plain string set (instead of importing the orchestrion integrations + * themselves) so the orchestrion code path stays tree-shakable: `init()` never + * pulls in anything from `../orchestrion/*`. + */ +const ORCHESTRION_REPLACED_INTEGRATIONS = new Set(['Mysql']); + /** Get the default integrations for the Node SDK. */ export function getDefaultIntegrations(options: Options): Integration[] { - return [ + const integrations: Integration[] = [ ...getDefaultIntegrationsWithoutPerformance(), // We only add performance integrations if tracing is enabled // Note that this means that without tracing enabled, e.g. `expressIntegration()` will not be added @@ -34,6 +47,11 @@ export function getDefaultIntegrations(options: Options): Integration[] { // But `transactionName` will not be set automatically ...(hasSpansEnabled(options) ? getAutoPerformanceIntegrations() : []), ]; + + if ((options as NodeOptions)._experimentalUseOrchestrion) { + return integrations.filter(i => !ORCHESTRION_REPLACED_INTEGRATIONS.has(i.name)); + } + return integrations; } /** diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 3a0cb1e7e5fc..869fd4098b78 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -65,6 +65,23 @@ export interface BaseNodeOptions extends OpenTelemetryServerRuntimeOptions { * Defaults to `true`. */ registerEsmLoaderHooks?: boolean; + + /** + * EXPERIMENTAL — opt into the orchestrion.js-based auto-instrumentation path. + * + * When `true`, `Sentry.init()` skips registering the default OTel + * auto-instrumentations for libraries that have a channel-based alternative + * (currently: `mysql`). It does NOT install any channel subscribers on its + * own — call `_experimentalSetupOrchestrion(client)` after `init()` for that. + * + * Splitting the opt-in across two calls keeps the orchestrion code path + * tree-shakable: bundlers can drop `orchestrion/*` from apps that don't + * import `_experimentalSetupOrchestrion`. + * + * Defaults to `false`. The flag name is intentionally underscore-prefixed and + * will be renamed or removed once the experiment graduates. + */ + _experimentalUseOrchestrion?: boolean; } /** diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index b0eb9ecb6476..0b9b93bef98b 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -4,4 +4,12 @@ "include": ["src/**/*"], "compilerOptions": {} + // The orchestrion runtime hooks are hand-written `.mjs` / `.cjs` files that + // self-reference `@sentry/node/orchestrion/config`. If tsc picks them up, it + // follows that subpath export back to `build/types/orchestrion/config.d.ts`, + // treats the .d.ts as an input, and then collides with the .d.ts it wants to + // emit from `src/orchestrion/config.ts`. Excluding them keeps tsc focused on + // the .ts sources — rollup copies these files through to `build/orchestrion/` + // unchanged. + "exclude": ["src/orchestrion/runtime/**/*.mjs", "src/orchestrion/runtime/**/*.cjs"] } diff --git a/packages/node/tsconfig.types.json b/packages/node/tsconfig.types.json index 65455f66bd75..8c1228d18c1d 100644 --- a/packages/node/tsconfig.types.json +++ b/packages/node/tsconfig.types.json @@ -5,6 +5,10 @@ "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, - "outDir": "build/types" + "outDir": "build/types", + // Required so Node16 module resolution can disambiguate package self-references + // (`@sentry/node/orchestrion/config` from inside this package) against the + // package's `.` export. Without this tsc reports TS2209. + "rootDir": "src" } } diff --git a/yarn.lock b/yarn.lock index 230a0e493deb..5389064314bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -379,6 +379,35 @@ dependencies: json-schema-to-ts "^3.1.1" +"@apm-js-collab/code-transformer-bundler-plugins@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.3.0.tgz#cc44d2bbfee3f83f2907f3f70b10601c342ec967" + integrity sha512-WjUUf8J+HFHLkA6mbzKyeq54B+WjGM4ZAiCkw0Xb9Nk1X2KJwCEkVScs3gRISuern1xRdsTtOYgihHvcd9mcSg== + dependencies: + "@apm-js-collab/code-transformer" "^0.13.0" + module-details-from-path "^1.0.4" + +"@apm-js-collab/code-transformer@^0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.13.0.tgz#3bb80cf17f2a09bc19faafb7c6133a5d057488e7" + integrity sha512-JPUR9mNUJV3SP0l6XQ5xGG/3IMOELzNy86vCq/+GOkIUsxEWC6AMIviAQ5sxrfQQEbQofjIzU3kshx4RQnRq7A== + dependencies: + "@types/estree" "^1.0.8" + astring "^1.9.0" + esquery "^1.7.0" + meriyah "^6.1.4" + semifies "^1.0.0" + source-map "^0.6.0" + +"@apm-js-collab/tracing-hooks@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.8.0.tgz#ab9df110b55f0ad9017521fd2f41f9a31fbe40dd" + integrity sha512-MIvMdVn71MyxevdshkSP8eu3lDtivhbJ5QrcKWg0EHT72GW30nVM+1wU6/CYL8bloHE+YZTU1AhIeSTz4Xfy3A== + dependencies: + "@apm-js-collab/code-transformer" "^0.13.0" + debug "^4.4.1" + module-details-from-path "^1.0.4" + "@apollo/cache-control-types@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz#5da62cf64c3b4419dabfef4536b57a40c8ff0b47" @@ -11233,10 +11262,10 @@ ast-walker-scope@^0.8.1: "@babel/parser" "^7.28.4" ast-kit "^2.1.3" -astring@^1.8.6: - version "1.8.6" - resolved "https://registry.yarnpkg.com/astring/-/astring-1.8.6.tgz#2c9c157cf1739d67561c56ba896e6948f6b93731" - integrity sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg== +astring@^1.8.6, astring@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/astring/-/astring-1.9.0.tgz#cc73e6062a7eb03e7d19c22d8b0b3451fd9bfeef" + integrity sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg== astro@^3.5.0: version "3.5.0" @@ -16494,10 +16523,10 @@ esprima@~3.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.0.0.tgz#53cf247acda77313e551c3aa2e73342d3fb4f7d9" integrity sha1-U88kes2ncxPlUcOqLnM0LT+099k= -esquery@^1.4.2, esquery@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" - integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== +esquery@^1.4.2, esquery@^1.6.0, esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== dependencies: estraverse "^5.1.0" @@ -21288,6 +21317,11 @@ merge2@^1.2.3, merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +meriyah@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/meriyah/-/meriyah-6.1.4.tgz#2d49a8934fbcd9205c20564579c3560d9b1e077b" + integrity sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ== + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -26829,6 +26863,11 @@ selfsigned@^2.0.1: "@types/node-forge" "^1.3.0" node-forge "^1" +semifies@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/semifies/-/semifies-1.0.0.tgz#b69569f32c2ba2ac04f705ea82831364289b2ae2" + integrity sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw== + semver-compare@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" From 5b9d089c8e1714187ab6d838168192e9ca222dd7 Mon Sep 17 00:00:00 2001 From: isaacs Date: Mon, 1 Jun 2026 16:26:05 -0700 Subject: [PATCH 2/3] ref(server-utils): move orchestrion into server-utils Also, fix the `import ... from 'node:module'` in the import-hook.mjs, which was broken. --- ORCHESTRIONJS_PLAN.md | 60 ++++++------ .../tracing/mysql/instrument-orchestrion.mjs | 4 +- .../suites/tracing/mysql/test.ts | 26 +++++- packages/node/package.json | 24 +---- packages/node/rollup.npm.config.mjs | 21 ++--- packages/node/src/orchestrion/bundler/vite.ts | 91 +----------------- packages/node/src/orchestrion/index.ts | 2 +- .../src/orchestrion/runtime/import-hook.mjs | 58 ++---------- packages/node/src/orchestrion/setup.ts | 51 +++------- packages/node/src/sdk/index.ts | 2 +- packages/node/tsconfig.json | 9 +- packages/node/tsconfig.types.json | 6 +- packages/server-utils/package.json | 84 ++++++++++++++++- packages/server-utils/rollup.npm.config.mjs | 52 ++++++++--- .../src/integrations/tracing-channel/mysql.ts | 2 + .../src/orchestrion/bundler/vite.ts | 92 +++++++++++++++++++ .../src/orchestrion/channels.ts | 0 .../src/orchestrion/config.ts | 0 .../src/orchestrion/detect.ts | 3 + .../server-utils/src/orchestrion/index.ts | 4 + .../src/orchestrion/runtime/import-hook.mjs | 65 +++++++++++++ .../server-utils/src/orchestrion/setup.ts | 55 +++++++++++ packages/server-utils/tsconfig.json | 9 +- packages/server-utils/tsconfig.types.json | 6 +- 24 files changed, 454 insertions(+), 272 deletions(-) rename packages/{node => server-utils}/src/integrations/tracing-channel/mysql.ts (98%) create mode 100644 packages/server-utils/src/orchestrion/bundler/vite.ts rename packages/{node => server-utils}/src/orchestrion/channels.ts (100%) rename packages/{node => server-utils}/src/orchestrion/config.ts (100%) rename packages/{node => server-utils}/src/orchestrion/detect.ts (88%) create mode 100644 packages/server-utils/src/orchestrion/index.ts create mode 100644 packages/server-utils/src/orchestrion/runtime/import-hook.mjs create mode 100644 packages/server-utils/src/orchestrion/setup.ts diff --git a/ORCHESTRIONJS_PLAN.md b/ORCHESTRIONJS_PLAN.md index 3794535df0aa..ef43b36fb5af 100644 --- a/ORCHESTRIONJS_PLAN.md +++ b/ORCHESTRIONJS_PLAN.md @@ -1,11 +1,25 @@ # Orchestrion.js Auto-Instrumentation Experiment Plan +> **Update (out of date):** the sections below describing a separate CJS +> `runtime/require-hook.cjs` (and a `require` arm on the `./orchestrion` subpath +> export) are obsolete. The implemented ESM `import-hook.mjs`, loaded via +> `--import`, already instruments **both** ESM and CJS user code — via +> `Module.registerHooks` where available, otherwise `Module.register` plus the +> CJS `Module._compile` patch (`ModulePatch.patch()`). So `--import` is the +> single runtime entry point for both module systems; no `require-hook`/`require` +> condition is needed. CJS apps run `node --import @sentry/node/orchestrion app.js`. +> > Experiment branch: `experiment/orchestrionjs-auto-instrumentation` > > Goal: prototype a future where `@sentry/node` does its own auto-instrumentation > via Node.js [`TracingChannel`](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel), > with channel injection driven by [orchestrion.js](https://github.com/nodejs/orchestrion-js) -> instead of OpenTelemetry's `require-in-the-middle` / `import-in-the-middle` machinery. +> instead of OpenTelemetry's `require-in-the-middle` / +> `import-in-the-middle` machinery. +> +> The `orchestrion.js` machinery lives in the shared +> `server-utils` package, for eventual use in the bun and deno +> SDKs, which will be done as a subsequent project. > > First target: the `mysql` integration. @@ -34,7 +48,7 @@ This means **one config array** can drive both the runtime hook and every bundle ## Architectural goals 1. **Integrations only know channels.** A Sentry integration (e.g. `mysqlIntegration`) subscribes to a published channel name and creates spans. It never imports orchestrion, never knows how the channel got there, and would work identically against a native `diagnostics_channel` that some library already publishes itself. -2. **Single source of truth for orchestrion config.** Channel names + module matchers + function queries live in **one** TypeScript module. Both the runtime hook and the bundler plugin import from it. Adding a new instrumentation = one edit. +2. **Single source of truth for orchestrion config.** Channel names + module matchers + function queries live in **one** TypeScript module. Both the runtime hook and the bundler plugin import from it. Adding a new instrumentation = one edit. When this config is being consumed by multiple SDKs, one edit can add instrumentation to multiple platforms. 3. **Two equally good user paths, one of which must be active.** - **Bundler path** (preferred when bundling): the user adds `sentryOrchestrionPlugin()` to their `vite.config.ts`. Nothing else. - **Runtime path** (preferred for unbundled Node servers): the user runs `node --import @sentry/node/orchestrion app.js` (ESM) or `node --require @sentry/node/orchestrion app.js` (CJS). The same import path resolves to the ESM `import-hook.mjs` or the CJS `require-hook.cjs` based on the active loader condition, so the user doesn't have to know which one to pick. @@ -74,7 +88,7 @@ All channel-consumer integrations live together under `integrations/tracing-chan ## Central config — the load-bearing file -`packages/node/src/orchestrion/channels.ts` +`packages/server-utils/src/orchestrion/channels.ts` ```ts // String constants shared between config.ts (producer) and integrations (consumer). @@ -85,7 +99,7 @@ export const CHANNELS = { } as const; ``` -`packages/node/src/orchestrion/config.ts` +`packages/server-utils/src/orchestrion/config.ts` ```ts import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; @@ -105,7 +119,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ## The integration — channel consumer -`packages/node/src/integrations/tracing-channel/mysql.ts` (sketch): +`packages/server-utils/src/integrations/tracing-channel/mysql.ts` (sketch): ```ts import { channel, tracingChannel } from 'node:diagnostics_channel'; @@ -160,8 +174,8 @@ Add to `packages/node/package.json`: "exports": { // … existing entries … "./orchestrion": { - // Single subpath, two condition arms — Node picks the right file based on - // whether the user passed `--import` (ESM hook) or `--require` (CJS hook). + // Only a --import hook is supported; this fully covers both + // CJS and ESM modules. "import": { "default": "./build/orchestrion/import-hook.mjs" }, "require": { "default": "./build/orchestrion/require-hook.cjs" } }, @@ -191,7 +205,7 @@ No `instrumentations: [...]` array to copy-paste, no channel names to remember. ## Runtime hook — `--import` ESM target -`packages/node/src/orchestrion/runtime/import-hook.mjs` +`packages/server-utils/src/orchestrion/runtime/import-hook.mjs` ```js import { register } from 'node:module'; @@ -210,26 +224,11 @@ if (g.runtime) { } ``` -`packages/node/src/orchestrion/runtime/require-hook.cjs` - -```js -const ModulePatch = require('@apm-js-collab/tracing-hooks'); -const { SENTRY_INSTRUMENTATIONS } = require('@sentry/node/orchestrion/config'); - -const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); -if (g.runtime) { - console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --require. Ignoring.'); -} else { - g.runtime = true; - new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); -} -``` - -Both files set `globalThis.__SENTRY_ORCHESTRION__.runtime = true`. That marker is how `detect.ts` knows the runtime path is active later. +The import hook sets `globalThis.__SENTRY_ORCHESTRION__.runtime = true`. That marker is how `detect.ts` knows the runtime path is active later. ## Vite plugin — build-time path -`packages/node/src/orchestrion/bundler/vite.ts` +`packages/server-utils/src/orchestrion/bundler/vite.ts` ```ts import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; @@ -266,7 +265,7 @@ the plugin injects runtime JS into the bundle, not just a build-time flag. Build ## Detection — `detect.ts` -`packages/node/src/orchestrion/detect.ts` +`packages/server-utils/src/orchestrion/detect.ts` ```ts import { logger } from '@sentry/core'; @@ -303,10 +302,15 @@ export function detectOrchestrionSetup(): void { The opt-in is deliberately split so the orchestrion code path stays tree-shakable. `Sentry.init()` only learns about a boolean flag; it does **not** import anything from `orchestrion/`. The orchestrion-specific code only runs if the user explicitly imports and calls `_experimentalSetupOrchestrion()` after `init()`. +This is used by for the Node orchestrion integration to keep it +opt-in, since it replaces existing working integrations. Other +SDKs that use orchestrion for net-new integrations will be +automatically enabled if available. + ### Step 1 — `_experimentalUseOrchestrion` flag on `NodeOptions` ```ts -// packages/node-core/src/types.ts (or wherever NodeOptions lives) +// packages/node/src/types.ts (or wherever NodeOptions lives) export interface NodeOptions extends ClientOptions { // … existing options … /** @@ -340,7 +344,7 @@ const ORCHESTRION_REPLACED_INTEGRATIONS = new Set([ ]); ``` -The list of replaced integration names is a plain string set defined alongside `init()` itself — it does not import from `orchestrion/`, so toggling the flag doesn't pull orchestrion code into a user's bundle. +The list of replaced integration names is a plain string set defined alongside `init()` itself — it does not import from `server-utils/orchestrion/`, so toggling the flag doesn't pull orchestrion code into a user's bundle. ### Step 2 — `_experimentalSetupOrchestrion()` as a separate export diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs index 0b1a81a0fb08..02bdca0e776d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs @@ -1,4 +1,6 @@ -import '@sentry/node/orchestrion'; +// The orchestrion runtime hook is loaded via the `--import @sentry/node/orchestrion` +// CLI flag (see test.ts), mirroring real usage. That single ESM hook instruments +// both ESM and CJS user code, so the same flag works for the esm and cjs scenarios. import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts index a88ac0694adc..1bea6b25706b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -38,13 +38,25 @@ describe('mysql auto instrumentation', () => { ])('%s', (instrumentation, instrumentFile) => { // esm is not supported for the otel instrumentation const failsOnEsm = instrumentation === 'opentelemetry-based'; + + // The orchestrion path is activated via the `--import @sentry/node/orchestrion` + // CLI flag. That single ESM hook instruments both ESM and CJS user code (via + // `Module.registerHooks` where available, otherwise `Module.register` + the + // CJS `Module._compile` patch), so the same flag covers the esm and cjs + // scenarios. The OTel path needs no extra flag. + const orchestrionFlags = instrumentation === 'orchestrion-based' ? ['--import', '@sentry/node/orchestrion'] : []; + createEsmAndCjsTests( __dirname, 'scenario-withConnect.mjs', instrumentFile, (createRunner, test) => { test('should auto-instrument `mysql` package when using connection.connect()', async () => { - await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + await createRunner() + .withFlags(...orchestrionFlags) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); }); }, { failsOnEsm }, @@ -56,7 +68,11 @@ describe('mysql auto instrumentation', () => { instrumentFile, (createRunner, test) => { test('should auto-instrument `mysql` package when using query without callback', async () => { - await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + await createRunner() + .withFlags(...orchestrionFlags) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); }); }, { failsOnEsm }, @@ -68,7 +84,11 @@ describe('mysql auto instrumentation', () => { instrumentFile, (createRunner, test) => { test('should auto-instrument `mysql` package without connection.connect()', async () => { - await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + await createRunner() + .withFlags(...orchestrionFlags) + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); }); }, { failsOnEsm }, diff --git a/packages/node/package.json b/packages/node/package.json index 92c287d2ea55..33f3ce6f39ad 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -58,16 +58,6 @@ "default": "./build/orchestrion/import-hook.mjs" } }, - "./orchestrion/config": { - "import": { - "types": "./build/types/orchestrion/config.d.ts", - "default": "./build/esm/orchestrion/config.js" - }, - "require": { - "types": "./build/types/orchestrion/config.d.ts", - "default": "./build/cjs/orchestrion/config.js" - } - }, "./orchestrion/vite": { "import": { "types": "./build/types/orchestrion/bundler/vite.d.ts", @@ -86,9 +76,6 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer": "^0.13.0", - "@apm-js-collab/code-transformer-bundler-plugins": "^0.3.0", - "@apm-js-collab/tracing-hooks": "^0.8.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", @@ -101,16 +88,7 @@ "import-in-the-middle": "^3.0.0" }, "devDependencies": { - "@types/node": "^18.19.1", - "vite": "^5.0.0" - }, - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } + "@types/node": "^18.19.1" }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index d870cbd883e3..ac547f786201 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -7,8 +7,8 @@ import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sent // also covers CJS-internal `require()` calls — no separate `--require` hook is // needed. We pass it through rollup only to copy it into `build/` at the path // the package.json `exports` map expects; `external: /.*/` keeps every import -// (e.g. `@sentry/node/orchestrion/config`) as a runtime resolution against the -// installed package. +// (e.g. `@sentry/server-utils/orchestrion/import-hook`) as a runtime +// resolution against the installed package. const orchestrionRuntimeHooks = [ defineConfig({ input: 'src/orchestrion/runtime/import-hook.mjs', @@ -22,18 +22,11 @@ export default [ ...orchestrionRuntimeHooks, ...makeNPMConfigVariants( makeBaseNPMConfig({ - // `src/orchestrion/config.ts` and `src/orchestrion/bundler/vite.ts` are - // loaded via dedicated subpath exports (`@sentry/node/orchestrion/config`, - // `@sentry/node/orchestrion/vite`) — neither is reachable from `src/index.ts`, - // so we list them as separate entrypoints to guarantee they end up in - // build/esm and build/cjs. - entrypoints: [ - 'src/index.ts', - 'src/init.ts', - 'src/preload.ts', - 'src/orchestrion/config.ts', - 'src/orchestrion/bundler/vite.ts', - ], + // `src/orchestrion/bundler/vite.ts` is loaded via the dedicated + // `@sentry/node/orchestrion/vite` subpath export and is not reachable from + // `src/index.ts`, so we list it as a separate entrypoint to guarantee it + // ends up in build/esm and build/cjs. + entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts', 'src/orchestrion/bundler/vite.ts'], packageSpecificConfig: { external: [/^@sentry\/opentelemetry/], output: { diff --git a/packages/node/src/orchestrion/bundler/vite.ts b/packages/node/src/orchestrion/bundler/vite.ts index 7506f45912ec..0939415fcd5f 100644 --- a/packages/node/src/orchestrion/bundler/vite.ts +++ b/packages/node/src/orchestrion/bundler/vite.ts @@ -1,86 +1,5 @@ -// EXPERIMENTAL — Vite plugin that runs the orchestrion code transform at build -// time, injecting `diagnostics_channel.tracingChannel` calls into the libraries -// listed in `SENTRY_INSTRUMENTATIONS`. -// -// This file is published ESM-only via the `@sentry/node/orchestrion/vite` -// subpath export. `@apm-js-collab/code-transformer-bundler-plugins` is -// `"type": "module"`, so consuming it from a CJS build is intentionally -// unsupported — vite.config.ts is almost always ESM in practice. The CJS -// rollup variant still emits this file, but `package.json` only exposes the -// ESM entry, so attempts to `require('@sentry/node/orchestrion/vite')` will -// fail at resolution time rather than producing a half-broken plugin. - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type UnknownPlugin = any; - -import { SENTRY_INSTRUMENTATIONS } from '../config'; -import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; - -// `vite` types live in the package's ESM-only subpath; under Node16 module -// resolution with TS treating @sentry/node as CJS, importing them produces a -// false positive. We don't need the runtime value for typing — `UnknownPlugin` -// is sufficient — so we omit the import entirely. - -/** - * Vite plugin that runs the orchestrion code transform on the bundled output. - * - * Use when bundling a Node app with Vite (e.g. Vite SSR builds, Nuxt's Nitro - * pipeline, SvelteKit). For unbundled Node processes use the runtime hook - * instead (`node --import @sentry/node/orchestrion app.js`). - * - * Returns two plugins: - * 1. `sentry-orchestrion-marker` — a `renderChunk` hook that prepends a - * single-line banner to entry chunks. The banner sets - * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at app boot, so the - * `_experimentalSetupOrchestrion()` detector can confirm the bundler path - * ran (rather than relying on a build-time flag that wouldn't be visible - * to the runtime). - * Also injects every instrumented package name into `ssr.noExternal` via - * the `config` hook, since externalized deps are `require()`d at runtime - * from `node_modules` and never pass through the transform. - * 2. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` - * plugin, fed our central `SENTRY_INSTRUMENTATIONS` config. - * - * @example - * ```ts - * // vite.config.ts - * import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; - * export default { plugins: [sentryOrchestrionPlugin()] }; - * ``` - */ -export function sentryOrchestrionPlugin(): UnknownPlugin[] { - const codeTransformerPlugins = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }); - const codeTransformerArray: UnknownPlugin[] = Array.isArray(codeTransformerPlugins) - ? codeTransformerPlugins - : [codeTransformerPlugins]; - return [bundlerMarkerPlugin(), ...codeTransformerArray]; -} - -function bundlerMarkerPlugin(): UnknownPlugin { - const banner = [ - 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', - 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', - '', - ].join('\n'); - - const instrumentedModules = Array.from(new Set(SENTRY_INSTRUMENTATIONS.map(i => i.module.name))); - - return { - name: 'sentry-orchestrion-marker', - enforce: 'pre' as const, - config(): { ssr: { noExternal: string[] } } { - // Force-bundle every instrumented package so the code transform actually - // sees its source. Vite externalizes dependencies in SSR builds by - // default, leaving them as bare `require()`/`import` calls resolved from - // `node_modules` at runtime — those copies are untouched and the - // diagnostics_channel calls never get injected. Vite merges array - // `noExternal` entries with the user's config, so we don't overwrite - // their additions. - return { ssr: { noExternal: instrumentedModules } }; - }, - renderChunk(code: string, chunk: { isEntry: boolean }): { code: string; map: null } | null { - if (!chunk.isEntry) return null; - return { code: banner + code, map: null }; - }, - }; -} +// Re-export of the shared orchestrion Vite plugin. The implementation lives in +// `@sentry/server-utils`; this file preserves the +// `@sentry/node/orchestrion/vite` subpath export. ESM-only, matching the +// upstream `@apm-js-collab/code-transformer-bundler-plugins` package. +export { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; diff --git a/packages/node/src/orchestrion/index.ts b/packages/node/src/orchestrion/index.ts index 4cd65c41648c..34b63a9c8076 100644 --- a/packages/node/src/orchestrion/index.ts +++ b/packages/node/src/orchestrion/index.ts @@ -1,3 +1,3 @@ export { _experimentalSetupOrchestrion } from './setup'; export type { ExperimentalSetupOrchestrionOptions } from './setup'; -export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; +export { mysqlChannelIntegration } from '@sentry/server-utils/orchestrion'; diff --git a/packages/node/src/orchestrion/runtime/import-hook.mjs b/packages/node/src/orchestrion/runtime/import-hook.mjs index 5c883005aa35..36d06e8622e3 100644 --- a/packages/node/src/orchestrion/runtime/import-hook.mjs +++ b/packages/node/src/orchestrion/runtime/import-hook.mjs @@ -1,54 +1,8 @@ // EXPERIMENTAL — entry point for `node --import @sentry/node/orchestrion app.js`. // -// Registers the orchestrion ESM loader with the central instrumentation config, -// and sets a global marker (`globalThis.__SENTRY_ORCHESTRION__.runtime`) so -// `detectOrchestrionSetup()` at `_experimentalSetupOrchestrion(client)` time can -// see that the runtime hook ran. -// -// This file is shipped as-is to `build/orchestrion/import-hook.mjs`. Keep it a -// single self-contained `.mjs` file with no relative-path imports — `--import` -// resolves it via Node's module resolution against the installed package. - -import { initialize, resolve, load } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'; -import ModulePatch from '@apm-js-collab/tracing-hooks'; -import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; - -const DEBUG = !!(process.env.DEBUG || process.env.debug || process.env.SENTRY_DEBUG); -// eslint-disable-next-line no-console -const debug = (...args) => DEBUG && console.log('[Sentry orchestrion]', ...args); - -debug('import-hook.mjs loaded, instrumentations:', SENTRY_INSTRUMENTATIONS); - -// detection to decide module loader hooks to use -// registerHooks was present but not stable until 24.13 and 25.1 -const version = (process.versions.node ?? '0.0.0').split('.').map(n => parseInt(n, 10)); -const stableSyncHooks = - version[0] > 25 || (version[0] === 25 && version[1] >= 1) || (version[0] === 24 && version[1] >= 13); - -const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); - -g.runtime = true; - -if (typeof Module.registerHooks === 'function' && stableSyncHooks) { - initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); - Module.registerHooks({ resolve, load }); - debug('Module.registerHooks() called for @apm-js-collab/tracing-hooks/hook-sync.mjs'); -} else if (typeof Module.register === 'function') { - Module.register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { - data: { instrumentations: SENTRY_INSTRUMENTATIONS }, - }); - debug('Module.register() called for @apm-js-collab/tracing-hooks/hook.mjs'); - - // ALSO patch `Module.prototype._compile` for the CJS side: when - // an ESM file `import`s a CJS package, Node loads the package's - // entry through the ESM bridge but resolves the package's - // INTERNAL `require()` calls through the CJS machinery. - // Those internal requires never reach the ESM resolve hook, so - // without this patch the file we actually want to instrument is - // loaded untransformed. - // This isn't necessary in the registerHooks case, because Node - // applies those hooks to all CJS and ESM modules. - new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); -} else { - throw new Error('No available API to apply module load hooks'); -} +// Delegates to the shared orchestrion runtime hook in +// `@sentry/server-utils`, which registers the orchestrion ESM loader +// (and CJS `Module.prototype._compile` patch) with the central instrumentation +// config and sets `globalThis.__SENTRY_ORCHESTRION__.runtime`. Kept as a thin +// node-resident shim so the `@sentry/node/orchestrion` subpath keeps working. +import '@sentry/server-utils/orchestrion/import-hook'; diff --git a/packages/node/src/orchestrion/setup.ts b/packages/node/src/orchestrion/setup.ts index a4c405a63c84..2d5b47b8f3e0 100644 --- a/packages/node/src/orchestrion/setup.ts +++ b/packages/node/src/orchestrion/setup.ts @@ -1,17 +1,10 @@ -import type { Integration } from '@sentry/core'; import { debug } from '@sentry/core'; +import type { SetupOrchestrionOptions } from '@sentry/server-utils/orchestrion'; +import { setupOrchestrion } from '@sentry/server-utils/orchestrion'; import type { NodeClient } from '@sentry/node-core'; import { DEBUG_BUILD } from '../debug-build'; -import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; -import { detectOrchestrionSetup } from './detect'; -export interface ExperimentalSetupOrchestrionOptions { - /** - * Override the default set of channel-based integrations. - * If omitted, all orchestrion integrations shipped by @sentry/node are added. - */ - integrations?: Integration[]; -} +export type ExperimentalSetupOrchestrionOptions = SetupOrchestrionOptions; /** * EXPERIMENTAL — wires up orchestrion-driven channel integrations. @@ -24,28 +17,22 @@ export interface ExperimentalSetupOrchestrionOptions { * _experimentalSetupOrchestrion(client); * ``` * - * This is the ONLY exported entry into `packages/node/src/orchestrion/*`. Bundlers - * can statically determine that apps which never import this drop the entire + * This is the ONLY exported entry into the orchestrion code path. Bundlers can + * statically determine that apps which never import this drop the entire * `orchestrion/` subtree from their output — that is the tree-shaking guarantee. + * + * The actual implementation lives in `@sentry/server-utils`; this Node + * wrapper only adds the experimental opt-in check tied to `NodeOptions`. */ export function _experimentalSetupOrchestrion( client: NodeClient | undefined, options: ExperimentalSetupOrchestrionOptions = {}, ): void { - DEBUG_BUILD && debug.log('[orchestrion] _experimentalSetupOrchestrion() called'); - - if (!client) { - DEBUG_BUILD && - debug.warn( - '[Sentry] _experimentalSetupOrchestrion() was called without a client. ' + - 'Pass the value returned by `Sentry.init()`.', - ); - return; - } - - // Verify the user remembered to set the flag on init(). - const clientOptions = client.getOptions() as { _experimentalUseOrchestrion?: boolean }; - if (!clientOptions._experimentalUseOrchestrion) { + // Node-specific: verify the user remembered to set the experimental flag on + // init(), which is what makes `init()` skip the OTel integrations these + // channel-based ones replace. Without it, both systems instrument the same + // library and produce duplicate spans. + if (client && !(client.getOptions() as { _experimentalUseOrchestrion?: boolean })._experimentalUseOrchestrion) { DEBUG_BUILD && debug.warn( '[Sentry] _experimentalSetupOrchestrion() called but Sentry.init() was not given ' + @@ -54,15 +41,5 @@ export function _experimentalSetupOrchestrion( ); } - detectOrchestrionSetup(); - - const integrations = options.integrations ?? [mysqlChannelIntegration()]; - DEBUG_BUILD && - debug.log( - '[orchestrion] registering channel integrations:', - integrations.map(i => i.name), - ); - for (const integration of integrations) { - client.addIntegration(integration); - } + setupOrchestrion(client, options); } diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index ff9e174d02be..49632b8c3998 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -33,7 +33,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { * * Kept as a plain string set (instead of importing the orchestrion integrations * themselves) so the orchestrion code path stays tree-shakable: `init()` never - * pulls in anything from `../orchestrion/*`. + * pulls in anything from `server-utils/orchestrion/*`. */ const ORCHESTRION_REPLACED_INTEGRATIONS = new Set(['Mysql']); diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index 0b9b93bef98b..c4c847b9512d 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -5,11 +5,8 @@ "compilerOptions": {} // The orchestrion runtime hooks are hand-written `.mjs` / `.cjs` files that - // self-reference `@sentry/node/orchestrion/config`. If tsc picks them up, it - // follows that subpath export back to `build/types/orchestrion/config.d.ts`, - // treats the .d.ts as an input, and then collides with the .d.ts it wants to - // emit from `src/orchestrion/config.ts`. Excluding them keeps tsc focused on - // the .ts sources — rollup copies these files through to `build/orchestrion/` - // unchanged. + // rollup copies through to `build/orchestrion/` unchanged. We exclude them so + // tsc doesn't try to type-check or emit declarations for them and stays + // focused on the `.ts` sources. "exclude": ["src/orchestrion/runtime/**/*.mjs", "src/orchestrion/runtime/**/*.cjs"] } diff --git a/packages/node/tsconfig.types.json b/packages/node/tsconfig.types.json index 8c1228d18c1d..65455f66bd75 100644 --- a/packages/node/tsconfig.types.json +++ b/packages/node/tsconfig.types.json @@ -5,10 +5,6 @@ "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, - "outDir": "build/types", - // Required so Node16 module resolution can disambiguate package self-references - // (`@sentry/node/orchestrion/config` from inside this package) against the - // package's `.` export. Without this tsc reports TS2209. - "rootDir": "src" + "outDir": "build/types" } } diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index c775339201c0..088b5d968d41 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -26,12 +26,63 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } + }, + "./orchestrion": { + "import": { + "types": "./build/types/orchestrion/index.d.ts", + "default": "./build/esm/orchestrion/index.js" + }, + "require": { + "types": "./build/types/orchestrion/index.d.ts", + "default": "./build/cjs/orchestrion/index.js" + } + }, + "./orchestrion/config": { + "import": { + "types": "./build/types/orchestrion/config.d.ts", + "default": "./build/esm/orchestrion/config.js" + }, + "require": { + "types": "./build/types/orchestrion/config.d.ts", + "default": "./build/cjs/orchestrion/config.js" + } + }, + "./orchestrion/vite": { + "types": "./build/types/orchestrion/bundler/vite.d.ts", + "import": { + "default": "./build/esm/orchestrion/bundler/vite.js" + } + }, + "./orchestrion/import-hook": { + "import": { + "default": "./build/orchestrion/import-hook.mjs" + } } }, "typesVersions": { "<5.0": { "build/types/index.d.ts": [ "build/types-ts3.8/index.d.ts" + ], + "orchestrion": [ + "build/types-ts3.8/orchestrion/index.d.ts" + ], + "orchestrion/config": [ + "build/types-ts3.8/orchestrion/config.d.ts" + ], + "orchestrion/vite": [ + "build/types-ts3.8/orchestrion/bundler/vite.d.ts" + ] + }, + "*": { + "orchestrion": [ + "build/types/orchestrion/index.d.ts" + ], + "orchestrion/config": [ + "build/types/orchestrion/config.d.ts" + ], + "orchestrion/vite": [ + "build/types/orchestrion/bundler/vite.d.ts" ] } }, @@ -39,8 +90,24 @@ "access": "public" }, "dependencies": { + "@apm-js-collab/code-transformer-bundler-plugins": "^0.3.0", + "@apm-js-collab/code-transformer": "^0.13.0", + "@apm-js-collab/tracing-hooks": "^0.8.0", "@sentry/conventions": "^0.12.0", - "@sentry/core": "10.58.0" + "@sentry/core": "10.58.0", + "magic-string": "~0.30.0" + }, + "devDependencies": { + "@types/node": "^18.19.1", + "vite": "^5.0.0" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } }, "scripts": { "build": "run-p build:transpile build:types", @@ -65,5 +132,18 @@ "volta": { "extends": "../../package.json" }, - "sideEffects": false + "sideEffects": false, + "nx": { + "targets": { + "build:transpile": { + "outputs": [ + "{projectRoot}/build/esm", + "{projectRoot}/build/cjs", + "{projectRoot}/build/npm/esm", + "{projectRoot}/build/npm/cjs", + "{projectRoot}/build/orchestrion" + ] + } + } + } } diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 7416307b5bac..161e42eb5c23 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -1,14 +1,44 @@ +import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants( - makeBaseNPMConfig({ - packageSpecificConfig: { - output: { - // set exports to 'named' or 'auto' so that rollup doesn't warn - exports: 'named', - // set preserveModules to true because we don't want to bundle everything into one file. - preserveModules: true, - }, - }, +// EXPERIMENTAL — orchestrion.js runtime hook. A hand-written `.mjs` shim that +// SDKs reference via a `--import .../orchestrion/import-hook` flag. We pass it +// through rollup only to copy it into `build/orchestrion/` at the path the +// package.json `exports` map expects; `external: /.*/` keeps every import (e.g. +// `@sentry/server-utils/orchestrion/config`) as a runtime resolution +// against the installed package. +const orchestrionRuntimeHooks = [ + defineConfig({ + input: 'src/orchestrion/runtime/import-hook.mjs', + external: /.*/, + output: { format: 'esm', file: 'build/orchestrion/import-hook.mjs' }, }), -); +]; + +export default [ + ...orchestrionRuntimeHooks, + ...makeNPMConfigVariants( + makeBaseNPMConfig({ + // `src/orchestrion/config.ts` and `src/orchestrion/bundler/vite.ts` are + // loaded via dedicated subpath exports (`.../orchestrion/config`, + // `.../orchestrion/vite`) — neither is reachable from `src/index.ts`, so we + // list them as separate entrypoints to guarantee they end up in build/esm + // and build/cjs. `src/orchestrion/index.ts` backs the `./orchestrion` + // subpath export. + entrypoints: [ + 'src/index.ts', + 'src/orchestrion/index.ts', + 'src/orchestrion/config.ts', + 'src/orchestrion/bundler/vite.ts', + ], + packageSpecificConfig: { + output: { + // set exports to 'named' or 'auto' so that rollup doesn't warn + exports: 'named', + // set preserveModules to true because we don't want to bundle everything into one file. + preserveModules: true, + }, + }, + }), + ), +]; diff --git a/packages/node/src/integrations/tracing-channel/mysql.ts b/packages/server-utils/src/integrations/tracing-channel/mysql.ts similarity index 98% rename from packages/node/src/integrations/tracing-channel/mysql.ts rename to packages/server-utils/src/integrations/tracing-channel/mysql.ts index 690b6a904cf0..5384b84f95f5 100644 --- a/packages/node/src/integrations/tracing-channel/mysql.ts +++ b/packages/server-utils/src/integrations/tracing-channel/mysql.ts @@ -12,6 +12,8 @@ import { import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; +// NOTE: this uses the same name as the OTel integration by design. +// When enabled, OTel 'Mysql' integration is omitted from the default set. const INTEGRATION_NAME = 'Mysql'; // OpenTelemetry "OLD" db/net semantic-conventions. We inline them rather than diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts new file mode 100644 index 000000000000..52032d4ae3a3 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -0,0 +1,92 @@ +// EXPERIMENTAL — Vite plugin that runs the orchestrion code transform at build +// time, injecting `diagnostics_channel.tracingChannel` calls into the libraries +// listed in `SENTRY_INSTRUMENTATIONS`. +// +// This file is published ESM-only via the `@sentry/node/orchestrion/vite` +// subpath export. `@apm-js-collab/code-transformer-bundler-plugins` is +// `"type": "module"`, so consuming it from a CJS build is intentionally +// unsupported — vite.config.ts is almost always ESM in practice. The CJS +// rollup variant still emits this file, but `package.json` only exposes the +// ESM entry, so attempts to `require('@sentry/node/orchestrion/vite')` will +// fail at resolution time rather than producing a half-broken plugin. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type UnknownPlugin = any; + +import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; +import MagicString from 'magic-string'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; + +// `vite` types live in the package's ESM-only subpath; under Node16 module +// resolution with TS treating @sentry/node as CJS, importing them produces a +// false positive. We don't need the runtime value for typing — `UnknownPlugin` +// is sufficient — so we omit the import entirely. + +/** + * Vite plugin that runs the orchestrion code transform on the bundled output. + * + * Use when bundling a Node app with Vite (e.g. Vite SSR builds, Nuxt's Nitro + * pipeline, SvelteKit). For unbundled Node processes use the runtime hook + * instead (`node --import @sentry/node/orchestrion app.js`). + * + * Returns two plugins: + * 1. `sentry-orchestrion-marker` — a `renderChunk` hook that prepends a + * single-line banner to entry chunks. The banner sets + * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at app boot, so the + * `_experimentalSetupOrchestrion()` detector can confirm the bundler path + * ran (rather than relying on a build-time flag that wouldn't be visible + * to the runtime). + * Also injects every instrumented package name into `ssr.noExternal` via + * the `config` hook, since externalized deps are `require()`d at runtime + * from `node_modules` and never pass through the transform. + * 2. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` + * plugin, fed our central `SENTRY_INSTRUMENTATIONS` config. + * + * @example + * ```ts + * // vite.config.ts + * import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; + * export default { plugins: [sentryOrchestrionPlugin()] }; + * ``` + */ +export function sentryOrchestrionPlugin(): UnknownPlugin[] { + const codeTransformerPlugins = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }); + const codeTransformerArray: UnknownPlugin[] = Array.isArray(codeTransformerPlugins) + ? codeTransformerPlugins + : [codeTransformerPlugins]; + return [bundlerMarkerPlugin(), ...codeTransformerArray]; +} + +function bundlerMarkerPlugin(): UnknownPlugin { + const banner = [ + 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', + 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', + '', + ].join('\n'); + + const instrumentedModules = Array.from(new Set(SENTRY_INSTRUMENTATIONS.map(i => i.module.name))); + + return { + name: 'sentry-orchestrion-marker', + enforce: 'pre' as const, + config(): { ssr: { noExternal: string[] } } { + // Force-bundle every instrumented package so the code transform actually + // sees its source. Vite externalizes dependencies in SSR builds by + // default, leaving them as bare `require()`/`import` calls resolved from + // `node_modules` at runtime — those copies are untouched and the + // diagnostics_channel calls never get injected. Vite merges array + // `noExternal` entries with the user's config, so we don't overwrite + // their additions. + return { ssr: { noExternal: instrumentedModules } }; + }, + renderChunk(code: string, chunk: { isEntry: boolean }): { code: string; map: unknown } | null { + if (!chunk.isEntry) return null; + // Prepend via magic-string so the entry chunk's sourcemap stays aligned — + // returning `map: null` here would shift every mapping by the banner's + // line count and misattribute server stack traces. + const ms = new MagicString(code); + ms.prepend(banner); + return { code: ms.toString(), map: ms.generateMap({ hires: true }) }; + }, + }; +} diff --git a/packages/node/src/orchestrion/channels.ts b/packages/server-utils/src/orchestrion/channels.ts similarity index 100% rename from packages/node/src/orchestrion/channels.ts rename to packages/server-utils/src/orchestrion/channels.ts diff --git a/packages/node/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts similarity index 100% rename from packages/node/src/orchestrion/config.ts rename to packages/server-utils/src/orchestrion/config.ts diff --git a/packages/node/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts similarity index 88% rename from packages/node/src/orchestrion/detect.ts rename to packages/server-utils/src/orchestrion/detect.ts index cb5dc13eff32..5513650aafc0 100644 --- a/packages/node/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -10,6 +10,9 @@ declare global { * Verifies that orchestrion has been setup, either: * - the runtime hook (`node --import @sentry/node/orchestrion app.js`), OR * - the bundler plugin (`sentryOrchestrionPlugin()`) + * + * Note: do NOT warn in production, only in debug builds, because + * production warnings are reserved for truly critical issues. */ export function detectOrchestrionSetup(): void { if (!DEBUG_BUILD) return; diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts new file mode 100644 index 000000000000..b0c2ea982c33 --- /dev/null +++ b/packages/server-utils/src/orchestrion/index.ts @@ -0,0 +1,4 @@ +export { setupOrchestrion } from './setup'; +export type { SetupOrchestrionOptions } from './setup'; +export { detectOrchestrionSetup } from './detect'; +export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; diff --git a/packages/server-utils/src/orchestrion/runtime/import-hook.mjs b/packages/server-utils/src/orchestrion/runtime/import-hook.mjs new file mode 100644 index 000000000000..63d7a0b624fa --- /dev/null +++ b/packages/server-utils/src/orchestrion/runtime/import-hook.mjs @@ -0,0 +1,65 @@ +// EXPERIMENTAL — entry point for `node --import @sentry/node/orchestrion app.js`. +// +// Registers the orchestrion ESM loader with the central instrumentation config, +// and sets a global marker (`globalThis.__SENTRY_ORCHESTRION__.runtime`) so +// `detectOrchestrionSetup()` at `_experimentalSetupOrchestrion(client)` time can +// see that the runtime hook ran. +// +// This file is shipped as-is to `build/orchestrion/import-hook.mjs`. Keep it a +// single self-contained `.mjs` file with no relative-path imports — `--import` +// resolves it via Node's module resolution against the installed package. + +import Module from 'node:module'; +import { initialize, resolve, load } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'; +import ModulePatch from '@apm-js-collab/tracing-hooks'; +import { SENTRY_INSTRUMENTATIONS } from '@sentry/server-utils/orchestrion/config'; + +const DEBUG = !!(process.env.DEBUG || process.env.debug || process.env.SENTRY_DEBUG); +// eslint-disable-next-line no-console +const debug = (...args) => DEBUG && console.log('[Sentry orchestrion]', ...args); + +debug('import-hook.mjs loaded, instrumentations:', SENTRY_INSTRUMENTATIONS); + +// detection to decide module loader hooks to use +// registerHooks was present but not stable until 24.13 and 25.1 +const nodeVersion = (process.versions.node ?? '0.0.0').split('.').map(n => parseInt(n, 10)); +// registerHooks available in Deno 2.8.0 +const denoVersion = (globalThis.Deno?.version?.deno ?? '0.0.0').split('.').map(n => parseInt(n, 10)); +const stableSyncHooks = + nodeVersion[0] > 25 || + (nodeVersion[0] === 25 && nodeVersion[1] >= 1) || + (nodeVersion[0] === 24 && nodeVersion[1] >= 13) || + denoVersion[0] > 2 || + (denoVersion[0] === 2 && denoVersion[1] >= 8); + +const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); + +// double-load guard +if (!g.runtime) { + if (typeof Module.registerHooks === 'function' && stableSyncHooks) { + initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); + Module.registerHooks({ resolve, load }); + debug('Module.registerHooks() called for @apm-js-collab/tracing-hooks/hook-sync.mjs'); + } else if (typeof Module.register === 'function' && !globalThis.Bun && !globalThis.Deno) { + Module.register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { + data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + }); + debug('Module.register() called for @apm-js-collab/tracing-hooks/hook.mjs'); + + // ALSO patch `Module.prototype._compile` for the CJS side: when + // an ESM file `import`s a CJS package, Node loads the package's + // entry through the ESM bridge but resolves the package's + // INTERNAL `require()` calls through the CJS machinery. + // Those internal requires never reach the ESM resolve hook, so + // without this patch the file we actually want to instrument is + // loaded untransformed. + // This isn't necessary in the registerHooks case, because Node + // applies those hooks to all CJS and ESM modules. + new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); + } else { + throw new Error('No available API to apply module load hooks'); + } + + // successfully added runtime hooks, set the flag. + g.runtime = true; +} diff --git a/packages/server-utils/src/orchestrion/setup.ts b/packages/server-utils/src/orchestrion/setup.ts new file mode 100644 index 000000000000..b27805c8a9e8 --- /dev/null +++ b/packages/server-utils/src/orchestrion/setup.ts @@ -0,0 +1,55 @@ +import type { Client, Integration } from '@sentry/core'; +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; +import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; +import { detectOrchestrionSetup } from './detect'; + +export interface SetupOrchestrionOptions { + /** + * Override the default set of channel-based integrations. + * If omitted, all orchestrion integrations shipped by server-utils are added. + */ + integrations?: Integration[]; +} + +/** + * Wires up orchestrion-driven channel integrations on the given client. + * + * Must be called after the SDK's `init()`, with the client returned by it: + * + * ```ts + * const client = Sentry.init({ dsn: '…' }); + * setupOrchestrion(client); + * ``` + * + * This is the only exported entry into `orchestrion/*` that registers + * integrations. Bundlers can statically determine that apps which never import + * it drop the entire `orchestrion/` subtree from their output — that is the + * tree-shaking guarantee. + * + * The orchestrion runtime hook (`--import .../orchestrion/import-hook`) or the + * bundler plugin (`sentryOrchestrionPlugin()`) must be active for the + * channel-based integrations to record spans; `detectOrchestrionSetup()` warns + * if neither ran. + */ +export function setupOrchestrion(client: Client | undefined, options: SetupOrchestrionOptions = {}): void { + DEBUG_BUILD && debug.log('[orchestrion] setupOrchestrion() called'); + + if (!client) { + DEBUG_BUILD && + debug.warn('[Sentry] setupOrchestrion() was called without a client. Pass the value returned by `init()`.'); + return; + } + + detectOrchestrionSetup(); + + const integrations = options.integrations ?? [mysqlChannelIntegration()]; + DEBUG_BUILD && + debug.log( + '[orchestrion] registering channel integrations:', + integrations.map(i => i.name), + ); + for (const integration of integrations) { + client.addIntegration(integration); + } +} diff --git a/packages/server-utils/tsconfig.json b/packages/server-utils/tsconfig.json index b0eb9ecb6476..5e5830ae75dc 100644 --- a/packages/server-utils/tsconfig.json +++ b/packages/server-utils/tsconfig.json @@ -3,5 +3,12 @@ "include": ["src/**/*"], - "compilerOptions": {} + "compilerOptions": {}, + // The orchestrion runtime hook is a hand-written `.mjs` file that self-references + // `@sentry/server-utils/orchestrion/config`. If tsc picks it up, it + // follows that subpath export back to `build/types/orchestrion/config.d.ts`, + // treats the .d.ts as an input, and then collides with the .d.ts it wants to + // emit from `src/orchestrion/config.ts`. Excluding it keeps tsc focused on the + // .ts sources — rollup copies the file through to `build/orchestrion/` unchanged. + "exclude": ["src/orchestrion/runtime/**/*.mjs", "src/orchestrion/runtime/**/*.cjs"] } diff --git a/packages/server-utils/tsconfig.types.json b/packages/server-utils/tsconfig.types.json index b1a51db073c2..9256d8987fc3 100644 --- a/packages/server-utils/tsconfig.types.json +++ b/packages/server-utils/tsconfig.types.json @@ -4,6 +4,10 @@ "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, - "outDir": "build/types" + "outDir": "build/types", + // Required so Node16 module resolution can disambiguate package self-references + // (`@sentry/server-utils/orchestrion/config` from inside this package) + // against the package's `.` export. Without this tsc reports TS2209. + "rootDir": "src" } } From ce95b7aa7816e39439c0a3656d37f6d4892f95f6 Mon Sep 17 00:00:00 2001 From: isaacs Date: Sun, 14 Jun 2026 14:23:56 -0700 Subject: [PATCH 3/3] feat(node): collapse orchestrion opt-in to a single option (#20900) Finish the the orchestrion.js-based auto-instrumentation so the Node SDK presents a minimal API that does not expose the internal "orchestrion" term. A single opt-in method now does the entire Orchestrion setup. ```ts Sentry.experimentalUseDiagnosticsChannelInjection(); // orchestrion integrations loaded and ready to go Sentry.init({ dsn: '__DSN__' tracesSampleRate: 1, }) ``` Assuming that this is run in a user's local `--import` module (or, failing that, before init is called and instrumented modules are loaded), then the appropriate hooks will be synchronously added, and Orchestrion diagnostics_channel-based integrations will be used instead of the legacy OTel integrations. The previous three-step setup (`_experimentalUseOrchestrion` flag, `--import @sentry/node/orchestrion`, plus a separate `_experimentalSetupOrchestrion()` method call) is removed. The channel-injection hooks are registered synchronously (mirroring `esmLoader.ts`: `Module.register(...)` + `ModulePatch` on Node <24.13, `Module.registerHooks` on newer / Deno 2.8+), so they are in place before the app's own `import`s resolve. (A dynamic `import()` would have raced module loading.) The `@sentry/node/import` loader hook script injects the channels **unconditionally**, as the presence of the channels is harmless when they are not being used. They are only *subscribed* to if the opt-in flag is set. The version detection is moved to a single source of truth in `server-utils/orchestrion/runtime/register.ts`, so that it can be initialized synchronously as part of `Sentry.init()`, or in the `import-hook.mjs` loader. All references to `orchestrion` are removed from the public API surface. The term remains in `server-utils`, of course, as this is an internal package designed to house shared implementation details across server-side JS platforms. The `@sentry/node/orchestrion/vite` subpath export is removed, as that really isn't used by anything, and was just a pass-through for `@sentry/server-utils/orchestrion/vite` anyway. The Bun and Edge computing SDKs will use this directly to instrument using Orchestrion. Also, only install diagnostics channel injection when tracing enabled. Every Orchestrion integration we're currently adding (ie, Mysql) or likely to in the very near future, replaces an OTel integration that is gated on span creation. So, there's no need to do this work if spans are not enabled. An e2e test is added to ensure that the orchestrion functionality is *not* added in bundlers that do not explicitly opt into it. --- .size-limit.js | 13 +- ORCHESTRIONJS_PLAN.md | 486 ------------------ .../scripts/consistentExports.ts | 7 +- .../node-express-orchestrion-cjs/package.json | 2 +- .../node-express-orchestrion-cjs/src/app.js | 10 +- .../node-express-orchestrion-vite/.gitignore | 1 - .../package.json | 34 -- .../playwright.config.mjs | 7 - .../node-express-orchestrion-vite/src/app.ts | 59 --- .../src/instrument.ts | 12 - .../start-event-proxy.mjs | 6 - .../tests/errors.test.ts | 29 -- .../tests/transactions.test.ts | 154 ------ .../tsconfig.json | 12 - .../vite.config.ts | 18 - .../src/instrument.mjs | 12 +- .../node-orchestrion-webpack/assert.mjs | 48 ++ .../node-orchestrion-webpack/build.mjs | 43 ++ .../node-orchestrion-webpack/package.json | 22 + .../node-orchestrion-webpack/src/app.mjs | 2 + .../src/no-orchestrion.mjs | 10 + .../src/with-orchestrion.mjs | 12 + .../node-integration-tests/package.json | 1 + .../suites/esbuild/app.ts | 10 + .../suites/esbuild/test.ts | 35 ++ .../tracing/mysql/instrument-orchestrion.mjs | 17 +- .../suites/tracing/mysql/test.ts | 150 +++--- ...mportHookWithDiagnosticsChannelTemplate.js | 10 + dev-packages/rollup-utils/npmHelpers.mjs | 21 +- packages/node/package.json | 14 +- packages/node/rollup.npm.config.mjs | 31 +- packages/node/src/index.ts | 2 +- packages/node/src/orchestrion/bundler/vite.ts | 5 - packages/node/src/orchestrion/index.ts | 3 - .../src/orchestrion/runtime/import-hook.mjs | 8 - packages/node/src/orchestrion/setup.ts | 45 -- .../src/sdk/diagnosticsChannelInjection.ts | 56 ++ ...erimentalUseDiagnosticsChannelInjection.ts | 47 ++ packages/node/src/sdk/index.ts | 56 +- packages/node/src/types.ts | 17 - packages/node/tsconfig.json | 5 - packages/server-utils/package.json | 22 +- packages/server-utils/rollup.npm.config.mjs | 4 + .../server-utils/src/orchestrion/detect.ts | 31 +- .../server-utils/src/orchestrion/index.ts | 2 - .../src/orchestrion/runtime/import-hook.mjs | 71 +-- .../src/orchestrion/runtime/register.ts | 120 +++++ .../server-utils/src/orchestrion/setup.ts | 55 -- packages/server-utils/tsconfig.types.json | 3 - yarn.lock | 102 ++-- 50 files changed, 696 insertions(+), 1246 deletions(-) delete mode 100644 ORCHESTRIONJS_PLAN.md delete mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/.gitignore delete mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/package.json delete mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/playwright.config.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/app.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/instrument.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/start-event-proxy.mjs delete mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/errors.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/transactions.test.ts delete mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tsconfig.json delete mode 100644 dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/no-orchestrion.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/with-orchestrion.mjs create mode 100644 dev-packages/node-integration-tests/suites/esbuild/app.ts create mode 100644 dev-packages/node-integration-tests/suites/esbuild/test.ts create mode 100644 dev-packages/rollup-utils/code/otelEsmImportHookWithDiagnosticsChannelTemplate.js delete mode 100644 packages/node/src/orchestrion/bundler/vite.ts delete mode 100644 packages/node/src/orchestrion/index.ts delete mode 100644 packages/node/src/orchestrion/runtime/import-hook.mjs delete mode 100644 packages/node/src/orchestrion/setup.ts create mode 100644 packages/node/src/sdk/diagnosticsChannelInjection.ts create mode 100644 packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts create mode 100644 packages/server-utils/src/orchestrion/runtime/register.ts delete mode 100644 packages/server-utils/src/orchestrion/setup.ts diff --git a/.size-limit.js b/.size-limit.js index fc3fbcced896..66e200a4eb3f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -395,17 +395,8 @@ module.exports = [ disablePlugins: ['@size-limit/esbuild'], }, { - name: '@sentry/node (with Orchestrion)', - path: 'packages/node/build/esm/index.js', - import: createImport('init', '_experimentalSetupOrchestrion'), - ignore: [...builtinModules, ...nodePrefixedBuiltinModules], - gzip: true, - limit: '173 KB', - disablePlugins: ['@size-limit/esbuild'], - }, - { - name: '@sentry/node/orchestrion (ESM hook)', - path: ['node_modules/@apm-js-collab/tracing-hooks/hook.mjs', 'packages/node/build/orchestrion/import-hook.mjs'], + name: '@sentry/node/import (ESM hook with diagnostics-channel injection)', + path: ['node_modules/@apm-js-collab/tracing-hooks/hook.mjs', 'packages/node/build/import-hook.mjs'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, limit: '100 KB', diff --git a/ORCHESTRIONJS_PLAN.md b/ORCHESTRIONJS_PLAN.md deleted file mode 100644 index ef43b36fb5af..000000000000 --- a/ORCHESTRIONJS_PLAN.md +++ /dev/null @@ -1,486 +0,0 @@ -# Orchestrion.js Auto-Instrumentation Experiment Plan - -> **Update (out of date):** the sections below describing a separate CJS -> `runtime/require-hook.cjs` (and a `require` arm on the `./orchestrion` subpath -> export) are obsolete. The implemented ESM `import-hook.mjs`, loaded via -> `--import`, already instruments **both** ESM and CJS user code — via -> `Module.registerHooks` where available, otherwise `Module.register` plus the -> CJS `Module._compile` patch (`ModulePatch.patch()`). So `--import` is the -> single runtime entry point for both module systems; no `require-hook`/`require` -> condition is needed. CJS apps run `node --import @sentry/node/orchestrion app.js`. -> -> Experiment branch: `experiment/orchestrionjs-auto-instrumentation` -> -> Goal: prototype a future where `@sentry/node` does its own auto-instrumentation -> via Node.js [`TracingChannel`](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel), -> with channel injection driven by [orchestrion.js](https://github.com/nodejs/orchestrion-js) -> instead of OpenTelemetry's `require-in-the-middle` / -> `import-in-the-middle` machinery. -> -> The `orchestrion.js` machinery lives in the shared -> `server-utils` package, for eventual use in the bun and deno -> SDKs, which will be done as a subsequent project. -> -> First target: the `mysql` integration. - -## Background - -Orchestrion-JS is published as three coordinated packages: - -| Package | What it does | We use it for | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| `@apm-js-collab/code-transformer` | Rust/WASM AST walker. Given an `InstrumentationConfig[]`, returns a `Transformer` that rewrites function bodies to publish to a `TracingChannel`. | Indirectly — via the two below. | -| `@apm-js-collab/tracing-hooks` | Node ESM loader (`register('@apm-js-collab/tracing-hooks/hook.mjs', ..., { data: { instrumentations } })`) + a CJS `ModulePatch` for `--require`. | **Runtime** channel injection. | -| `@apm-js-collab/code-transformer-bundler-plugins` | One plugin per bundler (`/vite`, `/webpack`, `/rollup`, `/esbuild`), all taking the same `{ instrumentations }` object. | **Build-time** channel injection. | - -All three accept the same `InstrumentationConfig` shape: - -```ts -type InstrumentationConfig = { - channelName: string; // diagnostics_channel TracingChannel name - module: { name: string; versionRange: string; filePath: string }; - functionQuery: FunctionQuery; // className+methodName / functionName / expressionName / ... -}; -``` - -This means **one config array** can drive both the runtime hook and every bundler plugin — that is the leverage point this plan is built around. - -## Architectural goals - -1. **Integrations only know channels.** A Sentry integration (e.g. `mysqlIntegration`) subscribes to a published channel name and creates spans. It never imports orchestrion, never knows how the channel got there, and would work identically against a native `diagnostics_channel` that some library already publishes itself. -2. **Single source of truth for orchestrion config.** Channel names + module matchers + function queries live in **one** TypeScript module. Both the runtime hook and the bundler plugin import from it. Adding a new instrumentation = one edit. When this config is being consumed by multiple SDKs, one edit can add instrumentation to multiple platforms. -3. **Two equally good user paths, one of which must be active.** - - **Bundler path** (preferred when bundling): the user adds `sentryOrchestrionPlugin()` to their `vite.config.ts`. Nothing else. - - **Runtime path** (preferred for unbundled Node servers): the user runs `node --import @sentry/node/orchestrion app.js` (ESM) or `node --require @sentry/node/orchestrion app.js` (CJS). The same import path resolves to the ESM `import-hook.mjs` or the CJS `require-hook.cjs` based on the active loader condition, so the user doesn't have to know which one to pick. -4. **Loud about misconfiguration.** When orchestrion setup runs, the SDK must detect (a) "no orchestrion hook was set up at all" and (b) "both paths ran — code is double-wrapped" and warn clearly. -5. **No mixing with the existing OTel-based init, and tree-shakable.** The opt-in is split into two pieces so users who don't opt in never pull in any orchestrion code: - - A new `_experimentalUseOrchestrion: true` flag on `Sentry.init()` that does the _base_ adjustments — i.e. skip registering the OTel auto-instrumentations that have a channel-based replacement (mysql, …). This is all `init()` itself does; it pulls in zero orchestrion-specific code. - - A new top-level export `_experimentalSetupOrchestrion()` that the user calls **after** `Sentry.init()`. This is where all orchestrion-specific code lives: the channel subscribers, the integration registrations, and the runtime/bundler detection warnings. If the user never calls it, the bundler can drop everything under `orchestrion/` from their bundle. - When the flag is unset (the default), `init()` behaves exactly as today and `_experimentalSetupOrchestrion` — if imported — is a no-op that only warns. Existing users keep using `@opentelemetry/instrumentation-*` integrations untouched. - -## Repository layout - -All new code lives under `packages/node/`. The existing OTel-based mysql integration stays untouched so we can A/B them. - -``` -packages/node/ -├── package.json (NEW subpath exports — see below) -└── src/ - └── orchestrion/ (NEW directory — all experiment code) - ├── index.ts public re-exports for the integrations subdir - ├── setup.ts ★ _experimentalSetupOrchestrion() — the only user-facing entry into this dir - ├── config.ts ★ central InstrumentationConfig[] — single source of truth - ├── channels.ts channel-name string constants (imported by configs AND integrations) - ├── detect.ts globalThis marker + warning logic - ├── runtime/ - │ ├── import-hook.mjs --import target: register() + marker - │ └── require-hook.cjs --require target: ModulePatch.patch() + marker - └── bundler/ - ├── vite.ts sentryOrchestrionVitePlugin() — wraps code-transformer/vite + marker - └── marker-banner.ts shared "inject `globalThis.__SENTRY_ORCHESTRION__.bundler = true`" plugin -packages/node/src/integrations/tracing-channel/ - └── mysql.ts ★ subscribes to channels; creates Sentry spans -``` - -All channel-consumer integrations live together under `integrations/tracing-channel/` — one file per library (`mysql.ts`, future `pg.ts`, `redis.ts`, …). This mirrors the existing `integrations/tracing/` layout for the OTel path, keeps related code visually grouped, and makes the boundary the user wants explicit: a contributor adding a new channel-driven integration edits `orchestrion/config.ts` (one entry) + `integrations/tracing-channel/.ts` (one subscriber) + adds it to the default list in `orchestrion/setup.ts`. Nothing else. - -`orchestrion/setup.ts` is the **only** file under `orchestrion/` that user code imports from at runtime (via the top-level `@sentry/node` re-export of `_experimentalSetupOrchestrion`). Everything else under `orchestrion/` is reachable only transitively through that one entry point — which is what makes the experiment tree-shakable for opted-out users. - -## Central config — the load-bearing file - -`packages/server-utils/src/orchestrion/channels.ts` - -```ts -// String constants shared between config.ts (producer) and integrations (consumer). -// Single source of truth for channel names — keeps the channel string from being -// misspelled in one place and silently never firing. -export const CHANNELS = { - MYSQL_QUERY: 'sentry:mysql:query', -} as const; -``` - -`packages/server-utils/src/orchestrion/config.ts` - -```ts -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; -import { CHANNELS } from './channels'; - -export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ - { - channelName: CHANNELS.MYSQL_QUERY, - module: { name: 'mysql', versionRange: '>=2.0.0', filePath: 'lib/Connection.js' }, - functionQuery: { className: 'Connection', methodName: 'query', kind: 'Callback' }, - }, - // … future entries: mysql2, pg, redis, etc. One line per instrumented method. -]; -``` - -`config.ts` has **no side effects** — it is the only thing both `runtime/*` and `bundler/*` import. This is what makes it cheap to maintain: adding a new instrumented method is one entry here + one subscriber file. - -## The integration — channel consumer - -`packages/server-utils/src/integrations/tracing-channel/mysql.ts` (sketch): - -```ts -import { channel, tracingChannel } from 'node:diagnostics_channel'; -import { defineIntegration, startSpan, SPAN_STATUS_ERROR } from '@sentry/core'; -import { CHANNELS } from '../../orchestrion/channels'; - -const _mysqlChannelIntegration = (() => { - const queryCh = tracingChannel(CHANNELS.MYSQL_QUERY); - // store per-context state on a WeakMap keyed by the `context` object - // that orchestrion passes to start/end/asyncStart/asyncEnd/error. - const spans = new WeakMap void }>(); - - return { - name: 'MysqlChannel', - setupOnce() { - queryCh.subscribe({ - start(ctx) { - // ctx.arguments contains the original call args — extract SQL for span name. - const sql = String((ctx as any).arguments?.[0] ?? 'mysql.query'); - // startSpan returns synchronously when we pass `{ forceTransaction: false }` semantics; - // for true async correlation we wrap startInactiveSpan + manual end here. - const span = startInactiveSpanForChannel(sql); - spans.set(ctx as object, { - finish: () => span.end(), - }); - }, - error(ctx) { - // pull error from ctx, mark span status - }, - asyncEnd(ctx) { - spans.get(ctx as object)?.finish(); - }, - // end() fires for sync paths; asyncEnd() for callback / promise paths - end(ctx) { - // only finish if asyncEnd hasn't (mysql Connection.query is callback-based — asyncEnd is the one) - }, - }); - }, - }; -}) satisfies IntegrationFn; - -export const mysqlChannelIntegration = defineIntegration(_mysqlChannelIntegration); -``` - -The integration imports **`CHANNELS.MYSQL_QUERY`, not the orchestrion config**. It is unaware orchestrion exists; if some day `mysql` publishes that channel natively we just stop injecting it. - -## Subpath exports - -Add to `packages/node/package.json`: - -```jsonc -"exports": { - // … existing entries … - "./orchestrion": { - // Only a --import hook is supported; this fully covers both - // CJS and ESM modules. - "import": { "default": "./build/orchestrion/import-hook.mjs" }, - "require": { "default": "./build/orchestrion/require-hook.cjs" } - }, - "./orchestrion/vite": { - // Vite plugin factory. - "import": { "types": "./build/types/orchestrion/bundler/vite.d.ts", "default": "./build/esm/orchestrion/bundler/vite.js" }, - "require": { "types": "./build/types/orchestrion/bundler/vite.d.ts", "default": "./build/cjs/orchestrion/bundler/vite.js" } - } -} -``` - -End-user friction is minimized: either - -```bash -node --import @sentry/node/orchestrion app.js -``` - -or - -```ts -// vite.config.ts -import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; -export default { plugins: [sentryOrchestrionPlugin()] }; -``` - -No `instrumentations: [...]` array to copy-paste, no channel names to remember. - -## Runtime hook — `--import` ESM target - -`packages/server-utils/src/orchestrion/runtime/import-hook.mjs` - -```js -import { register } from 'node:module'; -import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; - -// 1) Double-wrap guard. Set this BEFORE register() so even if a second --import -// is added, we won't double-register. -const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); -if (g.runtime) { - console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --import. Ignoring the second load.'); -} else { - g.runtime = true; - register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { - data: { instrumentations: SENTRY_INSTRUMENTATIONS }, - }); -} -``` - -The import hook sets `globalThis.__SENTRY_ORCHESTRION__.runtime = true`. That marker is how `detect.ts` knows the runtime path is active later. - -## Vite plugin — build-time path - -`packages/server-utils/src/orchestrion/bundler/vite.ts` - -```ts -import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; -import type { Plugin } from 'vite'; -import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; - -export function sentryOrchestrionPlugin(): Plugin[] { - return [ - // 1) Inject the runtime marker into the bundle so detect.ts can see it. - markerPlugin(), - // 2) The actual orchestrion transformer, fed our central config. - codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }), - ]; -} - -function markerPlugin(): Plugin { - // Emits/injects a one-liner into the bundle output: - // globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {}); - // if (globalThis.__SENTRY_ORCHESTRION__.bundler) { console.warn('[Sentry] orchestrion bundler plugin loaded twice'); } - // globalThis.__SENTRY_ORCHESTRION__.bundler = true; - return { - name: 'sentry-orchestrion-marker', - enforce: 'pre', - // Easiest: hook `renderChunk` and prepend to entry chunks. - // Alternative: emit a virtual module + use `banner` config injection. - // To be decided during implementation — both work; the renderChunk approach - // avoids requiring the user to import anything. - }; -} -``` - -**Design decision — where the marker comes from in the bundler path:** -the plugin injects runtime JS into the bundle, not just a build-time flag. Build-time markers (e.g. `define`) are useless to `detect.ts`, which runs at app start. The marker must execute when the bundled app boots. - -## Detection — `detect.ts` - -`packages/server-utils/src/orchestrion/detect.ts` - -```ts -import { logger } from '@sentry/core'; - -declare global { - // eslint-disable-next-line no-var - var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; -} - -export function detectOrchestrionSetup(): void { - const marker = globalThis.__SENTRY_ORCHESTRION__; - const runtime = !!marker?.runtime; - const bundler = !!marker?.bundler; - - if (runtime && bundler) { - logger.warn( - '[Sentry] Detected BOTH the @sentry/node/orchestrion runtime hook AND the bundler plugin. ' + - 'Functions will be instrumented twice and produce duplicate spans. ' + - 'Remove `--import @sentry/node/orchestrion` if you are using the bundler plugin, or vice versa.', - ); - return; - } - - if (!runtime && !bundler) { - logger.warn( - '[Sentry] No auto-instrumentation hook detected. Channel-based integrations (mysql, …) will not record spans. ' + - 'Either run with `node --import @sentry/node/orchestrion app.js`, or add `sentryOrchestrionPlugin()` to your bundler config.', - ); - } -} -``` - -## Two-step user setup — flag on `init()` + `_experimentalSetupOrchestrion()` - -The opt-in is deliberately split so the orchestrion code path stays tree-shakable. `Sentry.init()` only learns about a boolean flag; it does **not** import anything from `orchestrion/`. The orchestrion-specific code only runs if the user explicitly imports and calls `_experimentalSetupOrchestrion()` after `init()`. - -This is used by for the Node orchestrion integration to keep it -opt-in, since it replaces existing working integrations. Other -SDKs that use orchestrion for net-new integrations will be -automatically enabled if available. - -### Step 1 — `_experimentalUseOrchestrion` flag on `NodeOptions` - -```ts -// packages/node/src/types.ts (or wherever NodeOptions lives) -export interface NodeOptions extends ClientOptions { - // … existing options … - /** - * EXPERIMENTAL — opt into the orchestrion.js-based auto-instrumentation path. - * When `true`, `Sentry.init()` will skip registering the default OTel - * auto-instrumentations for libraries that have a channel-based alternative - * (mysql, …). It does **not** install any channel subscribers on its own — - * call `_experimentalSetupOrchestrion()` after `init()` for that. - * - * Defaults to `false`. The flag name is intentionally underscore-prefixed and - * will be renamed or removed once the experiment graduates. - */ - _experimentalUseOrchestrion?: boolean; -} -``` - -```ts -// packages/node/src/sdk/index.ts (sketch of the additional lines in init()) -export function init(options: NodeOptions | undefined = {}): NodeClient | undefined { - // … existing init body, with one change: when assembling the default integrations - // list, skip entries whose libraries are covered by the orchestrion experiment. - if (options._experimentalUseOrchestrion) { - defaultIntegrations = defaultIntegrations.filter(i => !ORCHESTRION_REPLACED_INTEGRATIONS.has(i.name)); - } - // … the rest of init() is unchanged, and crucially does NOT import from ../orchestrion/* … -} - -// A tiny string-set constant — no orchestrion code imported. -const ORCHESTRION_REPLACED_INTEGRATIONS = new Set([ - 'Mysql', // matches the existing OTel mysql integration's `name` -]); -``` - -The list of replaced integration names is a plain string set defined alongside `init()` itself — it does not import from `server-utils/orchestrion/`, so toggling the flag doesn't pull orchestrion code into a user's bundle. - -### Step 2 — `_experimentalSetupOrchestrion()` as a separate export - -```ts -// packages/node/src/orchestrion/setup.ts -import { logger } from '@sentry/core'; -import type { NodeClient } from '../sdk/client'; -import { detectOrchestrionSetup } from './detect'; -import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; - -export interface ExperimentalSetupOrchestrionOptions { - /** - * Override or extend the default set of channel-based integrations. - * If omitted, all orchestrion integrations shipped by @sentry/node are added. - */ - integrations?: Integration[]; -} - -export function _experimentalSetupOrchestrion( - client: NodeClient | undefined, - options: ExperimentalSetupOrchestrionOptions = {}, -): void { - if (!client) { - logger.warn( - '[Sentry] _experimentalSetupOrchestrion() was called without a client. ' + - 'Pass the value returned by `Sentry.init()`.', - ); - return; - } - if (!client.getOptions()._experimentalUseOrchestrion) { - logger.warn( - '[Sentry] _experimentalSetupOrchestrion() called but Sentry.init() was not given ' + - '`_experimentalUseOrchestrion: true`. The default OTel integrations are still active — ' + - 'you will get duplicate spans. Add the flag to Sentry.init().', - ); - } - - // 1) Verify the runtime/bundler hook actually ran. - detectOrchestrionSetup(); - - // 2) Register the channel-based integrations on the passed-in client. - const integrations = options.integrations ?? [ - mysqlChannelIntegration(), - // … future channel integrations default-on here. - ]; - for (const integration of integrations) { - client.addIntegration(integration); - } -} -``` - -Taking the client as an explicit argument (instead of pulling it from `getClient()`) makes the call order unambiguous, avoids surprises when multiple clients exist (tests, multi-tenant setups), and gives TypeScript users a clear type on what `_experimentalSetupOrchestrion` operates against. - -`_experimentalSetupOrchestrion` is the **only** export through which orchestrion-specific code is reachable from a user's app graph. Bundlers can statically determine that an app which never imports it has no live edges into `orchestrion/`, so all the channel subscribers, detection code, and integration factories drop out. - -The function is also where we sanity-check the user's setup: it warns if `init()` wasn't told about the flag, and it runs `detectOrchestrionSetup()` to confirm exactly one of the runtime / bundler paths is active. - -### Usage - -```ts -import * as Sentry from '@sentry/node'; -import { _experimentalSetupOrchestrion } from '@sentry/node'; - -const client = Sentry.init({ - dsn: '…', - _experimentalUseOrchestrion: true, -}); - -_experimentalSetupOrchestrion(client); -// Or, to override which integrations are registered: -// _experimentalSetupOrchestrion(client, { integrations: [mysqlChannelIntegration()] }); -``` - -This keeps the experiment self-contained — no parallel `init` function, no separate entry point — while still being fully tree-shakable for users who don't opt in. - -## End-user surface - -**Bundled app (Vite):** - -```ts -// vite.config.ts -import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; -export default { plugins: [sentryOrchestrionPlugin()] }; - -// app.ts -import * as Sentry from '@sentry/node'; -import { _experimentalSetupOrchestrion } from '@sentry/node'; - -const client = Sentry.init({ - dsn: '…', - _experimentalUseOrchestrion: true, -}); -_experimentalSetupOrchestrion(client); -``` - -**Unbundled Node ESM app:** - -```bash -node --import @sentry/node/orchestrion app.js -``` - -```ts -// app.ts — same two-step init + setup as above, no plugin needed. -``` - -**Unbundled Node CJS app:** - -```bash -node --require @sentry/node/orchestrion app.js -``` - -If the user does **neither** runtime nor bundler hook, `_experimentalSetupOrchestrion()` warns at startup. If they do **both**, it also warns. If they set `_experimentalUseOrchestrion: true` but never call `_experimentalSetupOrchestrion()`, they get no channel-based spans and no OTel-based spans for the replaced libraries — also a warning case (emitted lazily the first time the client tries to flush, since we can't observe the missing call directly at `init()` time). TBD whether this third warning is worth the complexity. - -## Double-wrap analysis — what orchestrion does and doesn't protect against - -| Failure mode | Who catches it | How | -| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| Bundler plugin added twice in the same Vite config | orchestrion's bundler plugin itself? **Unverified** — needs a test during the spike. If not, our marker plugin warns. | `__SENTRY_ORCHESTRION__.bundler` already true at second plugin invocation. | -| `--import @sentry/node/orchestrion` passed twice on CLI | Our hook | Marker set before `register()`, second load short-circuits with a warn. | -| Bundler plugin + runtime hook both run | Our `detect.ts` at `Sentry.init` | Warn — this is the most likely real-world footgun, since a Vite-built app may still launch with a stray `--import` from prod tooling. | -| Neither runs | Our `detect.ts` | Warn — user thinks Sentry instruments their DB but it silently doesn't. | -| Orchestrion patches a function the user already patched manually | **Out of scope** for this experiment. Document it. | n/a | - -## Implementation phases - -1. **Plumbing first** — branch (done), add the three orchestrion packages to `packages/node/package.json` as `dependencies`, create `orchestrion/` directory with empty `config.ts`, `channels.ts`, `detect.ts`. No real channels yet. Build passes. -2. **Runtime path end-to-end** — wire `import-hook.mjs` + the rollup config in `packages/node/rollup.npm.config.mjs` to emit it. Verify with a throwaway script that has _one_ instrumentation in `config.ts` (a function in a tiny local fixture module) that publishing fires. -3. **Mysql channel integration** — write `integrations/tracing-channel/mysql.ts`. Plug into a `dev-packages/node-integration-tests/` scenario that runs against a real mysql container, asserts spans. -4. **Bundler path** — add `sentryOrchestrionPlugin()` for Vite, including marker injection. Test in a small fixture under `dev-packages/e2e-tests/` (Vite-built Node entry hitting mysql). -5. **Detection + setup entry point** — add `detect.ts` + `setup.ts` (exporting `_experimentalSetupOrchestrion`), wire the `_experimentalUseOrchestrion` flag into `init()` so it filters the default integrations, and re-export `_experimentalSetupOrchestrion` from the package root. Test all four hook states (runtime only / bundler only / both / neither) via the e2e fixtures, plus a bundler-size assertion that not importing `_experimentalSetupOrchestrion` drops `orchestrion/*` from the output. -6. **Decide & write up** — capture findings in a follow-up doc: does this beat the OTel path on (a) bundle size, (b) cold start, (c) reliability, (d) maintenance cost? - -## Open questions to settle during the spike - -- **Does `@apm-js-collab/tracing-hooks` ship its own double-register guard?** Cheap to test — register twice, see if it complains. If yes, our runtime-path warning is belt-and-suspenders; if no, our marker is the only guard. -- **Does `code-transformer-bundler-plugins/vite` work cleanly with Vite's SSR / library modes?** Our likely consumers (Next, Nuxt, SvelteKit server bundles) all go through SSR pipelines. -- **`TracingChannel` callback context shape** — orchestrion docs describe the channel name + the `kind` (Sync/Async/Callback) but not the exact `context` payload (what `arguments`, `this`, `result`, `error` keys are present). Needs a quick `subscribe` + `console.log` smoke test before writing `mysql.ts`. -- **CJS vs ESM coverage** — does the runtime require-hook see ESM imports of mysql? Does the import-hook see CJS requires? The mysql package itself is CJS, but the consuming app may be either. Likely we need to wire both hooks together in `--import @sentry/node/orchestrion` (the ESM hook also patches CJS via the require-hook path). -- **How do we keep `SENTRY_INSTRUMENTATIONS` tree-shakable?** If a user only wants mysql, the unused configs shouldn't ship. Probably each integration owns its config fragment and `config.ts` aggregates via barrel import — TBD during phase 1. diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 86a717459850..1436e35fcc6b 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -21,11 +21,12 @@ const NODE_EXPORTS_IGNORE = [ 'SentryContextManager', 'validateOpenTelemetrySetup', 'preloadOpenTelemetry', + // Experimental, Node-runtime-only opt-in (diagnostics-channel injection); it + // registers Node module hooks and is not surfaced through the framework / + // serverless SDKs. + 'experimentalUseDiagnosticsChannelInjection', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', - // Experimental - '_experimentalSetupOrchestrion', - 'mysqlChannelIntegration', ]; const nodeExports = Object.keys(SentryNode).filter(e => !NODE_EXPORTS_IGNORE.includes(e)); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json index 4123b3e3d43b..e3f3bbf2efe7 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json @@ -4,7 +4,7 @@ "private": true, "type": "commonjs", "scripts": { - "start": "node --import @sentry/node/orchestrion ./src/app.js", + "start": "node --import @sentry/node/import ./src/app.js", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml dist", "test:build": "pnpm install", diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js index cc54b773b3c0..867a932b23bd 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js @@ -1,16 +1,18 @@ const Sentry = require('@sentry/node'); -const client = Sentry.init({ +// The channels are injected by `node --import @sentry/node/import` (see the +// `start` script); opting in via this method makes the SDK subscribe to +// them instead of using the OTel instrumentation. +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, - _experimentalUseOrchestrion: true, }); -Sentry._experimentalSetupOrchestrion(client); - const express = require('express'); const mysql = require('mysql'); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/.gitignore deleted file mode 100644 index 1521c8b7652b..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/package.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/package.json deleted file mode 100644 index dfcf44b6b889..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "node-express-orchestrion-vite-app", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "start": "node --import ./dist/instrument.js ./dist/app.js", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml dist", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/node": "file:../../packed/sentry-node-packed.tgz", - "@types/express": "^4.17.21", - "@types/node": "^18.19.1", - "express": "^5.1.0", - "mysql": "2.18.1", - "typescript": "~5.0.0" - }, - "devDependencies": { - "@playwright/test": "~1.56.0", - "@sentry-internal/test-utils": "link:../../../test-utils", - "@sentry/core": "file:../../packed/sentry-core-packed.tgz", - "vite": "^5.4.11" - }, - "resolutions": { - "@types/qs": "6.9.17" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/playwright.config.mjs deleted file mode 100644 index 31f2b913b58b..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/playwright.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, -}); - -export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/app.ts deleted file mode 100644 index f34260393bb6..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/app.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as Sentry from '@sentry/node'; -import express from 'express'; -import mysql from 'mysql'; - -const connection = mysql.createConnection({ - user: 'root', - password: 'docker', -}); - -const app = express(); -const port = 3030; - -app.get('/test-success', function (req, res) { - res.send({ version: 'v1' }); -}); - -app.get('/test-param/:param', function (req, res) { - res.send({ paramWas: req.params.param }); -}); - -app.get('/test-mysql', function (req, res) { - connection.query('SELECT 1 + 1 AS solution', function () { - connection.query('SELECT NOW()', ['1', '2'], () => { - res.send({ status: 'ok' }); - }); - }); -}); - -app.get('/test-transaction', function (_req, res) { - Sentry.startSpan({ name: 'test-span' }, () => undefined); - - res.send({ status: 'ok' }); -}); - -app.get('/test-error', async function (req, res) { - const exceptionId = Sentry.captureException(new Error('This is an error')); - - await Sentry.flush(2000); - - res.send({ exceptionId }); -}); - -app.get('/test-exception/:id', function (req, _res) { - throw new Error(`This is an exception with id ${req.params.id}`); -}); - -Sentry.setupExpressErrorHandler(app); - -// @ts-ignore -app.use(function onError(err, req, res, next) { - // The error id is attached to `res.sentry` to be returned - // and optionally displayed to the user for support. - res.statusCode = 500; - res.end(res.sentry + '\n'); -}); - -app.listen(port, () => { - console.log(`Example app listening on port ${port}`); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/instrument.ts deleted file mode 100644 index 109beefafba6..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/instrument.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as Sentry from '@sentry/node'; - -const client = Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - debug: !!process.env.DEBUG, - tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1, - _experimentalUseOrchestrion: true, -}); - -Sentry._experimentalSetupOrchestrion(client); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/start-event-proxy.mjs deleted file mode 100644 index 7cb02eee13af..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'node-express-orchestrion-vite', -}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/errors.test.ts deleted file mode 100644 index dd94052af1fe..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/errors.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('Sends correct error event', async ({ baseURL }) => { - const errorEventPromise = waitForError('node-express-orchestrion-vite', event => { - return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; - }); - - await fetch(`${baseURL}/test-exception/123`); - - const errorEvent = await errorEventPromise; - - expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); - - expect(errorEvent.request).toEqual({ - method: 'GET', - cookies: {}, - headers: expect.any(Object), - url: 'http://localhost:3030/test-exception/123', - }); - - expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); - - expect(errorEvent.contexts?.trace).toEqual({ - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/transactions.test.ts deleted file mode 100644 index 1890a6af44ec..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/transactions.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Sends an API route transaction', async ({ baseURL }) => { - const pageloadTransactionEventPromise = waitForTransaction('node-express-orchestrion-vite', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-transaction' - ); - }); - - await fetch(`${baseURL}/test-transaction`); - - const transactionEvent = await pageloadTransactionEventPromise; - - expect(transactionEvent.contexts?.trace).toEqual({ - data: { - 'sentry.source': 'route', - 'sentry.origin': 'auto.http.otel.http', - 'sentry.op': 'http.server', - 'sentry.sample_rate': 1, - url: 'http://localhost:3030/test-transaction', - 'otel.kind': 'SERVER', - 'http.response.status_code': 200, - 'http.url': 'http://localhost:3030/test-transaction', - 'http.host': 'localhost:3030', - 'net.host.name': 'localhost', - 'http.method': 'GET', - 'http.scheme': 'http', - 'http.target': '/test-transaction', - 'http.user_agent': 'node', - 'http.flavor': '1.1', - 'net.transport': 'ip_tcp', - 'net.host.ip': expect.any(String), - 'net.host.port': expect.any(Number), - 'net.peer.ip': expect.any(String), - 'net.peer.port': expect.any(Number), - 'http.status_code': 200, - 'http.status_text': 'OK', - 'http.route': '/test-transaction', - 'http.request.header.accept': '*/*', - 'http.request.header.accept_encoding': 'gzip, deflate', - 'http.request.header.accept_language': '*', - 'http.request.header.connection': 'keep-alive', - 'http.request.header.host': expect.any(String), - 'http.request.header.sec_fetch_mode': 'cors', - 'http.request.header.user_agent': 'node', - }, - op: 'http.server', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.http.otel.http', - }); - - expect(transactionEvent.contexts?.response).toEqual({ - status_code: 200, - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'GET /test-transaction', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - const spans = transactionEvent.spans || []; - - // Manually started span - expect(spans).toContainEqual({ - data: { 'sentry.origin': 'manual' }, - description: 'test-span', - origin: 'manual', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); - - // auto instrumented span - expect(spans).toContainEqual({ - data: { - 'sentry.origin': 'auto.http.express', - 'sentry.op': 'request_handler.express', - 'http.route': '/test-transaction', - 'express.name': '/test-transaction', - 'express.type': 'request_handler', - }, - description: '/test-transaction', - op: 'request_handler.express', - origin: 'auto.http.express', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); -}); - -test('Sends an API route transaction for an errored route', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('node-express-orchestrion-vite', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /test-exception/:id' && - transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' - ); - }); - - await fetch(`${baseURL}/test-exception/777`); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); - expect(transactionEvent.transaction).toEqual('GET /test-exception/:id'); - expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); - expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); -}); - -test('Instruments MySQL via Orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('node-express-orchestrion-vite', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /test-mysql'; - }); - - await fetch(`${baseURL}/test-mysql`); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); - expect(transactionEvent.transaction).toEqual('GET /test-mysql'); - expect(transactionEvent.contexts?.trace?.status).toEqual('ok'); - expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(200); - - const spans = transactionEvent.spans || []; - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.mysql', - description: 'SELECT 1 + 1 AS solution', - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.mysql', - description: 'SELECT NOW()', - }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tsconfig.json deleted file mode 100644 index c46f5dea4945..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "types": ["node"], - "module": "ESNext", - "moduleResolution": "Bundler", - "esModuleInterop": true, - "lib": ["es2020"], - "strict": true, - "skipLibCheck": true - }, - "include": ["src/**/*.ts", "vite.config.ts"] -} diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/vite.config.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/vite.config.ts deleted file mode 100644 index daa0417a5e3a..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [sentryOrchestrionPlugin()], - build: { - target: 'node18', - ssr: true, - outDir: 'dist', - emptyOutDir: true, - rollupOptions: { - input: ['src/app.ts', 'src/instrument.ts'], - output: { - format: 'esm', - }, - }, - }, -}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs index bdce1c09630c..2f47402bb02a 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs @@ -1,13 +1,15 @@ -import '@sentry/node/orchestrion'; import * as Sentry from '@sentry/node'; -const client = Sentry.init({ +// Opting in via `experimentalUseDiagnosticsChannelInjection()` (before `init`) +// is all that's needed. Because this file runs via `node --import` before +// `app.mjs` imports `mysql`, `Sentry.init()` synchronously installs the +// channel-injection hooks. +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, - _experimentalUseOrchestrion: true, }); - -Sentry._experimentalSetupOrchestrion(client); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs new file mode 100644 index 000000000000..e2ddb56b9ffa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs @@ -0,0 +1,48 @@ +/** + * Asserts the orchestrion subtree is tree-shaken out of the bundle unless the + * app opted in via `experimentalUseDiagnosticsChannelInjection()`. + * + * @module + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// `orchestrion:mysql:query` lives only in @sentry/server-utils' orchestrion +// subtree (channels.ts), never in @sentry/node — so finding it in a bundle +// means the orchestrion code path was pulled in. +const MARKER = 'orchestrion:mysql:query'; + +function bundleText(name) { + const dir = join(__dirname, 'dist', name); + return readdirSync(dir) + .map(f => readFileSync(join(dir, f), 'utf8')) + .join('\n'); +} + +let failed = false; +function check(condition, message) { + // eslint-disable-next-line no-console + console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); + if (!condition) failed = true; +} + +const noOrchestrion = bundleText('no-orchestrion'); +const withOrchestrion = bundleText('with-orchestrion'); + +check( + !noOrchestrion.includes(MARKER), + 'orchestrion is EXCLUDED when experimentalUseDiagnosticsChannelInjection() is NOT called', +); +check( + withOrchestrion.includes(MARKER), + 'orchestrion is INCLUDED when experimentalUseDiagnosticsChannelInjection() IS called', +); + +if (failed) { + process.exit(1); +} +// eslint-disable-next-line no-console +console.log('All bundle tree-shaking assertions passed.'); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs new file mode 100644 index 000000000000..81f1e661d9e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs @@ -0,0 +1,43 @@ +// Bundles both entrypoints with webpack (the pinned version in package.json +// kept current, since webpack's `createRequire` following has changed across +// releases). Outputs go to ./dist// for assert.mjs to inspect. +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import webpack from 'webpack'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function build(name) { + return new Promise((resolve, reject) => { + webpack( + { + entry: join(__dirname, 'src', `${name}.mjs`), + mode: 'production', + target: 'node', + experiments: { topLevelAwait: true, outputModule: true }, + output: { + path: join(__dirname, 'dist', name), + filename: 'main.mjs', + module: true, + library: { type: 'module' }, + chunkFormat: 'module', + }, + // 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. + optimization: { minimize: false }, + }, + (err, stats) => { + if (err) return reject(err); + if (stats.hasErrors()) { + return reject(new Error(`webpack build of ${name} failed:\n${stats.toString({ errors: true })}`)); + } + // eslint-disable-next-line no-console + console.log(`built ${name} (webpack ${webpack.version})`); + resolve(); + }, + ); + }); +} + +await Promise.all([build('no-orchestrion'), build('with-orchestrion')]); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json new file mode 100644 index 000000000000..69dd20caf346 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json @@ -0,0 +1,22 @@ +{ + "name": "node-orchestrion-webpack", + "description": "ensure that orchestrion is not bundled inappropriately", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test:build": "pnpm install && node ./build.mjs", + "test:assert": "node ./assert.mjs" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz" + }, + "devDependencies": { + "webpack": "5.107.2" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs new file mode 100644 index 000000000000..e66db6685328 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-console +console.log('this is the application'); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/no-orchestrion.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/no-orchestrion.mjs new file mode 100644 index 000000000000..104d9144f5f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/no-orchestrion.mjs @@ -0,0 +1,10 @@ +// Does NOT call `experimentalUseDiagnosticsChannelInjection()`, so a bundler +// must be able to drop the entire orchestrion subtree from the output. +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, +}); + +await import('./app.mjs'); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/with-orchestrion.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/with-orchestrion.mjs new file mode 100644 index 000000000000..29c9ab3d5de8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/with-orchestrion.mjs @@ -0,0 +1,12 @@ +// Calls `experimentalUseDiagnosticsChannelInjection()`, so the orchestrion +// subtree MUST be reachable and end up in the bundle. +import * as Sentry from '@sentry/node'; + +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, +}); + +await import('./app.mjs'); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index d3a61f5814f0..fa5396cc7eae 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -101,6 +101,7 @@ "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", + "esbuild": "0.28.0", "eslint-plugin-regexp": "^3.1.0", "globby": "11", "react": "^18.3.1", diff --git a/dev-packages/node-integration-tests/suites/esbuild/app.ts b/dev-packages/node-integration-tests/suites/esbuild/app.ts new file mode 100644 index 000000000000..6b11a4ce7fda --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/app.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; + +// `@sentry/node` is `sideEffects: false`, so esbuild only evaluates the +// module if we reference an export. +// a module-scope `createRequire(import.meta.url)` throws +// in a CJS bundle, because esbuild rewrites `import.meta.url` to `{}`, +// so it becomes `createRequire(undefined)`, which would break apps that +// do not opt into orchestrion. +// eslint-disable-next-line no-console +console.log(`SENTRY_NODE_LOADED typeof_init=${typeof Sentry.init}`); diff --git a/dev-packages/node-integration-tests/suites/esbuild/test.ts b/dev-packages/node-integration-tests/suites/esbuild/test.ts new file mode 100644 index 000000000000..1d1806f0b843 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/test.ts @@ -0,0 +1,35 @@ +import { spawnSync } from 'child_process'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { build } from 'esbuild'; +import { describe, expect, test } from 'vitest'; + +describe('esbuild bundling', () => { + test('@sentry/node loads when bundled to CommonJS with esbuild', async () => { + const outDir = mkdtempSync(join(tmpdir(), 'sentry-esbuild-cjs-')); + const outfile = join(outDir, 'bundle.cjs'); + + try { + await build({ + entryPoints: [join(__dirname, 'app.ts')], + outfile, + platform: 'node', + format: 'cjs', + bundle: true, + logLevel: 'silent', + }); + + const result = spawnSync('node', [outfile], { encoding: 'utf-8' }); + + // The specific failure signature this guards against. + expect(result.stderr).not.toContain('ERR_INVALID_ARG_VALUE'); + expect(result.stderr).not.toContain('createRequire'); + // The bundle loaded and ran to completion. + expect(result.status).toBe(0); + expect(result.stdout).toContain('SENTRY_NODE_LOADED'); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs index 02bdca0e776d..032187efe33b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs @@ -1,16 +1,17 @@ -// The orchestrion runtime hook is loaded via the `--import @sentry/node/orchestrion` -// CLI flag (see test.ts), mirroring real usage. That single ESM hook instruments -// both ESM and CJS user code, so the same flag works for the esm and cjs scenarios. +// Opting in via `experimentalUseDiagnosticsChannelInjection()` (before `init`) +// is all that's needed. +// +// `Sentry.init()` swaps the OTel `mysql` instrumentation +// for the diagnostics-channel one and synchronously +// installs the module hooks that inject the channels. import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; -const client = Sentry.init({ +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, - _experimentalUseOrchestrion: true, - debug: true, }); - -Sentry._experimentalSetupOrchestrion(client); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts index 1bea6b25706b..6d5ba767cea1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -6,92 +6,92 @@ describe('mysql auto instrumentation', () => { cleanupChildProcesses(); }); - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction', - spans: expect.arrayContaining([ + // Builds the expected transaction. When `origin` is given, the spans must also + // carry that `sentry.origin`, which is how we assert that the + // diagnostics-channel instrumentation (not the OTel one) produced them. + function expectedTransaction(origin?: string): Record { + const span = (description: string): ReturnType => expect.objectContaining({ - description: 'SELECT 1 + 1 AS solution', + description, op: 'db', + ...(origin ? { origin } : {}), data: expect.objectContaining({ 'db.system': 'mysql', 'net.peer.name': 'localhost', 'net.peer.port': 3306, 'db.user': 'root', }), - }), - expect.objectContaining({ - description: 'SELECT NOW()', - op: 'db', - data: expect.objectContaining({ - 'db.system': 'mysql', - 'net.peer.name': 'localhost', - 'net.peer.port': 3306, - 'db.user': 'root', - }), - }), - ]), - }; + }); - describe.each([ - ['opentelemetry-based', 'instrument.mjs'], - ['orchestrion-based', 'instrument-orchestrion.mjs'], - ])('%s', (instrumentation, instrumentFile) => { - // esm is not supported for the otel instrumentation - const failsOnEsm = instrumentation === 'opentelemetry-based'; + return { + transaction: 'Test Transaction', + spans: expect.arrayContaining([span('SELECT 1 + 1 AS solution'), span('SELECT NOW()')]), + }; + } - // The orchestrion path is activated via the `--import @sentry/node/orchestrion` - // CLI flag. That single ESM hook instruments both ESM and CJS user code (via - // `Module.registerHooks` where available, otherwise `Module.register` + the - // CJS `Module._compile` patch), so the same flag covers the esm and cjs - // scenarios. The OTel path needs no extra flag. - const orchestrionFlags = instrumentation === 'orchestrion-based' ? ['--import', '@sentry/node/orchestrion'] : []; + const CHANNEL_ORIGIN = 'auto.db.orchestrion.mysql'; - createEsmAndCjsTests( - __dirname, - 'scenario-withConnect.mjs', - instrumentFile, - (createRunner, test) => { - test('should auto-instrument `mysql` package when using connection.connect()', async () => { - await createRunner() - .withFlags(...orchestrionFlags) - .expect({ transaction: EXPECTED_TRANSACTION }) - .start() - .completed(); - }); - }, - { failsOnEsm }, - ); + // 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. + const CASES = [ + // OpenTelemetry default — no opt-in, no injection. (OTel does not support ESM.) + { label: 'opentelemetry (default)', instrument: 'instrument.mjs', flags: [], origin: undefined, failsOnEsm: true }, + // Opt-in via init only. `Sentry.init()` injects the channels synchronously. + { + label: 'diagnostics-channel (init opt-in)', + instrument: 'instrument-orchestrion.mjs', + flags: [], + origin: CHANNEL_ORIGIN, + failsOnEsm: false, + }, + // Opt-in and rely on `node --import @sentry/node/import`. + { + label: 'diagnostics-channel (--import @sentry/node/import opt-in)', + instrument: 'instrument-orchestrion.mjs', + flags: ['--import', '@sentry/node/import'], + origin: CHANNEL_ORIGIN, + failsOnEsm: false, + }, + // Without opt-in: channels are injected unconditionally but not subscribed + // to, so the OTel instrumentation records the spans — proves injecting the + // channels has no downside. (OTel does not support ESM.) + { + label: 'opentelemetry (channels injected, no opt-in)', + instrument: 'instrument.mjs', + flags: ['--import', '@sentry/node/import'], + origin: undefined, + failsOnEsm: true, + }, + ] as const; - createEsmAndCjsTests( - __dirname, - 'scenario-withoutCallback.mjs', - instrumentFile, - (createRunner, test) => { - test('should auto-instrument `mysql` package when using query without callback', async () => { - await createRunner() - .withFlags(...orchestrionFlags) - .expect({ transaction: EXPECTED_TRANSACTION }) - .start() - .completed(); - }); - }, - { failsOnEsm }, - ); + const SCENARIOS = [ + ['scenario-withConnect.mjs', 'using connection.connect()'], + ['scenario-withoutCallback.mjs', 'using query without callback'], + ['scenario-withoutConnect.mjs', 'without connection.connect()'], + ] as const; - createEsmAndCjsTests( - __dirname, - 'scenario-withoutConnect.mjs', - instrumentFile, - (createRunner, test) => { - test('should auto-instrument `mysql` package without connection.connect()', async () => { - await createRunner() - .withFlags(...orchestrionFlags) - .expect({ transaction: EXPECTED_TRANSACTION }) - .start() - .completed(); - }); - }, - { failsOnEsm }, - ); - }); + for (const { label, instrument, flags, origin, failsOnEsm } of CASES) { + describe(label, () => { + const expected = expectedTransaction(origin); + + for (const [scenario, description] of SCENARIOS) { + createEsmAndCjsTests( + __dirname, + scenario, + instrument, + (createRunner, test) => { + test(`should auto-instrument \`mysql\` package when ${description}`, async () => { + await createRunner() + .withFlags(...flags) + .expect({ transaction: expected }) + .start() + .completed(); + }); + }, + { failsOnEsm }, + ); + } + }); + } }); diff --git a/dev-packages/rollup-utils/code/otelEsmImportHookWithDiagnosticsChannelTemplate.js b/dev-packages/rollup-utils/code/otelEsmImportHookWithDiagnosticsChannelTemplate.js new file mode 100644 index 000000000000..e221b22cbd4a --- /dev/null +++ b/dev-packages/rollup-utils/code/otelEsmImportHookWithDiagnosticsChannelTemplate.js @@ -0,0 +1,10 @@ +// Like otelEsmImportHookTemplate.js, but also registers the diagnostics-channel +// injection so that `node --import @sentry/node/import app.js` injects the +// channels unconditionally (they are only *subscribed* to when the app opts in +// via `experimentalUseDiagnosticsChannelInjection()`). +import '@sentry/server-utils/orchestrion/import-hook'; +import { register } from 'module'; + +register('@opentelemetry/instrumentation/hook.mjs', import.meta.url); + +globalThis._sentryEsmLoaderHookRegistered = true; diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 4a184d1ea4e5..10d3132cb84f 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -179,12 +179,25 @@ export function makeNPMConfigVariants(baseConfig, options = {}) { /** * This creates a loader file at the target location as part of the rollup build. * This loader script can then be used in combination with various Node.js flags (like --import=...) to monkeypatch 3rd party modules. + * + * @param {string} outputFolder Build output folder. + * @param {'otel' | 'sentry-node'} hookVariant Which hook template to use. + * @param {{ injectDiagnosticsChannel?: boolean }} [options] When `injectDiagnosticsChannel` + * is set (only valid for the `'otel'` variant), the generated `import-hook.mjs` + * additionally imports `@sentry/server-utils/orchestrion/import-hook`, which + * registers the diagnostics-channel injection. Used by `@sentry/node` so that + * `node --import @sentry/node/import` injects the channels unconditionally. */ -export function makeOtelLoaders(outputFolder, hookVariant) { +export function makeOtelLoaders(outputFolder, hookVariant, options = {}) { if (hookVariant !== 'otel' && hookVariant !== 'sentry-node') { throw new Error('hookVariant is neither "otel" nor "sentry-node". Pick one.'); } + const { injectDiagnosticsChannel = false } = options; + if (injectDiagnosticsChannel && hookVariant !== 'otel') { + throw new Error('injectDiagnosticsChannel is only supported with the "otel" hookVariant.'); + } + const expectedRegisterLoaderLocation = `${outputFolder}/import-hook.mjs`; const foundRegisterLoaderExport = Object.keys(packageDotJSON.exports ?? {}).some(key => { return packageDotJSON?.exports?.[key]?.import?.default === expectedRegisterLoaderLocation; @@ -229,7 +242,11 @@ export function makeOtelLoaders(outputFolder, hookVariant) { input: path.join( __dirname, 'code', - hookVariant === 'otel' ? 'otelEsmImportHookTemplate.js' : 'sentryNodeEsmImportHookTemplate.js', + hookVariant === 'otel' + ? injectDiagnosticsChannel + ? 'otelEsmImportHookWithDiagnosticsChannelTemplate.js' + : 'otelEsmImportHookTemplate.js' + : 'sentryNodeEsmImportHookTemplate.js', ), external: /.*/, output: { diff --git a/packages/node/package.json b/packages/node/package.json index 33f3ce6f39ad..914b882bed4b 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -52,17 +52,6 @@ "require": { "default": "./build/cjs/preload.js" } - }, - "./orchestrion": { - "import": { - "default": "./build/orchestrion/import-hook.mjs" - } - }, - "./orchestrion/vite": { - "import": { - "types": "./build/types/orchestrion/bundler/vite.d.ts", - "default": "./build/esm/orchestrion/bundler/vite.js" - } } }, "typesVersions": { @@ -123,7 +112,8 @@ "{projectRoot}/build/cjs", "{projectRoot}/build/npm/esm", "{projectRoot}/build/npm/cjs", - "{projectRoot}/build/orchestrion" + "{projectRoot}/build/import-hook.mjs", + "{projectRoot}/build/loader-hook.mjs" ] } } diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index ac547f786201..3f6d1b28bf93 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -1,32 +1,15 @@ -import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils'; -// EXPERIMENTAL — orchestrion.js runtime hook. A tiny hand-written `.mjs` shim -// that the user references via `node --import @sentry/node/orchestrion`. It -// installs both the ESM loader and a `Module.prototype._compile` patch, so it -// also covers CJS-internal `require()` calls — no separate `--require` hook is -// needed. We pass it through rollup only to copy it into `build/` at the path -// the package.json `exports` map expects; `external: /.*/` keeps every import -// (e.g. `@sentry/server-utils/orchestrion/import-hook`) as a runtime -// resolution against the installed package. -const orchestrionRuntimeHooks = [ - defineConfig({ - input: 'src/orchestrion/runtime/import-hook.mjs', - external: /.*/, - output: { format: 'esm', file: 'build/orchestrion/import-hook.mjs' }, - }), -]; - export default [ - ...makeOtelLoaders('./build', 'otel'), - ...orchestrionRuntimeHooks, + // `injectDiagnosticsChannel` makes the generated `@sentry/node/import` hook + // also register the diagnostics-channel injection, so `node --import + // @sentry/node/import app.js` injects the channels unconditionally (they are + // only subscribed to when the app opts in via + // `experimentalUseDiagnosticsChannelInjection()`). + ...makeOtelLoaders('./build', 'otel', { injectDiagnosticsChannel: true }), ...makeNPMConfigVariants( makeBaseNPMConfig({ - // `src/orchestrion/bundler/vite.ts` is loaded via the dedicated - // `@sentry/node/orchestrion/vite` subpath export and is not reachable from - // `src/index.ts`, so we list it as a separate entrypoint to guarantee it - // ends up in build/esm and build/cjs. - entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts', 'src/orchestrion/bundler/vite.ts'], + entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts'], packageSpecificConfig: { external: [/^@sentry\/opentelemetry/], output: { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index cff78492a37f..df90fd85e755 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -47,7 +47,7 @@ export { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations, } from './sdk'; -export { _experimentalSetupOrchestrion, mysqlChannelIntegration } from './orchestrion'; +export { experimentalUseDiagnosticsChannelInjection } from './sdk/experimentalUseDiagnosticsChannelInjection'; export { initOpenTelemetry, preloadOpenTelemetry } from './sdk/initOtel'; export { getAutoPerformanceIntegrations } from './integrations/tracing'; diff --git a/packages/node/src/orchestrion/bundler/vite.ts b/packages/node/src/orchestrion/bundler/vite.ts deleted file mode 100644 index 0939415fcd5f..000000000000 --- a/packages/node/src/orchestrion/bundler/vite.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Re-export of the shared orchestrion Vite plugin. The implementation lives in -// `@sentry/server-utils`; this file preserves the -// `@sentry/node/orchestrion/vite` subpath export. ESM-only, matching the -// upstream `@apm-js-collab/code-transformer-bundler-plugins` package. -export { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; diff --git a/packages/node/src/orchestrion/index.ts b/packages/node/src/orchestrion/index.ts deleted file mode 100644 index 34b63a9c8076..000000000000 --- a/packages/node/src/orchestrion/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { _experimentalSetupOrchestrion } from './setup'; -export type { ExperimentalSetupOrchestrionOptions } from './setup'; -export { mysqlChannelIntegration } from '@sentry/server-utils/orchestrion'; diff --git a/packages/node/src/orchestrion/runtime/import-hook.mjs b/packages/node/src/orchestrion/runtime/import-hook.mjs deleted file mode 100644 index 36d06e8622e3..000000000000 --- a/packages/node/src/orchestrion/runtime/import-hook.mjs +++ /dev/null @@ -1,8 +0,0 @@ -// EXPERIMENTAL — entry point for `node --import @sentry/node/orchestrion app.js`. -// -// Delegates to the shared orchestrion runtime hook in -// `@sentry/server-utils`, which registers the orchestrion ESM loader -// (and CJS `Module.prototype._compile` patch) with the central instrumentation -// config and sets `globalThis.__SENTRY_ORCHESTRION__.runtime`. Kept as a thin -// node-resident shim so the `@sentry/node/orchestrion` subpath keeps working. -import '@sentry/server-utils/orchestrion/import-hook'; diff --git a/packages/node/src/orchestrion/setup.ts b/packages/node/src/orchestrion/setup.ts deleted file mode 100644 index 2d5b47b8f3e0..000000000000 --- a/packages/node/src/orchestrion/setup.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { debug } from '@sentry/core'; -import type { SetupOrchestrionOptions } from '@sentry/server-utils/orchestrion'; -import { setupOrchestrion } from '@sentry/server-utils/orchestrion'; -import type { NodeClient } from '@sentry/node-core'; -import { DEBUG_BUILD } from '../debug-build'; - -export type ExperimentalSetupOrchestrionOptions = SetupOrchestrionOptions; - -/** - * EXPERIMENTAL — wires up orchestrion-driven channel integrations. - * - * Must be called after `Sentry.init({ _experimentalUseOrchestrion: true })`, with - * the client returned by `init()`: - * - * ```ts - * const client = Sentry.init({ dsn: '…', _experimentalUseOrchestrion: true }); - * _experimentalSetupOrchestrion(client); - * ``` - * - * This is the ONLY exported entry into the orchestrion code path. Bundlers can - * statically determine that apps which never import this drop the entire - * `orchestrion/` subtree from their output — that is the tree-shaking guarantee. - * - * The actual implementation lives in `@sentry/server-utils`; this Node - * wrapper only adds the experimental opt-in check tied to `NodeOptions`. - */ -export function _experimentalSetupOrchestrion( - client: NodeClient | undefined, - options: ExperimentalSetupOrchestrionOptions = {}, -): void { - // Node-specific: verify the user remembered to set the experimental flag on - // init(), which is what makes `init()` skip the OTel integrations these - // channel-based ones replace. Without it, both systems instrument the same - // library and produce duplicate spans. - if (client && !(client.getOptions() as { _experimentalUseOrchestrion?: boolean })._experimentalUseOrchestrion) { - DEBUG_BUILD && - debug.warn( - '[Sentry] _experimentalSetupOrchestrion() called but Sentry.init() was not given ' + - '`_experimentalUseOrchestrion: true` — it will use default instrumentation instead of ' + - 'channel-based instrumentation. Add the flag to Sentry.init().', - ); - } - - setupOrchestrion(client, options); -} diff --git a/packages/node/src/sdk/diagnosticsChannelInjection.ts b/packages/node/src/sdk/diagnosticsChannelInjection.ts new file mode 100644 index 000000000000..9f51c053d30a --- /dev/null +++ b/packages/node/src/sdk/diagnosticsChannelInjection.ts @@ -0,0 +1,56 @@ +import type { Integration } from '@sentry/core'; + +/** + * The orchestrion-driven pieces, resolved lazily by the opt-in loader. + * + * IMPORTANT: this module (and everything `init()` imports) must NOT reference + * the orchestrion code (`@sentry/server-utils/orchestrion/*`). The only + * reference lives inside `experimentalUseDiagnosticsChannelInjection()` (a + * separate module, reachable solely through that public export). That's the + * tree-shaking boundary: if an app never calls the opt-in function, then a + * bundler drops the entire orchestrion subtree, including its transitive + * dependencies, while an app that does call it gets it bundled + * normally. + */ +export interface DiagnosticsChannelInjection { + /** Channel-based integrations to register, replacing their OTel equivalents. */ + integrations: Integration[]; + /** OTel integration names these replace; filtered out of the default set. */ + replacedOtelIntegrationNames: string[]; + /** Installs the module hooks that inject the diagnostics channels. */ + register: () => void; + /** Warns (DEBUG only) about missing or doubled channel injection. */ + detect: () => void; +} + +let loader: (() => DiagnosticsChannelInjection) | undefined; +let cached: DiagnosticsChannelInjection | undefined; + +/** + * Set by `experimentalUseDiagnosticsChannelInjection()`. The loader + * is the only thing that pulls in the orchestrion modules; see + * {@link DiagnosticsChannelInjection) re tree-shaking concerns this addresses. + * + * @internal + */ +export function setDiagnosticsChannelInjectionLoader(load: () => DiagnosticsChannelInjection): void { + loader = load; +} + +/** Whether `experimentalUseDiagnosticsChannelInjection()` was called. */ +export function isDiagnosticsChannelInjectionEnabled(): boolean { + return !!loader; +} + +/** + * Resolve and memoize the orchestrion pieces. This is what actually loads + * the orchestrion modules. Returns `undefined` if the app never opted in. + * Callers gate this on span recording, so the modules load only when both + * opted in and tracing is enabled. + */ +export function resolveDiagnosticsChannelInjection(): DiagnosticsChannelInjection | undefined { + if (!loader) { + return undefined; + } + return (cached ??= loader()); +} diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts new file mode 100644 index 000000000000..d51f2d86a610 --- /dev/null +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -0,0 +1,47 @@ +import { mysqlChannelIntegration, detectOrchestrionSetup } from '@sentry/server-utils/orchestrion'; +import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; +import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; +import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInjection'; + +/** + * EXPERIMENTAL: opt into diagnostics-channel-based auto-instrumentation. + * + * Call this BEFORE `Sentry.init()`: + * + * ```ts + * import * as Sentry from '@sentry/node'; + * + * Sentry.experimentalUseDiagnosticsChannelInjection(); + * Sentry.init({ + * dsn: '__DSN__', + * // other settings... + * }); + * ``` + * + * When this has been called AND span recording is enabled, `Sentry.init()` + * uses the diagnostics-channel-injection-based integrations instead of the + * OpenTelemetry ones, and installs the module hooks that inject the channels + * (so libraries imported after `init()` publish the channel events). + * + * This is a standalone function rather than an `init()` option so that a + * bundler drops all of it (and its transitive deps) when this function isn't + * called. `init()` reads the loader registered below. + * + * An app that DOES call it gets the orchestrion code bundled as intended. + * + * In an unbundled (server-side runtime) app this eagerly loads only the small + * subscriber/channel modules; the heavy code-transform dependencies stay lazy + * inside `register()` and load only when injection actually runs. + * + * @experimental May change or be removed in any release. + */ +export function experimentalUseDiagnosticsChannelInjection(): void { + setDiagnosticsChannelInjectionLoader( + (): DiagnosticsChannelInjection => ({ + integrations: [mysqlChannelIntegration()], + replacedOtelIntegrationNames: ['Mysql'], + register: registerDiagnosticsChannelInjection, + detect: detectOrchestrionSetup, + }), + ); +} diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 49632b8c3998..8c8d2e887541 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -10,6 +10,10 @@ import { httpIntegration } from '../integrations/http'; import { nativeNodeFetchIntegration } from '../integrations/node-fetch'; import { getAutoPerformanceIntegrations } from '../integrations/tracing'; import type { NodeOptions } from '../types'; +import { + isDiagnosticsChannelInjectionEnabled, + resolveDiagnosticsChannelInjection, +} from './diagnosticsChannelInjection'; import { initOpenTelemetry } from './initOtel'; /** @@ -24,19 +28,6 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { .concat(httpIntegration(), nativeNodeFetchIntegration()); } -/** - * Names of OTel-based default integrations that the orchestrion experiment - * replaces with channel-based equivalents. When - * `_experimentalUseOrchestrion: true` is set on `Sentry.init()`, these are - * filtered out of the default integration list so the two systems don't both - * instrument the same library and produce duplicate spans. - * - * Kept as a plain string set (instead of importing the orchestrion integrations - * themselves) so the orchestrion code path stays tree-shakable: `init()` never - * pulls in anything from `server-utils/orchestrion/*`. - */ -const ORCHESTRION_REPLACED_INTEGRATIONS = new Set(['Mysql']); - /** Get the default integrations for the Node SDK. */ export function getDefaultIntegrations(options: Options): Integration[] { const integrations: Integration[] = [ @@ -48,8 +39,21 @@ export function getDefaultIntegrations(options: Options): Integration[] { ...(hasSpansEnabled(options) ? getAutoPerformanceIntegrations() : []), ]; - if ((options as NodeOptions)._experimentalUseOrchestrion) { - return integrations.filter(i => !ORCHESTRION_REPLACED_INTEGRATIONS.has(i.name)); + // When the app opted into diagnostics-channel injection (via + // `experimentalUseDiagnosticsChannelInjection()`) AND span recording is + // enabled, swap the channel-based integrations in place of OTel equivalents + // so the two don't both instrument the same library. + // + // Every channel-based integration we ship today is a 1:1 replacement for an + // OTel performance/tracing integration and produces nothing but spans (those + // only come from `getAutoPerformanceIntegrations()` above), so it's gated on + // span recording. + if (isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options)) { + const diagnosticsChannelInjection = resolveDiagnosticsChannelInjection(); + if (diagnosticsChannelInjection) { + const replaced = new Set(diagnosticsChannelInjection.replacedOtelIntegrationNames); + return [...integrations.filter(i => !replaced.has(i.name)), ...diagnosticsChannelInjection.integrations]; + } } return integrations; } @@ -70,6 +74,22 @@ function _init( ): NodeClient | undefined { applySdkMetadata(options, 'node'); + // EXPERIMENTAL: diagnostics-channel injection, opted into via + // `experimentalUseDiagnosticsChannelInjection()`. Gated on span recording to + // match the OTel integrations it replaces. With tracing off there are no + // channel subscribers, so injecting is pointless work. `resolve...()` is + // memoized, so `getDefaultIntegrations()` (below) sees the same instance. + const diagnosticsChannelInjection = + isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options) + ? resolveDiagnosticsChannelInjection() + : undefined; + + // Install the channel-injection hooks as early as possible, before the app + // imports its instrumented modules. + if (diagnosticsChannelInjection) { + diagnosticsChannelInjection.register(); + } + const client = initNodeCore({ ...options, // Only use Node SDK defaults if none provided @@ -84,6 +104,12 @@ function _init( validateOpenTelemetrySetup(); } + // Warn about missing or doubled channel injection. Runs after the client + // is created so the debug logger is enabled and the warning is emitted. + if (diagnosticsChannelInjection) { + diagnosticsChannelInjection.detect(); + } + return client; } diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 869fd4098b78..3a0cb1e7e5fc 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -65,23 +65,6 @@ export interface BaseNodeOptions extends OpenTelemetryServerRuntimeOptions { * Defaults to `true`. */ registerEsmLoaderHooks?: boolean; - - /** - * EXPERIMENTAL — opt into the orchestrion.js-based auto-instrumentation path. - * - * When `true`, `Sentry.init()` skips registering the default OTel - * auto-instrumentations for libraries that have a channel-based alternative - * (currently: `mysql`). It does NOT install any channel subscribers on its - * own — call `_experimentalSetupOrchestrion(client)` after `init()` for that. - * - * Splitting the opt-in across two calls keeps the orchestrion code path - * tree-shakable: bundlers can drop `orchestrion/*` from apps that don't - * import `_experimentalSetupOrchestrion`. - * - * Defaults to `false`. The flag name is intentionally underscore-prefixed and - * will be renamed or removed once the experiment graduates. - */ - _experimentalUseOrchestrion?: boolean; } /** diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index c4c847b9512d..b0eb9ecb6476 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -4,9 +4,4 @@ "include": ["src/**/*"], "compilerOptions": {} - // The orchestrion runtime hooks are hand-written `.mjs` / `.cjs` files that - // rollup copies through to `build/orchestrion/` unchanged. We exclude them so - // tsc doesn't try to type-check or emit declarations for them and stays - // focused on the `.ts` sources. - "exclude": ["src/orchestrion/runtime/**/*.mjs", "src/orchestrion/runtime/**/*.cjs"] } diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 088b5d968d41..4ce497259d59 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -47,6 +47,16 @@ "default": "./build/cjs/orchestrion/config.js" } }, + "./orchestrion/register": { + "import": { + "types": "./build/types/orchestrion/runtime/register.d.ts", + "default": "./build/esm/orchestrion/runtime/register.js" + }, + "require": { + "types": "./build/types/orchestrion/runtime/register.d.ts", + "default": "./build/cjs/orchestrion/runtime/register.js" + } + }, "./orchestrion/vite": { "types": "./build/types/orchestrion/bundler/vite.d.ts", "import": { @@ -70,6 +80,9 @@ "orchestrion/config": [ "build/types-ts3.8/orchestrion/config.d.ts" ], + "orchestrion/register": [ + "build/types-ts3.8/orchestrion/runtime/register.d.ts" + ], "orchestrion/vite": [ "build/types-ts3.8/orchestrion/bundler/vite.d.ts" ] @@ -81,6 +94,9 @@ "orchestrion/config": [ "build/types/orchestrion/config.d.ts" ], + "orchestrion/register": [ + "build/types/orchestrion/runtime/register.d.ts" + ], "orchestrion/vite": [ "build/types/orchestrion/bundler/vite.d.ts" ] @@ -90,9 +106,9 @@ "access": "public" }, "dependencies": { - "@apm-js-collab/code-transformer-bundler-plugins": "^0.3.0", - "@apm-js-collab/code-transformer": "^0.13.0", - "@apm-js-collab/tracing-hooks": "^0.8.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", + "@apm-js-collab/code-transformer": "^0.15.0", + "@apm-js-collab/tracing-hooks": "^0.10.0", "@sentry/conventions": "^0.12.0", "@sentry/core": "10.58.0", "magic-string": "~0.30.0" diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 161e42eb5c23..21942c9340c8 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -29,6 +29,10 @@ export default [ 'src/index.ts', 'src/orchestrion/index.ts', 'src/orchestrion/config.ts', + // `src/orchestrion/runtime/register.ts` backs the `./orchestrion/register` + // subpath export; the Node SDK `require`s it synchronously from + // `Sentry.init()` to install the channel-injection hooks. + 'src/orchestrion/runtime/register.ts', 'src/orchestrion/bundler/vite.ts', ], packageSpecificConfig: { diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index 5513650aafc0..60b6070740ba 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -7,12 +7,19 @@ declare global { } /** - * Verifies that orchestrion has been setup, either: - * - the runtime hook (`node --import @sentry/node/orchestrion app.js`), OR - * - the bundler plugin (`sentryOrchestrionPlugin()`) + * Verifies that the diagnostics channels have been injected either by the + * runtime `--import` hook (or init-time registration), a bundler plugin, or + * both, and warns if not. * - * Note: do NOT warn in production, only in debug builds, because - * production warnings are reserved for truly critical issues. + * Both injectors being active at once is fine: they operate on disjoint module + * sets (a module is either loaded through Node's loader and transformed by the + * runtime hook, or inlined by the bundler and transformed by the plugin), so + * a single module can't be double-wrapped. A hybrid setup, with some deps + * external and runtime-instrumented, others bundled and plugin-instrumented, + * is fine. + * + * Note: intentionally does NOT warn in production, only in debug builds, + * because production warnings are reserved for truly critical issues. */ export function detectOrchestrionSetup(): void { if (!DEBUG_BUILD) return; @@ -21,14 +28,14 @@ export function detectOrchestrionSetup(): void { const runtime = !!marker?.runtime; const bundler = !!marker?.bundler; - debug.log(`[orchestrion] detect: runtime=${runtime} bundler=${bundler}`); + DEBUG_BUILD && debug.log(`[orchestrion] detect: runtime=${runtime} bundler=${bundler}`); if (!runtime && !bundler) { - debug.warn( - '[Sentry] No orchestrion auto-instrumentation hook detected. Channel-based integrations ' + - '(mysql, …) will not record spans. Either run with ' + - '`node --import @sentry/node/orchestrion app.js`, or add `sentryOrchestrionPlugin()` ' + - 'to your bundler config.', - ); + DEBUG_BUILD && + debug.warn( + '[Sentry] No diagnostics-channel injection detected. Channel-based integrations ' + + '(mysql, …) will not record spans. Make sure the diagnostics channels are injected ' + + 'via the runtime `--import` hook or a bundler plugin before the instrumented modules load.', + ); } } diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index b0c2ea982c33..dd3ecd0f8f19 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,4 +1,2 @@ -export { setupOrchestrion } from './setup'; -export type { SetupOrchestrionOptions } from './setup'; export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; diff --git a/packages/server-utils/src/orchestrion/runtime/import-hook.mjs b/packages/server-utils/src/orchestrion/runtime/import-hook.mjs index 63d7a0b624fa..49a08597cfba 100644 --- a/packages/server-utils/src/orchestrion/runtime/import-hook.mjs +++ b/packages/server-utils/src/orchestrion/runtime/import-hook.mjs @@ -1,65 +1,18 @@ -// EXPERIMENTAL — entry point for `node --import @sentry/node/orchestrion app.js`. +// EXPERIMENTAL — diagnostics-channel injection runtime hook. The side-effecting +// `--import` entry (e.g. `node --import @sentry/node/import app.js`) that injects +// the channels unconditionally before the app loads. // -// Registers the orchestrion ESM loader with the central instrumentation config, -// and sets a global marker (`globalThis.__SENTRY_ORCHESTRION__.runtime`) so -// `detectOrchestrionSetup()` at `_experimentalSetupOrchestrion(client)` time can -// see that the runtime hook ran. +// All of the registration logic lives in `register.ts` — it has to be a +// CJS-compatible, dual-built module so `Sentry.init()` can `require()` it +// synchronously, and keeping a single source of truth means the `--import` path +// and the `init()` path can never drift apart. This file is just the +// side-effecting wrapper that invokes it. // // This file is shipped as-is to `build/orchestrion/import-hook.mjs`. Keep it a // single self-contained `.mjs` file with no relative-path imports — `--import` -// resolves it via Node's module resolution against the installed package. +// resolves it (and the bare specifier below) via Node's module resolution +// against the installed package. -import Module from 'node:module'; -import { initialize, resolve, load } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'; -import ModulePatch from '@apm-js-collab/tracing-hooks'; -import { SENTRY_INSTRUMENTATIONS } from '@sentry/server-utils/orchestrion/config'; +import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; -const DEBUG = !!(process.env.DEBUG || process.env.debug || process.env.SENTRY_DEBUG); -// eslint-disable-next-line no-console -const debug = (...args) => DEBUG && console.log('[Sentry orchestrion]', ...args); - -debug('import-hook.mjs loaded, instrumentations:', SENTRY_INSTRUMENTATIONS); - -// detection to decide module loader hooks to use -// registerHooks was present but not stable until 24.13 and 25.1 -const nodeVersion = (process.versions.node ?? '0.0.0').split('.').map(n => parseInt(n, 10)); -// registerHooks available in Deno 2.8.0 -const denoVersion = (globalThis.Deno?.version?.deno ?? '0.0.0').split('.').map(n => parseInt(n, 10)); -const stableSyncHooks = - nodeVersion[0] > 25 || - (nodeVersion[0] === 25 && nodeVersion[1] >= 1) || - (nodeVersion[0] === 24 && nodeVersion[1] >= 13) || - denoVersion[0] > 2 || - (denoVersion[0] === 2 && denoVersion[1] >= 8); - -const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); - -// double-load guard -if (!g.runtime) { - if (typeof Module.registerHooks === 'function' && stableSyncHooks) { - initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); - Module.registerHooks({ resolve, load }); - debug('Module.registerHooks() called for @apm-js-collab/tracing-hooks/hook-sync.mjs'); - } else if (typeof Module.register === 'function' && !globalThis.Bun && !globalThis.Deno) { - Module.register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { - data: { instrumentations: SENTRY_INSTRUMENTATIONS }, - }); - debug('Module.register() called for @apm-js-collab/tracing-hooks/hook.mjs'); - - // ALSO patch `Module.prototype._compile` for the CJS side: when - // an ESM file `import`s a CJS package, Node loads the package's - // entry through the ESM bridge but resolves the package's - // INTERNAL `require()` calls through the CJS machinery. - // Those internal requires never reach the ESM resolve hook, so - // without this patch the file we actually want to instrument is - // loaded untransformed. - // This isn't necessary in the registerHooks case, because Node - // applies those hooks to all CJS and ESM modules. - new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); - } else { - throw new Error('No available API to apply module load hooks'); - } - - // successfully added runtime hooks, set the flag. - g.runtime = true; -} +registerDiagnosticsChannelInjection(); diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts new file mode 100644 index 000000000000..48a63a732a5d --- /dev/null +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -0,0 +1,120 @@ +import { debug } from '@sentry/core'; +import { createRequire } from 'node:module'; +import * as Module from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { DEBUG_BUILD } from '../../debug-build'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; + +declare global { + // eslint-disable-next-line no-var + var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; +} + +/** + * Synchronously register the diagnostics-channel injection module hooks. + * + * This is the single source of truth for the registration logic. It is used by: + * - `Sentry.init()` (the Node SDK calls it directly — that's why this module + * must be CJS-compatible / dual-built, so it can be `require()`d synchronously + * before the app's `import`s resolve), and + * - `import-hook.mjs`, the side-effecting `--import` entry, which just calls it. + * + * Libraries imported *after* this call publish the `tracingChannel` events that + * the channel-based integrations subscribe to. + * + * Idempotent via `globalThis.__SENTRY_ORCHESTRION__` — a no-op if the runtime + * `--import` hook or a bundler plugin already injected the channels. + */ +export function registerDiagnosticsChannelInjection(): void { + const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); + + // Already injected (runtime --import hook or bundler plugin) — nothing to do. + if (g.runtime || g.bundler) { + return; + } + + const globalAny = globalThis as { Bun?: unknown; Deno?: { version?: { deno?: string } } }; + const parseVersion = (v: string): number[] => v.split('.').map(n => parseInt(n, 10)); + const nodeVersion = parseVersion(process.versions.node ?? '0.0.0'); + const denoVersion = parseVersion(globalAny.Deno?.version?.deno ?? '0.0.0'); + // `Module.registerHooks` only became stable in Node 24.13 / 25.1 and Deno 2.8. + const stableSyncHooks = + (nodeVersion[0] ?? 0) > 25 || + (nodeVersion[0] === 25 && (nodeVersion[1] ?? 0) >= 1) || + (nodeVersion[0] === 24 && (nodeVersion[1] ?? 0) >= 13) || + (denoVersion[0] ?? 0) > 2 || + (denoVersion[0] === 2 && (denoVersion[1] ?? 0) >= 8); + + // Prefer the builtin `require` if possible. This is present in CommonJS, + // including a bundler's CJS output, so no need to ever have to evaluate + // `import.meta.url` there. + // + // esbuild and friends rewrite `import.meta.url` to `{}` for CJS output, + // which would make `createRequire(undefined)` throw. + // Only use `import.meta.url` in true ESM, where there's no `require` + const nodeRequire = typeof require === 'function' ? require : createRequire(import.meta.url); + + // `Module.registerHooks` / `Module.register` are newer than the @types/node + // we build against, hence the cast. + const mod = Module as unknown as { + registerHooks?: (hooks: unknown) => void; + register?: (specifier: string, options: unknown) => 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 + try { + if (typeof mod.registerHooks === 'function' && stableSyncHooks) { + // Sync hooks cover CJS and ESM, no separate `_compile` patch needed. + // We require() the module here so that we can synchronously load it, + // including from a CommonJS Sentry build, without bundlers pulling in. + // All versions in stableSyncHooks support this. + const { initialize, resolve, load } = nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') as { + initialize: (opts: { instrumentations: unknown }) => void; + resolve: unknown; + load: unknown; + }; + initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); + mod.registerHooks({ resolve, load }); + DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.registerHooks()'); + } else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) { + // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 + // path. Bun/Deno are excluded: they don't support this combination and + // must use the stable `registerHooks` path above (or none at all). + // Resolve the hook to an absolute file URL ourselves so + // `Module.register` needs no `parentURL`, so no need for + // `import.meta.url` polyfilling + mod.register(pathToFileURL(nodeRequire.resolve('@apm-js-collab/tracing-hooks/hook.mjs')).href, { + data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + }); + + // ALSO patch `Module.prototype._compile` for the CJS side: when an ESM + // file `import`s a CJS package, the package's internal `require()` calls + // are resolved through the CJS machinery and never reach the ESM + // register hook, so without this patch the file we want to instrument + // loads untransformed. + const ModulePatch = nodeRequire('@apm-js-collab/tracing-hooks') as new (opts: { instrumentations: unknown }) => { + patch: () => void; + }; + new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); + DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.register()'); + } else { + DEBUG_BUILD && + debug.warn('[Sentry] No available Node API to register diagnostics-channel injection hooks; skipping.'); + return; + } + } catch (error) { + DEBUG_BUILD && + debug.warn( + '[Sentry] Failed to register diagnostics-channel injection hooks; channel-based integrations ' + + 'will not record spans.', + error, + ); + return; + } + + g.runtime = true; +} diff --git a/packages/server-utils/src/orchestrion/setup.ts b/packages/server-utils/src/orchestrion/setup.ts deleted file mode 100644 index b27805c8a9e8..000000000000 --- a/packages/server-utils/src/orchestrion/setup.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Client, Integration } from '@sentry/core'; -import { debug } from '@sentry/core'; -import { DEBUG_BUILD } from '../debug-build'; -import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; -import { detectOrchestrionSetup } from './detect'; - -export interface SetupOrchestrionOptions { - /** - * Override the default set of channel-based integrations. - * If omitted, all orchestrion integrations shipped by server-utils are added. - */ - integrations?: Integration[]; -} - -/** - * Wires up orchestrion-driven channel integrations on the given client. - * - * Must be called after the SDK's `init()`, with the client returned by it: - * - * ```ts - * const client = Sentry.init({ dsn: '…' }); - * setupOrchestrion(client); - * ``` - * - * This is the only exported entry into `orchestrion/*` that registers - * integrations. Bundlers can statically determine that apps which never import - * it drop the entire `orchestrion/` subtree from their output — that is the - * tree-shaking guarantee. - * - * The orchestrion runtime hook (`--import .../orchestrion/import-hook`) or the - * bundler plugin (`sentryOrchestrionPlugin()`) must be active for the - * channel-based integrations to record spans; `detectOrchestrionSetup()` warns - * if neither ran. - */ -export function setupOrchestrion(client: Client | undefined, options: SetupOrchestrionOptions = {}): void { - DEBUG_BUILD && debug.log('[orchestrion] setupOrchestrion() called'); - - if (!client) { - DEBUG_BUILD && - debug.warn('[Sentry] setupOrchestrion() was called without a client. Pass the value returned by `init()`.'); - return; - } - - detectOrchestrionSetup(); - - const integrations = options.integrations ?? [mysqlChannelIntegration()]; - DEBUG_BUILD && - debug.log( - '[orchestrion] registering channel integrations:', - integrations.map(i => i.name), - ); - for (const integration of integrations) { - client.addIntegration(integration); - } -} diff --git a/packages/server-utils/tsconfig.types.json b/packages/server-utils/tsconfig.types.json index 9256d8987fc3..ab12a03a64f2 100644 --- a/packages/server-utils/tsconfig.types.json +++ b/packages/server-utils/tsconfig.types.json @@ -5,9 +5,6 @@ "declarationMap": true, "emitDeclarationOnly": true, "outDir": "build/types", - // Required so Node16 module resolution can disambiguate package self-references - // (`@sentry/server-utils/orchestrion/config` from inside this package) - // against the package's `.` export. Without this tsc reports TS2209. "rootDir": "src" } } diff --git a/yarn.lock b/yarn.lock index 5389064314bf..22e6cd0fb1a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -379,18 +379,20 @@ dependencies: json-schema-to-ts "^3.1.1" -"@apm-js-collab/code-transformer-bundler-plugins@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.3.0.tgz#cc44d2bbfee3f83f2907f3f70b10601c342ec967" - integrity sha512-WjUUf8J+HFHLkA6mbzKyeq54B+WjGM4ZAiCkw0Xb9Nk1X2KJwCEkVScs3gRISuern1xRdsTtOYgihHvcd9mcSg== +"@apm-js-collab/code-transformer-bundler-plugins@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz#8a08136e8281f7e8e36ac6810d2b122c5917de93" + integrity sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ== dependencies: - "@apm-js-collab/code-transformer" "^0.13.0" + "@apm-js-collab/code-transformer" "^0.15.0" + es-module-lexer "^2.1.0" + magic-string "^0.30.21" module-details-from-path "^1.0.4" -"@apm-js-collab/code-transformer@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.13.0.tgz#3bb80cf17f2a09bc19faafb7c6133a5d057488e7" - integrity sha512-JPUR9mNUJV3SP0l6XQ5xGG/3IMOELzNy86vCq/+GOkIUsxEWC6AMIviAQ5sxrfQQEbQofjIzU3kshx4RQnRq7A== +"@apm-js-collab/code-transformer@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz#a3a1b6c7b92db16f8277636b4a72a1626e2fa52a" + integrity sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww== dependencies: "@types/estree" "^1.0.8" astring "^1.9.0" @@ -399,12 +401,12 @@ semifies "^1.0.0" source-map "^0.6.0" -"@apm-js-collab/tracing-hooks@^0.8.0": - version "0.8.0" - resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.8.0.tgz#ab9df110b55f0ad9017521fd2f41f9a31fbe40dd" - integrity sha512-MIvMdVn71MyxevdshkSP8eu3lDtivhbJ5QrcKWg0EHT72GW30nVM+1wU6/CYL8bloHE+YZTU1AhIeSTz4Xfy3A== +"@apm-js-collab/tracing-hooks@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.0.tgz#b31f4bd474380475dc72f57f1b84dd71ba98edde" + integrity sha512-2/Z3NTewJTruUkmsSnBC5bJlLNUd9keuD1OLlTEpim4FyLhm6m2Rnfv+wrFdUvFfhmH8CRdiDZBqBrn+wyaGuA== dependencies: - "@apm-js-collab/code-transformer" "^0.13.0" + "@apm-js-collab/code-transformer" "^0.15.0" debug "^4.4.1" module-details-from-path "^1.0.4" @@ -15616,10 +15618,10 @@ es-module-lexer@^1.3.0, es-module-lexer@^1.3.1, es-module-lexer@^1.6.0, es-modul resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== -es-module-lexer@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.0.0.tgz#f657cd7a9448dcdda9c070a3cb75e5dc1e85f5b1" - integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw== +es-module-lexer@^2.0.0, es-module-lexer@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz#1dfcbb5ea3bbfb63f28e1fc3676c3676d1c9624c" + integrity sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ== es-object-atoms@1.1.1, es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" @@ -15984,6 +15986,38 @@ esbuild@0.27.3: "@esbuild/win32-ia32" "0.27.3" "@esbuild/win32-x64" "0.27.3" +esbuild@0.28.0, esbuild@^0.28.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" + integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.0" + "@esbuild/android-arm" "0.28.0" + "@esbuild/android-arm64" "0.28.0" + "@esbuild/android-x64" "0.28.0" + "@esbuild/darwin-arm64" "0.28.0" + "@esbuild/darwin-x64" "0.28.0" + "@esbuild/freebsd-arm64" "0.28.0" + "@esbuild/freebsd-x64" "0.28.0" + "@esbuild/linux-arm" "0.28.0" + "@esbuild/linux-arm64" "0.28.0" + "@esbuild/linux-ia32" "0.28.0" + "@esbuild/linux-loong64" "0.28.0" + "@esbuild/linux-mips64el" "0.28.0" + "@esbuild/linux-ppc64" "0.28.0" + "@esbuild/linux-riscv64" "0.28.0" + "@esbuild/linux-s390x" "0.28.0" + "@esbuild/linux-x64" "0.28.0" + "@esbuild/netbsd-arm64" "0.28.0" + "@esbuild/netbsd-x64" "0.28.0" + "@esbuild/openbsd-arm64" "0.28.0" + "@esbuild/openbsd-x64" "0.28.0" + "@esbuild/openharmony-arm64" "0.28.0" + "@esbuild/sunos-x64" "0.28.0" + "@esbuild/win32-arm64" "0.28.0" + "@esbuild/win32-ia32" "0.28.0" + "@esbuild/win32-x64" "0.28.0" + esbuild@^0.15.0: version "0.15.18" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.18.tgz#ea894adaf3fbc036d32320a00d4d6e4978a2f36d" @@ -16160,38 +16194,6 @@ esbuild@^0.25.0, esbuild@^0.25.3, esbuild@^0.25.6: "@esbuild/win32-ia32" "0.25.12" "@esbuild/win32-x64" "0.25.12" -esbuild@^0.28.0: - version "0.28.0" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" - integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== - optionalDependencies: - "@esbuild/aix-ppc64" "0.28.0" - "@esbuild/android-arm" "0.28.0" - "@esbuild/android-arm64" "0.28.0" - "@esbuild/android-x64" "0.28.0" - "@esbuild/darwin-arm64" "0.28.0" - "@esbuild/darwin-x64" "0.28.0" - "@esbuild/freebsd-arm64" "0.28.0" - "@esbuild/freebsd-x64" "0.28.0" - "@esbuild/linux-arm" "0.28.0" - "@esbuild/linux-arm64" "0.28.0" - "@esbuild/linux-ia32" "0.28.0" - "@esbuild/linux-loong64" "0.28.0" - "@esbuild/linux-mips64el" "0.28.0" - "@esbuild/linux-ppc64" "0.28.0" - "@esbuild/linux-riscv64" "0.28.0" - "@esbuild/linux-s390x" "0.28.0" - "@esbuild/linux-x64" "0.28.0" - "@esbuild/netbsd-arm64" "0.28.0" - "@esbuild/netbsd-x64" "0.28.0" - "@esbuild/openbsd-arm64" "0.28.0" - "@esbuild/openbsd-x64" "0.28.0" - "@esbuild/openharmony-arm64" "0.28.0" - "@esbuild/sunos-x64" "0.28.0" - "@esbuild/win32-arm64" "0.28.0" - "@esbuild/win32-ia32" "0.28.0" - "@esbuild/win32-x64" "0.28.0" - escalade@3.2.0, escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"