From 5bf10b7fce65f6d2c196284138e5e9ede1687d64 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 09:06:16 +0200 Subject: [PATCH 01/19] add orchestrion plan --- ORCHESTRIONJS_PLAN.md | 480 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 ORCHESTRIONJS_PLAN.md diff --git a/ORCHESTRIONJS_PLAN.md b/ORCHESTRIONJS_PLAN.md new file mode 100644 index 000000000000..679be70c463c --- /dev/null +++ b/ORCHESTRIONJS_PLAN.md @@ -0,0 +1,480 @@ +# 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/require app.js` (CJS). Nothing else. +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/mysql/ + └── tracing-channel.ts ★ subscribes to channels; creates Sentry spans +``` + +The split between `orchestrion/` (plumbing) and `integrations/` (consumers) makes the boundary the user wants explicit: a contributor adding a new channel-driven integration edits `orchestrion/config.ts` (one entry) + `integrations/.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/mysql/tracing-channel.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": { + // ESM --import target. Single mjs file with register() call. + "import": { "default": "./build/orchestrion/import-hook.mjs" } + }, + "./orchestrion/require": { + // CJS --require target. Calls ModulePatch.patch(). + "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 '../config.js'; + +// 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('../config.js'); + +const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); +if (g.runtime) { + console.warn('[Sentry] @sentry/node/orchestrion/require 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 '../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/mysql/tracing-channel'; + +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/require 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. From b0540cd96f0848bd4f23ed26e8e28f14840ea8f5 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 10:56:10 +0200 Subject: [PATCH 02/19] add the plan --- ORCHESTRIONJS_PLAN.md | 74 ++++++++++++++++++++++--------------------- 1 file changed, 38 insertions(+), 36 deletions(-) diff --git a/ORCHESTRIONJS_PLAN.md b/ORCHESTRIONJS_PLAN.md index 679be70c463c..4470ad43e959 100644 --- a/ORCHESTRIONJS_PLAN.md +++ b/ORCHESTRIONJS_PLAN.md @@ -13,19 +13,19 @@ 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. | +| 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 + channelName: string; // diagnostics_channel TracingChannel name module: { name: string; versionRange: string; filePath: string }; - functionQuery: FunctionQuery; // className+methodName / functionName / expressionName / ... + functionQuery: FunctionQuery; // className+methodName / functionName / expressionName / ... }; ``` @@ -37,12 +37,12 @@ This means **one config array** can drive both the runtime hook and every bundle 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/require app.js` (CJS). 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 `_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. + 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 @@ -64,11 +64,11 @@ packages/node/ └── bundler/ ├── vite.ts sentryOrchestrionVitePlugin() — wraps code-transformer/vite + marker └── marker-banner.ts shared "inject `globalThis.__SENTRY_ORCHESTRION__.bundler = true`" plugin -packages/node/src/integrations/mysql/ - └── tracing-channel.ts ★ subscribes to channels; creates Sentry spans +packages/node/src/integrations/tracing-channel/ + └── mysql.ts ★ subscribes to channels; creates Sentry spans ``` -The split between `orchestrion/` (plumbing) and `integrations/` (consumers) makes the boundary the user wants explicit: a contributor adding a new channel-driven integration edits `orchestrion/config.ts` (one entry) + `integrations/.ts` (one subscriber) + adds it to the default list in `orchestrion/setup.ts`. Nothing else. +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. @@ -105,7 +105,7 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ## The integration — channel consumer -`packages/node/src/integrations/mysql/tracing-channel.ts` (sketch): +`packages/node/src/integrations/tracing-channel/mysql.ts` (sketch): ```ts import { channel, tracingChannel } from 'node:diagnostics_channel'; @@ -160,11 +160,9 @@ Add to `packages/node/package.json`: "exports": { // … existing entries … "./orchestrion": { - // ESM --import target. Single mjs file with register() call. - "import": { "default": "./build/orchestrion/import-hook.mjs" } - }, - "./orchestrion/require": { - // CJS --require target. Calls ModulePatch.patch(). + // 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": { @@ -220,7 +218,7 @@ const { SENTRY_INSTRUMENTATIONS } = require('../config.js'); const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); if (g.runtime) { - console.warn('[Sentry] @sentry/node/orchestrion/require was loaded twice via --require. Ignoring.'); + console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --require. Ignoring.'); } else { g.runtime = true; new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); @@ -286,8 +284,8 @@ export function detectOrchestrionSetup(): void { 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.', + '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; } @@ -295,7 +293,7 @@ export function detectOrchestrionSetup(): void { 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.', + 'Either run with `node --import @sentry/node/orchestrion app.js`, or add `sentryOrchestrionPlugin()` to your bundler config.', ); } } @@ -351,7 +349,7 @@ The list of replaced integration names is a plain string set defined alongside ` import { logger } from '@sentry/core'; import type { NodeClient } from '../sdk/client'; import { detectOrchestrionSetup } from './detect'; -import { mysqlChannelIntegration } from '../integrations/mysql/tracing-channel'; +import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; export interface ExperimentalSetupOrchestrionOptions { /** @@ -368,15 +366,15 @@ export function _experimentalSetupOrchestrion( if (!client) { logger.warn( '[Sentry] _experimentalSetupOrchestrion() was called without a client. ' + - 'Pass the value returned by `Sentry.init()`.', + '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().', + '`_experimentalUseOrchestrion: true`. The default OTel integrations are still active — ' + + 'you will get duplicate spans. Add the flag to Sentry.init().', ); } @@ -421,6 +419,7 @@ This keeps the experiment self-contained — no parallel `init` function, no sep ## End-user surface **Bundled app (Vite):** + ```ts // vite.config.ts import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; @@ -438,34 +437,37 @@ _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/require app.js +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 | +| 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. +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. From 98e9f0d86f3930682cf1bf88ef1d3a3124d5af41 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 11:03:57 +0200 Subject: [PATCH 03/19] Add general utils and exports to node SDK --- packages/node/package.json | 38 ++++++++- packages/node/rollup.npm.config.mjs | 34 +++++++- packages/node/src/index.ts | 1 + packages/node/src/orchestrion/bundler/vite.ts | 74 ++++++++++++++++ packages/node/src/orchestrion/channels.ts | 16 ++++ packages/node/src/orchestrion/config.ts | 14 ++++ packages/node/src/orchestrion/detect.ts | 43 ++++++++++ packages/node/src/orchestrion/index.ts | 2 + .../src/orchestrion/runtime/import-hook.mjs | 46 ++++++++++ .../src/orchestrion/runtime/require-hook.cjs | 33 ++++++++ packages/node/src/orchestrion/setup.ts | 69 +++++++++++++++ packages/node/src/sdk/index.ts | 20 ++++- packages/node/src/types.ts | 17 ++++ yarn.lock | 84 ++++++++++++++++++- 14 files changed, 487 insertions(+), 4 deletions(-) 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/runtime/require-hook.cjs create mode 100644 packages/node/src/orchestrion/setup.ts diff --git a/packages/node/package.json b/packages/node/package.json index dfcf07169328..62aa55f5a19f 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -52,6 +52,30 @@ "require": { "default": "./build/cjs/preload.js" } + }, + "./orchestrion": { + "import": { + "default": "./build/orchestrion/import-hook.mjs" + }, + "require": { + "default": "./build/orchestrion/require-hook.cjs" + } + }, + "./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 +89,9 @@ "access": "public" }, "dependencies": { + "@apm-js-collab/code-transformer": "^0.13.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.1.0", + "@apm-js-collab/tracing-hooks": "^0.7.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", @@ -82,7 +109,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", diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 741c6ec27fe5..de826931691a 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -1,10 +1,42 @@ +import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils'; +// EXPERIMENTAL — orchestrion.js runtime hooks. Each one is a tiny hand-written +// `.mjs`/`.cjs` shim that the user references via `node --import` or +// `node --require`. We pass them through rollup only to copy them 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' }, + }), + defineConfig({ + input: 'src/orchestrion/runtime/require-hook.cjs', + external: /.*/, + output: { format: 'cjs', file: 'build/orchestrion/require-hook.cjs', strict: false }, + }), +]; + 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 3bd5e1edba1c..0023d8d9ec26 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -46,6 +46,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/orchestrion/bundler/vite.ts b/packages/node/src/orchestrion/bundler/vite.ts new file mode 100644 index 000000000000..d222fd1fd0b4 --- /dev/null +++ b/packages/node/src/orchestrion/bundler/vite.ts @@ -0,0 +1,74 @@ +// 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'; + +// `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). + * 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 async function sentryOrchestrionPlugin(): Promise { + const { default: codeTransformer } = await import('@apm-js-collab/code-transformer-bundler-plugins/vite'); + 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__ || {});', + 'if (globalThis.__SENTRY_ORCHESTRION__.bundler) {', + ' console.warn("[Sentry] sentryOrchestrionPlugin() ran twice in the same bundle. Functions may be instrumented twice.");', + '}', + 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', + '', + ].join('\n'); + + return { + name: 'sentry-orchestrion-marker', + enforce: 'pre' as const, + 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..16924f4ee746 --- /dev/null +++ b/packages/node/src/orchestrion/channels.ts @@ -0,0 +1,16 @@ +/** + * 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 = {} 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..a73efc98d3e7 --- /dev/null +++ b/packages/node/src/orchestrion/config.ts @@ -0,0 +1,14 @@ +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 hooks + * (`runtime/import-hook.mjs`, `runtime/require-hook.cjs`) 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[] = []; diff --git a/packages/node/src/orchestrion/detect.ts b/packages/node/src/orchestrion/detect.ts new file mode 100644 index 000000000000..9b2f1d3711e8 --- /dev/null +++ b/packages/node/src/orchestrion/detect.ts @@ -0,0 +1,43 @@ +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 exactly one of the two orchestrion setup paths is active: + * - the runtime hook (`node --import @sentry/node/orchestrion app.js`), OR + * - the bundler plugin (`sentryOrchestrionPlugin()`). + * + * Warns if neither (channels never fire — integrations silently record nothing) + * or both (double-wrapped — duplicate spans) ran. + */ +export function detectOrchestrionSetup(): void { + const marker = globalThis.__SENTRY_ORCHESTRION__; + const runtime = !!marker?.runtime; + const bundler = !!marker?.bundler; + + DEBUG_BUILD && debug.log(`[orchestrion] detect: runtime=${runtime} bundler=${bundler}`); + + if (runtime && bundler) { + DEBUG_BUILD && + debug.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) { + DEBUG_BUILD && + 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..a0e9efb8a778 --- /dev/null +++ b/packages/node/src/orchestrion/index.ts @@ -0,0 +1,2 @@ +export { _experimentalSetupOrchestrion } from './setup'; +export type { ExperimentalSetupOrchestrionOptions } from './setup'; 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..7071a6a24283 --- /dev/null +++ b/packages/node/src/orchestrion/runtime/import-hook.mjs @@ -0,0 +1,46 @@ +// 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 { createRequire } from 'node:module'; +import { register } from 'node:module'; +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); + +const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); +if (g.runtime) { + // eslint-disable-next-line no-console + console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --import. Ignoring the second load.'); +} else { + g.runtime = true; + + // ESM loader for `import`-ed modules. + 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 (e.g. `import mysql from 'mysql'`), Node loads the + // package's entry through the ESM bridge but resolves the package's INTERNAL + // `require()` calls (mysql/index.js → `require('./lib/Connection.js')`) + // through the CJS machinery. Those internal requires never reach the ESM + // resolve hook, so without this patch the file we actually want to instrument + // (mysql/lib/Connection.js) is loaded untransformed. + const require = createRequire(import.meta.url); + const ModulePatch = require('@apm-js-collab/tracing-hooks'); + new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); + debug('Module.patch() called for CJS-internal requires'); +} diff --git a/packages/node/src/orchestrion/runtime/require-hook.cjs b/packages/node/src/orchestrion/runtime/require-hook.cjs new file mode 100644 index 000000000000..560faff11ac6 --- /dev/null +++ b/packages/node/src/orchestrion/runtime/require-hook.cjs @@ -0,0 +1,33 @@ +// EXPERIMENTAL — entry point for `node --require @sentry/node/orchestrion app.js`. +// +// Installs orchestrion's CJS `_compile` patch 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/require-hook.cjs`. Keep it a +// single self-contained `.cjs` file with no relative-path requires — `--require` +// resolves it via Node's module resolution against the installed package's +// `./orchestrion` subpath export, which picks this file under the `require` +// condition and `import-hook.mjs` under the `import` condition. + +'use strict'; + +const ModulePatch = require('@apm-js-collab/tracing-hooks'); +const { SENTRY_INSTRUMENTATIONS } = require('../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('require-hook.cjs loaded, instrumentations:', SENTRY_INSTRUMENTATIONS); + +const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); +if (g.runtime) { + // eslint-disable-next-line no-console + console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --require. Ignoring the second load.'); +} else { + g.runtime = true; + new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); + debug('ModulePatch.patch() called'); +} diff --git a/packages/node/src/orchestrion/setup.ts b/packages/node/src/orchestrion/setup.ts new file mode 100644 index 000000000000..4d07b1a75c89 --- /dev/null +++ b/packages/node/src/orchestrion/setup.ts @@ -0,0 +1,69 @@ +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() — without it, the default + // OTel integrations are still active and we'd produce duplicate spans. + 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`. The default OTel integrations are still active — ' + + 'you will get duplicate spans. 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..168a7536f9ab 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([]); + /** 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/yarn.lock b/yarn.lock index 6ae4d0efbae3..3bba58b03f43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -379,6 +379,43 @@ dependencies: json-schema-to-ts "^3.1.1" +"@apm-js-collab/code-transformer-bundler-plugins@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.1.0.tgz#655ba83f88e156a0b1c0e501ac2f427a1c6a0741" + integrity sha512-pFSNp4Y0r+PSft9az5rFO8zgsIX8NuDMxMsaq6uAkPPuZBArq2Xykg2xlmuc884sI9lfZ4rJ0VSeVuTz8lfAcA== + dependencies: + "@apm-js-collab/code-transformer" "^0.3.0" + module-details-from-path "^1.0.4" + unplugin "^2.3.5" + +"@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/code-transformer@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.3.0.tgz#bf6b46e1f8db932da457aeb568f13b38509dd2fb" + integrity sha512-6vZdhmS8sSR/FCzpqo43+rD8xL0wRmzdwt8h+xm0ytRK0BIAzAargRu6mqiP9k/wd/p1LQyPd4wTnaFw12t9HA== + dependencies: + wasm-pack "^0.13.1" + +"@apm-js-collab/tracing-hooks@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.7.0.tgz#4ecc4023f8874d86d1f004afcdee8e07297e739d" + integrity sha512-ETZbwnF3+nw6ORKW5gQnLyDgvQKg7gmshevAV34a87rQIIJoazZBRnLd8wkBaU4HUru3leAkFCwxGbeksvVKaQ== + 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" @@ -11255,6 +11292,11 @@ astring@^1.8.6: resolved "https://registry.yarnpkg.com/astring/-/astring-1.8.6.tgz#2c9c157cf1739d67561c56ba896e6948f6b93731" integrity sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg== +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" resolved "https://registry.yarnpkg.com/astro/-/astro-3.5.0.tgz#45f0852e9384dc997d4285b567c8a0ee89b58c9b" @@ -11452,6 +11494,13 @@ axios@1.15.2: form-data "^4.0.5" proxy-from-env "^2.1.0" +axios@^0.26.1: + version "0.26.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.1.tgz#1ede41c51fcf51bbbd6fd43669caaa4f0495aaa9" + integrity sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA== + dependencies: + follow-redirects "^1.14.8" + axobject-query@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -11845,6 +11894,15 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +binary-install@^1.0.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/binary-install/-/binary-install-1.1.2.tgz#06f059e5475e2a208d65ead8cb523d7845a83038" + integrity sha512-ZS2cqFHPZOy4wLxvzqfQvDjCOifn+7uCPqNmYRIBM/03+yllON+4fNnsD0VJdW0p97y+E+dTRNPStWNqMBq+9g== + dependencies: + axios "^0.26.1" + rimraf "^3.0.2" + tar "^6.1.11" + binary@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79" @@ -16486,6 +16544,13 @@ esquery@^1.4.2, esquery@^1.6.0: dependencies: estraverse "^5.1.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" + esrecurse@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" @@ -17312,7 +17377,7 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== -follow-redirects@^1.0.0, follow-redirects@^1.15.11: +follow-redirects@^1.0.0, follow-redirects@^1.14.8, follow-redirects@^1.15.11: version "1.16.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== @@ -21291,6 +21356,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" @@ -26733,6 +26803,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" @@ -30199,6 +30274,13 @@ warning@^3.0.0: dependencies: loose-envify "^1.0.0" +wasm-pack@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/wasm-pack/-/wasm-pack-0.13.1.tgz#345701522420ad74a5b584f1bdaf6db8c264cb54" + integrity sha512-P9exD4YkjpDbw68xUhF3MDm/CC/3eTmmthyG5bHJ56kalxOTewOunxTke4SyF8MTXV6jUtNjXggPgrGmMtczGg== + dependencies: + binary-install "^1.0.1" + watch-detector@^1.0.0, watch-detector@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/watch-detector/-/watch-detector-1.0.2.tgz#95deb9189f8c89c0a9f211739cef6d01cffcf452" From 97bcfa059902f47cc3cb599a40b379b7f0a14d9d Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 11:04:13 +0200 Subject: [PATCH 04/19] add mysql example --- .../tracing/mysql/instrument-orchestrion.mjs | 11 ++ .../tracing/mysql/scenario-orchestrion.mjs | 39 ++++++ .../suites/tracing/mysql/test.ts | 39 +++++- .../src/integrations/tracing-channel/mysql.ts | 127 ++++++++++++++++++ packages/node/src/orchestrion/channels.ts | 4 +- packages/node/src/orchestrion/config.ts | 16 ++- packages/node/src/orchestrion/index.ts | 1 + packages/node/src/sdk/index.ts | 2 +- 8 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs create mode 100644 dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs create mode 100644 packages/node/src/integrations/tracing-channel/mysql.ts 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..385f79a52bd9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs @@ -0,0 +1,11 @@ +// Loaded BEFORE the scenario (via `--import` in ESM mode, `--require` in CJS +// mode). Pulling in `@sentry/node/orchestrion` triggers the runtime channel +// injection: the ESM build calls `module.register()` to install the +// orchestrion loader; the CJS build patches `Module.prototype._compile`. +// +// `createEsmAndCjsTests` converts this file's `import` statements to `require()` +// for the CJS variant by string substitution — the import specifier is +// unchanged. The `./orchestrion` subpath export resolves to a different file +// under the two conditions (`import` → import-hook.mjs, `require` → +// require-hook.cjs), so the same instrument file works in both modes. +import '@sentry/node/orchestrion'; diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs new file mode 100644 index 000000000000..2794dd814827 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs @@ -0,0 +1,39 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; +import { _experimentalSetupOrchestrion } from '@sentry/node'; +import mysql from 'mysql'; + +// EXPERIMENTAL — verifies the orchestrion runtime hook path for `mysql`. +// +// Pre-conditions set up by `instrument.mjs` (loaded via `--import` or `--require` +// before this file runs): orchestrion has rewritten `mysql/lib/Connection.js` +// so `Connection.prototype.query` publishes to `node:diagnostics_channel`. +// `_experimentalSetupOrchestrion()` below subscribes our channel-based mysql +// integration to those publications. + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + _experimentalUseOrchestrion: true, +}); + +_experimentalSetupOrchestrion(client); + +// Stop the process from exiting before the transaction is sent. +setInterval(() => {}, 1000); + +const connection = mysql.createConnection({ + user: 'root', + password: 'docker', +}); + +Sentry.startSpanManual({ op: 'transaction', name: 'Test Transaction' }, span => { + connection.query('SELECT 1 + 1 AS solution', () => { + connection.query('SELECT NOW()', ['1', '2'], () => { + span.end(); + connection.end(); + }); + }); +}); 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..05cd58074a18 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -1,5 +1,5 @@ -import { afterAll, describe, expect } from 'vitest'; -import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; +import { afterAll, describe, expect, test } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; describe('mysql auto instrumentation', () => { afterAll(() => { @@ -73,4 +73,39 @@ describe('mysql auto instrumentation', () => { { failsOnEsm: true }, ); }); + + createEsmAndCjsTests(__dirname, 'scenario-orchestrion.mjs', 'instrument-orchestrion.mjs', (createRunner, test) => { + test('records db spans for `Connection.query` via the channel-based integration', { timeout: 75_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: 'SELECT 1 + 1 AS solution', + op: 'db', + origin: 'auto.db.orchestrion.mysql', + data: expect.objectContaining({ + 'db.system.name': 'mysql', + 'db.query.text': 'SELECT 1 + 1 AS solution', + 'db.operation.name': 'SELECT', + }), + }), + expect.objectContaining({ + description: 'SELECT NOW()', + op: 'db', + origin: 'auto.db.orchestrion.mysql', + data: expect.objectContaining({ + 'db.system.name': 'mysql', + 'db.query.text': 'SELECT NOW()', + 'db.operation.name': 'SELECT', + }), + }), + ]), + }; + + await createRunner() + .expect({ transaction: EXPECTED_TRANSACTION }) + .start() + .completed(); + }); + }); }); 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..cbdd5fbcc341 --- /dev/null +++ b/packages/node/src/integrations/tracing-channel/mysql.ts @@ -0,0 +1,127 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { debug, defineIntegration, SPAN_STATUS_ERROR, startInactiveSpan } from '@sentry/core'; +import { addOriginToSpan } from '@sentry/node-core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; + +const INTEGRATION_NAME = 'Mysql'; + +// OpenTelemetry semantic-conventions strings. 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. +const ATTR_DB_SYSTEM_NAME = 'db.system.name'; +const ATTR_DB_QUERY_TEXT = 'db.query.text'; +const ATTR_DB_OPERATION_NAME = 'db.operation.name'; + +const SQL_OPERATION_REGEX = + /^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|TRUNCATE|REPLACE|MERGE|CALL|SHOW|USE|BEGIN|COMMIT|ROLLBACK)\b/i; + +/** + * 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`. + */ +interface MysqlQueryChannelContext { + arguments: unknown[]; + self?: unknown; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +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. For callback-style mysql: + // - `start` — synchronous entry to `Connection.query`. Start span here. + // - `end` — synchronous return from `Connection.query` (BEFORE the + // network round-trip completes). Not the right span end. + // - `error` — the query callback fired with an error. + // - `asyncStart` — about to invoke the user callback (query result is ready). + // - `asyncEnd` — user callback returned. This is the moment we want to + // end the span — it captures the full query latency. + queryCh.subscribe({ + start(rawCtx) { + const ctx = rawCtx as MysqlQueryChannelContext; + const sql = extractSql(ctx.arguments[0]); + const operation = sql ? extractOperation(sql) : undefined; + + const span = startInactiveSpan({ + name: sql ?? 'mysql.query', + op: 'db', + attributes: { + [ATTR_DB_SYSTEM_NAME]: 'mysql', + ...(sql ? { [ATTR_DB_QUERY_TEXT]: sql } : {}), + ...(operation ? { [ATTR_DB_OPERATION_NAME]: operation } : {}), + }, + }); + addOriginToSpan(span, 'auto.db.orchestrion.mysql'); + spans.set(rawCtx, span); + }, + + end() { + // No-op: span ends in `asyncEnd` once the network round-trip completes. + }, + + 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) { + const span = spans.get(rawCtx); + if (!span) return; + span.end(); + spans.delete(rawCtx); + }, + }); + }, + }; +}) satisfies IntegrationFn; + +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 extractOperation(sql: string): string | undefined { + const match = sql.match(SQL_OPERATION_REGEX); + return match?.[1]?.toUpperCase(); +} + +/** + * 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/channels.ts b/packages/node/src/orchestrion/channels.ts index 16924f4ee746..28dcf0c33468 100644 --- a/packages/node/src/orchestrion/channels.ts +++ b/packages/node/src/orchestrion/channels.ts @@ -11,6 +11,8 @@ * suffix in `channelName`. Keeping both pieces in one file is what guarantees * they don't drift apart and silently stop firing. */ -export const CHANNELS = {} as const; +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 index a73efc98d3e7..db68156c4120 100644 --- a/packages/node/src/orchestrion/config.ts +++ b/packages/node/src/orchestrion/config.ts @@ -11,4 +11,18 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; * `channelName` here is the unprefixed suffix; the actual diagnostics_channel * name is `orchestrion:${module.name}:${channelName}` (see `channels.ts`). */ -export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = []; +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]`. + functionQuery: { expressionName: 'query', kind: 'Callback' }, + }, +]; diff --git a/packages/node/src/orchestrion/index.ts b/packages/node/src/orchestrion/index.ts index a0e9efb8a778..4cd65c41648c 100644 --- a/packages/node/src/orchestrion/index.ts +++ b/packages/node/src/orchestrion/index.ts @@ -1,2 +1,3 @@ export { _experimentalSetupOrchestrion } from './setup'; export type { ExperimentalSetupOrchestrionOptions } from './setup'; +export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 168a7536f9ab..ff9e174d02be 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -35,7 +35,7 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { * themselves) so the orchestrion code path stays tree-shakable: `init()` never * pulls in anything from `../orchestrion/*`. */ -const ORCHESTRION_REPLACED_INTEGRATIONS = new Set([]); +const ORCHESTRION_REPLACED_INTEGRATIONS = new Set(['Mysql']); /** Get the default integrations for the Node SDK. */ export function getDefaultIntegrations(options: Options): Integration[] { From 4bf97f0495ccdfb5d335f59e1babcf1dbe331cef Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 11:29:24 +0200 Subject: [PATCH 05/19] fixes --- ORCHESTRIONJS_PLAN.md | 6 +++--- packages/node/src/orchestrion/bundler/vite.ts | 2 +- packages/node/src/orchestrion/runtime/require-hook.cjs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ORCHESTRIONJS_PLAN.md b/ORCHESTRIONJS_PLAN.md index 4470ad43e959..3794535df0aa 100644 --- a/ORCHESTRIONJS_PLAN.md +++ b/ORCHESTRIONJS_PLAN.md @@ -195,7 +195,7 @@ No `instrumentations: [...]` array to copy-paste, no channel names to remember. ```js import { register } from 'node:module'; -import { SENTRY_INSTRUMENTATIONS } from '../config.js'; +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. @@ -214,7 +214,7 @@ if (g.runtime) { ```js const ModulePatch = require('@apm-js-collab/tracing-hooks'); -const { SENTRY_INSTRUMENTATIONS } = require('../config.js'); +const { SENTRY_INSTRUMENTATIONS } = require('@sentry/node/orchestrion/config'); const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); if (g.runtime) { @@ -234,7 +234,7 @@ Both files set `globalThis.__SENTRY_ORCHESTRION__.runtime = true`. That marker i ```ts import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; import type { Plugin } from 'vite'; -import { SENTRY_INSTRUMENTATIONS } from '../config'; +import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; export function sentryOrchestrionPlugin(): Plugin[] { return [ diff --git a/packages/node/src/orchestrion/bundler/vite.ts b/packages/node/src/orchestrion/bundler/vite.ts index d222fd1fd0b4..dc8916deb73c 100644 --- a/packages/node/src/orchestrion/bundler/vite.ts +++ b/packages/node/src/orchestrion/bundler/vite.ts @@ -13,7 +13,7 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any type UnknownPlugin = any; -import { SENTRY_INSTRUMENTATIONS } from '../config'; +import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/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 diff --git a/packages/node/src/orchestrion/runtime/require-hook.cjs b/packages/node/src/orchestrion/runtime/require-hook.cjs index 560faff11ac6..e16f53a7c96f 100644 --- a/packages/node/src/orchestrion/runtime/require-hook.cjs +++ b/packages/node/src/orchestrion/runtime/require-hook.cjs @@ -14,7 +14,7 @@ 'use strict'; const ModulePatch = require('@apm-js-collab/tracing-hooks'); -const { SENTRY_INSTRUMENTATIONS } = require('../config'); +const { SENTRY_INSTRUMENTATIONS } = require('@sentry/node/orchestrion/config'); const DEBUG = !!(process.env.DEBUG || process.env.debug || process.env.SENTRY_DEBUG); // eslint-disable-next-line no-console From 71ea8a0025671d742549d9ae5baad13abf169570 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 11:29:48 +0200 Subject: [PATCH 06/19] fix lockfile --- yarn.lock | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/yarn.lock b/yarn.lock index 3bba58b03f43..8a967933cf23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11287,12 +11287,7 @@ 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.9.0: +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== @@ -16537,14 +16532,7 @@ 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== - dependencies: - estraverse "^5.1.0" - -esquery@^1.7.0: +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== From a1e0b48a12d480bfeda63cbaf29365442d9eac58 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 11:30:10 +0200 Subject: [PATCH 07/19] fix lint --- .../node-integration-tests/suites/tracing/mysql/test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 05cd58074a18..0686163fb8e4 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -102,10 +102,7 @@ describe('mysql auto instrumentation', () => { ]), }; - await createRunner() - .expect({ transaction: EXPECTED_TRANSACTION }) - .start() - .completed(); + await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); }); }); }); From 833ddd1ac5b4c30872edcc1b8668d3bff0c1d654 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 11:31:34 +0200 Subject: [PATCH 08/19] fix exports --- .../node-exports-test-app/scripts/consistentExports.ts | 2 ++ 1 file changed, 2 insertions(+) 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..5a78ba1f8a72 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,8 @@ const NODE_EXPORTS_IGNORE = [ 'preloadOpenTelemetry', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', + // Experimental + '_experimentalSetupOrchestrion', ]; const nodeExports = Object.keys(SentryNode).filter(e => !NODE_EXPORTS_IGNORE.includes(e)); From 2b675cadcc9cca4a2f2ead7d309ef0d6bd8a2c98 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 11:48:55 +0200 Subject: [PATCH 09/19] fix types build --- packages/node/tsconfig.json | 9 +++++++++ packages/node/tsconfig.types.json | 6 +++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index d5f034ad1048..35935cd48365 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -3,6 +3,15 @@ "include": ["src/**/*"], + // 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"], + "compilerOptions": { "lib": ["es2020"], "module": "Node16", 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" } } From 18667e9a0ae4c31efb67b4539575e162c5ad55d8 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 11:50:53 +0200 Subject: [PATCH 10/19] fix import --- packages/node/src/orchestrion/bundler/vite.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/node/src/orchestrion/bundler/vite.ts b/packages/node/src/orchestrion/bundler/vite.ts index dc8916deb73c..d222fd1fd0b4 100644 --- a/packages/node/src/orchestrion/bundler/vite.ts +++ b/packages/node/src/orchestrion/bundler/vite.ts @@ -13,7 +13,7 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any type UnknownPlugin = any; -import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; +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 From db3150183377d2db76e5cb32093f0c3327b715cd Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Fri, 15 May 2026 11:52:11 +0200 Subject: [PATCH 11/19] better handle sync errors --- .../src/integrations/tracing-channel/mysql.ts | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/node/src/integrations/tracing-channel/mysql.ts b/packages/node/src/integrations/tracing-channel/mysql.ts index cbdd5fbcc341..58d2bd88838b 100644 --- a/packages/node/src/integrations/tracing-channel/mysql.ts +++ b/packages/node/src/integrations/tracing-channel/mysql.ts @@ -44,14 +44,17 @@ const _mysqlChannelIntegration = (() => { // asyncEnd for some reason. const spans = new WeakMap(); - // `subscribe()` requires all five lifecycle hooks. For callback-style mysql: - // - `start` — synchronous entry to `Connection.query`. Start span here. - // - `end` — synchronous return from `Connection.query` (BEFORE the - // network round-trip completes). Not the right span end. - // - `error` — the query callback fired with an error. - // - `asyncStart` — about to invoke the user callback (query result is ready). - // - `asyncEnd` — user callback returned. This is the moment we want to - // end the span — it captures the full query latency. + // `subscribe()` requires all five lifecycle hooks. The orchestrion + // `wrapCallback` transform fires them in one of three orders: + // - sync throw from `query()` : start → error → end (NO asyncEnd) + // - async error from callback : start → end → error → asyncStart → asyncEnd + // - async success : start → end → asyncStart → asyncEnd + // We end the span on `asyncEnd` for the two async paths (so the span + // covers the full network round-trip + callback duration), and fall back + // to `end` for the sync-throw path so the span isn't left unfinished. + // 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`. queryCh.subscribe({ start(rawCtx) { const ctx = rawCtx as MysqlQueryChannelContext; @@ -71,8 +74,14 @@ const _mysqlChannelIntegration = (() => { spans.set(rawCtx, span); }, - end() { - // No-op: span ends in `asyncEnd` once the network round-trip completes. + end(rawCtx) { + // Only acts for sync throws: `end` fires AFTER `error` (both inside + // the wrapper's `try/catch/finally`), so `ctx.error` is already set. + // For async paths `end` fires before `error`, so `ctx.error` is still + // undefined here and we leave the span open for `asyncEnd` to close. + const ctx = rawCtx as MysqlQueryChannelContext; + if (ctx.error === undefined) return; + finishSpan(rawCtx); }, error(rawCtx) { @@ -90,12 +99,16 @@ const _mysqlChannelIntegration = (() => { }, asyncEnd(rawCtx) { - const span = spans.get(rawCtx); - if (!span) return; - span.end(); - spans.delete(rawCtx); + finishSpan(rawCtx); }, }); + + function finishSpan(rawCtx: object): void { + const span = spans.get(rawCtx); + if (!span) return; + span.end(); + spans.delete(rawCtx); + } }, }; }) satisfies IntegrationFn; From bea319af00cea764c8730b832cce16bc45424ce2 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 18 May 2026 09:28:18 +0200 Subject: [PATCH 12/19] tests --- .../tracing/mysql/instrument-orchestrion.mjs | 23 +++--- .../tracing/mysql/scenario-orchestrion.mjs | 19 ----- .../suites/tracing/mysql/test.ts | 71 ++++++------------- 3 files changed, 33 insertions(+), 80 deletions(-) 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 385f79a52bd9..0b1a81a0fb08 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,11 +1,14 @@ -// Loaded BEFORE the scenario (via `--import` in ESM mode, `--require` in CJS -// mode). Pulling in `@sentry/node/orchestrion` triggers the runtime channel -// injection: the ESM build calls `module.register()` to install the -// orchestrion loader; the CJS build patches `Module.prototype._compile`. -// -// `createEsmAndCjsTests` converts this file's `import` statements to `require()` -// for the CJS variant by string substitution — the import specifier is -// unchanged. The `./orchestrion` subpath export resolves to a different file -// under the two conditions (`import` → import-hook.mjs, `require` → -// require-hook.cjs), so the same instrument file works in both modes. 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/scenario-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs index 2794dd814827..4508c679cd56 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs @@ -1,26 +1,7 @@ -import { loggingTransport } from '@sentry-internal/node-integration-tests'; import * as Sentry from '@sentry/node'; import { _experimentalSetupOrchestrion } from '@sentry/node'; import mysql from 'mysql'; -// EXPERIMENTAL — verifies the orchestrion runtime hook path for `mysql`. -// -// Pre-conditions set up by `instrument.mjs` (loaded via `--import` or `--require` -// before this file runs): orchestrion has rewritten `mysql/lib/Connection.js` -// so `Connection.prototype.query` publishes to `node:diagnostics_channel`. -// `_experimentalSetupOrchestrion()` below subscribes our channel-based mysql -// integration to those publications. - -const client = Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - release: '1.0', - tracesSampleRate: 1.0, - transport: loggingTransport, - _experimentalUseOrchestrion: true, -}); - -_experimentalSetupOrchestrion(client); - // Stop the process from exiting before the transaction is sent. setInterval(() => {}, 1000); 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 0686163fb8e4..a88ac0694adc 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -1,5 +1,5 @@ -import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; +import { afterAll, describe, expect } from 'vitest'; +import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner'; describe('mysql auto instrumentation', () => { afterAll(() => { @@ -32,77 +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 }, ); }); - - createEsmAndCjsTests(__dirname, 'scenario-orchestrion.mjs', 'instrument-orchestrion.mjs', (createRunner, test) => { - test('records db spans for `Connection.query` via the channel-based integration', { timeout: 75_000 }, async () => { - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction', - spans: expect.arrayContaining([ - expect.objectContaining({ - description: 'SELECT 1 + 1 AS solution', - op: 'db', - origin: 'auto.db.orchestrion.mysql', - data: expect.objectContaining({ - 'db.system.name': 'mysql', - 'db.query.text': 'SELECT 1 + 1 AS solution', - 'db.operation.name': 'SELECT', - }), - }), - expect.objectContaining({ - description: 'SELECT NOW()', - op: 'db', - origin: 'auto.db.orchestrion.mysql', - data: expect.objectContaining({ - 'db.system.name': 'mysql', - 'db.query.text': 'SELECT NOW()', - 'db.operation.name': 'SELECT', - }), - }), - ]), - }; - - await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); - }); - }); }); From 4ecc12c8de42a7fc4504f774434650baaa5c0a61 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 18 May 2026 09:28:26 +0200 Subject: [PATCH 13/19] mysql fixes --- .../src/integrations/tracing-channel/mysql.ts | 191 +++++++++++++++--- packages/node/src/orchestrion/config.ts | 9 +- 2 files changed, 169 insertions(+), 31 deletions(-) diff --git a/packages/node/src/integrations/tracing-channel/mysql.ts b/packages/node/src/integrations/tracing-channel/mysql.ts index 58d2bd88838b..eaa9c7e544d1 100644 --- a/packages/node/src/integrations/tracing-channel/mysql.ts +++ b/packages/node/src/integrations/tracing-channel/mysql.ts @@ -1,36 +1,71 @@ import { tracingChannel } from 'node:diagnostics_channel'; import type { IntegrationFn, Span } from '@sentry/core'; -import { debug, defineIntegration, SPAN_STATUS_ERROR, startInactiveSpan } from '@sentry/core'; +import { + debug, + defineIntegration, + getActiveSpan, + SPAN_STATUS_ERROR, + startInactiveSpan, + withActiveSpan, +} from '@sentry/core'; import { addOriginToSpan } from '@sentry/node-core'; import { DEBUG_BUILD } from '../../debug-build'; import { CHANNELS } from '../../orchestrion/channels'; const INTEGRATION_NAME = 'Mysql'; -// OpenTelemetry semantic-conventions strings. We inline them rather than +// 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. -const ATTR_DB_SYSTEM_NAME = 'db.system.name'; -const ATTR_DB_QUERY_TEXT = 'db.query.text'; -const ATTR_DB_OPERATION_NAME = 'db.operation.name'; - -const SQL_OPERATION_REGEX = - /^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|TRUNCATE|REPLACE|MERGE|CALL|SHOW|USE|BEGIN|COMMIT|ROLLBACK)\b/i; +// +// 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?: 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, @@ -45,43 +80,112 @@ const _mysqlChannelIntegration = (() => { const spans = new WeakMap(); // `subscribe()` requires all five lifecycle hooks. The orchestrion - // `wrapCallback` transform fires them in one of three orders: - // - sync throw from `query()` : start → error → end (NO asyncEnd) - // - async error from callback : start → end → error → asyncStart → asyncEnd - // - async success : start → end → asyncStart → asyncEnd - // We end the span on `asyncEnd` for the two async paths (so the span - // covers the full network round-trip + callback duration), and fall back - // to `end` for the sync-throw path so the span isn't left unfinished. + // `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`. + // 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 operation = sql ? extractOperation(sql) : undefined; + 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_NAME]: 'mysql', - ...(sql ? { [ATTR_DB_QUERY_TEXT]: sql } : {}), - ...(operation ? { [ATTR_DB_OPERATION_NAME]: operation } : {}), + [ATTR_DB_SYSTEM]: '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 } : {}), }, }); addOriginToSpan(span, 'auto.db.orchestrion.mysql'); 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) { - // Only acts for sync throws: `end` fires AFTER `error` (both inside - // the wrapper's `try/catch/finally`), so `ctx.error` is already set. - // For async paths `end` fires before `error`, so `ctx.error` is still - // undefined here and we leave the span open for `asyncEnd` to close. const ctx = rawCtx as MysqlQueryChannelContext; - if (ctx.error === undefined) return; - finishSpan(rawCtx); + + // 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. + 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', + }); + }); + result.on('end', () => finishSpan(rawCtx)); + return; + } + + // Callback path: `asyncEnd` will close the span. Nothing to do here. }, error(rawCtx) { @@ -113,6 +217,10 @@ const _mysqlChannelIntegration = (() => { }; }) 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; @@ -124,9 +232,32 @@ function extractSql(firstArg: unknown): string | undefined { return undefined; } -function extractOperation(sql: string): string | undefined { - const match = sql.match(SQL_OPERATION_REGEX); - return match?.[1]?.toUpperCase(); +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; } /** diff --git a/packages/node/src/orchestrion/config.ts b/packages/node/src/orchestrion/config.ts index db68156c4120..41d8716918bb 100644 --- a/packages/node/src/orchestrion/config.ts +++ b/packages/node/src/orchestrion/config.ts @@ -23,6 +23,13 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ // assignment is what we want — that's matched by `expressionName: 'query'`, // which produces the esquery selector // `AssignmentExpression[left.property.name="query"] > FunctionExpression[async]`. - functionQuery: { expressionName: 'query', kind: 'Callback' }, + // `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' }, }, ]; From 87a0dd6750d61ff5cf1760e47cacc368af70f4e4 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 18 May 2026 10:53:37 +0200 Subject: [PATCH 14/19] remove unused file --- .../tracing/mysql/scenario-orchestrion.mjs | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs deleted file mode 100644 index 4508c679cd56..000000000000 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs +++ /dev/null @@ -1,20 +0,0 @@ -import * as Sentry from '@sentry/node'; -import { _experimentalSetupOrchestrion } from '@sentry/node'; -import mysql from 'mysql'; - -// Stop the process from exiting before the transaction is sent. -setInterval(() => {}, 1000); - -const connection = mysql.createConnection({ - user: 'root', - password: 'docker', -}); - -Sentry.startSpanManual({ op: 'transaction', name: 'Test Transaction' }, span => { - connection.query('SELECT 1 + 1 AS solution', () => { - connection.query('SELECT NOW()', ['1', '2'], () => { - span.end(); - connection.end(); - }); - }); -}); From f1b5a5a09a5f22f501d81d153d68fb4289056ee3 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 18 May 2026 10:55:33 +0200 Subject: [PATCH 15/19] be defensive --- packages/node/src/integrations/tracing-channel/mysql.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/node/src/integrations/tracing-channel/mysql.ts b/packages/node/src/integrations/tracing-channel/mysql.ts index eaa9c7e544d1..d8fd35ce27ce 100644 --- a/packages/node/src/integrations/tracing-channel/mysql.ts +++ b/packages/node/src/integrations/tracing-channel/mysql.ts @@ -180,6 +180,10 @@ const _mysqlChannelIntegration = (() => { 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; From 52264e91a9410902572d68807c4ef2f87e7045d5 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 18 May 2026 15:26:03 +0200 Subject: [PATCH 16/19] improvements --- .../node-exports-test-app/scripts/consistentExports.ts | 1 + packages/node/src/orchestrion/setup.ts | 7 +++---- 2 files changed, 4 insertions(+), 4 deletions(-) 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 5a78ba1f8a72..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 @@ -25,6 +25,7 @@ const NODE_EXPORTS_IGNORE = [ '_INTERNAL_normalizeCollectionInterval', // Experimental '_experimentalSetupOrchestrion', + 'mysqlChannelIntegration', ]; const nodeExports = Object.keys(SentryNode).filter(e => !NODE_EXPORTS_IGNORE.includes(e)); diff --git a/packages/node/src/orchestrion/setup.ts b/packages/node/src/orchestrion/setup.ts index 4d07b1a75c89..a4c405a63c84 100644 --- a/packages/node/src/orchestrion/setup.ts +++ b/packages/node/src/orchestrion/setup.ts @@ -43,15 +43,14 @@ export function _experimentalSetupOrchestrion( return; } - // Verify the user remembered to set the flag on init() — without it, the default - // OTel integrations are still active and we'd produce duplicate spans. + // 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`. The default OTel integrations are still active — ' + - 'you will get duplicate spans. Add the flag to Sentry.init().', + '`_experimentalUseOrchestrion: true` — it will use default instrumentation instead of ' + + 'channel-based instrumentation. Add the flag to Sentry.init().', ); } From b069d37018306d65296d2b3a7e8663a582d55dbb Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Mon, 18 May 2026 15:40:53 +0200 Subject: [PATCH 17/19] add custom output --- packages/node/package.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/node/package.json b/packages/node/package.json index 62aa55f5a19f..6363d1996421 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -144,5 +144,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" + ] + } + } + } } From 12ac21c6b2c0af02e04532164972d4f4745cf1cc Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 21 May 2026 13:02:33 +0200 Subject: [PATCH 18/19] add some more size limit scenarios --- .size-limit.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.size-limit.js b/.size-limit.js index 160a610e1c8d..2f1df0f8bd96 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -394,6 +394,32 @@ module.exports = [ limit: '172 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', From caaf69b0a983b0c870e7cd084c493152c01464a4 Mon Sep 17 00:00:00 2001 From: Francesco Gringl-Novy Date: Thu, 21 May 2026 13:41:35 +0200 Subject: [PATCH 19/19] add test app --- .../node-express-vite-orchestrion/.gitignore | 1 + .../package.json | 33 +++++ .../playwright.config.mjs | 7 + .../node-express-vite-orchestrion/src/app.ts | 56 ++++++++ .../start-event-proxy.mjs | 6 + .../tests/errors.test.ts | 29 +++++ .../tests/transactions.test.ts | 123 ++++++++++++++++++ .../tsconfig.json | 12 ++ .../vite.config.ts | 19 +++ 9 files changed, 286 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/package.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/playwright.config.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/src/app.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/tests/errors.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/tests/transactions.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/vite.config.ts diff --git a/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/.gitignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/.gitignore @@ -0,0 +1 @@ +dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/package.json b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/package.json new file mode 100644 index 000000000000..a8392ed93f54 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/package.json @@ -0,0 +1,33 @@ +{ + "name": "node-express-vite-orchestrion-app", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "start": "node 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", + "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-vite-orchestrion/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/playwright.config.mjs new file mode 100644 index 000000000000..31f2b913b58b --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-vite-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-vite-orchestrion/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/src/app.ts new file mode 100644 index 000000000000..6f8ce8b4ca14 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/src/app.ts @@ -0,0 +1,56 @@ +import * as Sentry from '@sentry/node'; +import express from 'express'; + +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 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-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-vite-orchestrion/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/start-event-proxy.mjs new file mode 100644 index 000000000000..0f3810991327 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'node-express-vite-orchestrion', +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/tests/errors.test.ts new file mode 100644 index 000000000000..12427dfbbe1e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-vite-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-vite-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-vite-orchestrion/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/tests/transactions.test.ts new file mode 100644 index 000000000000..4bb343f96739 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/tests/transactions.test.ts @@ -0,0 +1,123 @@ +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-vite-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-vite-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); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/tsconfig.json new file mode 100644 index 000000000000..c46f5dea4945 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/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-vite-orchestrion/vite.config.ts b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/vite.config.ts new file mode 100644 index 000000000000..96bb82d97fa0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-express-vite-orchestrion/vite.config.ts @@ -0,0 +1,19 @@ +import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig(async () => ({ + plugins: await sentryOrchestrionPlugin(), + build: { + target: 'node18', + ssr: true, + outDir: 'dist', + emptyOutDir: true, + rollupOptions: { + input: 'src/app.ts', + output: { + format: 'esm', + entryFileNames: 'app.js', + }, + }, + }, +}));