diff --git a/.size-limit.js b/.size-limit.js index fc3fbcced896..66e200a4eb3f 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -395,17 +395,8 @@ module.exports = [ disablePlugins: ['@size-limit/esbuild'], }, { - name: '@sentry/node (with Orchestrion)', - path: 'packages/node/build/esm/index.js', - import: createImport('init', '_experimentalSetupOrchestrion'), - ignore: [...builtinModules, ...nodePrefixedBuiltinModules], - gzip: true, - limit: '173 KB', - disablePlugins: ['@size-limit/esbuild'], - }, - { - name: '@sentry/node/orchestrion (ESM hook)', - path: ['node_modules/@apm-js-collab/tracing-hooks/hook.mjs', 'packages/node/build/orchestrion/import-hook.mjs'], + name: '@sentry/node/import (ESM hook with diagnostics-channel injection)', + path: ['node_modules/@apm-js-collab/tracing-hooks/hook.mjs', 'packages/node/build/import-hook.mjs'], ignore: [...builtinModules, ...nodePrefixedBuiltinModules], gzip: true, limit: '100 KB', diff --git a/ORCHESTRIONJS_PLAN.md b/ORCHESTRIONJS_PLAN.md deleted file mode 100644 index ef43b36fb5af..000000000000 --- a/ORCHESTRIONJS_PLAN.md +++ /dev/null @@ -1,486 +0,0 @@ -# Orchestrion.js Auto-Instrumentation Experiment Plan - -> **Update (out of date):** the sections below describing a separate CJS -> `runtime/require-hook.cjs` (and a `require` arm on the `./orchestrion` subpath -> export) are obsolete. The implemented ESM `import-hook.mjs`, loaded via -> `--import`, already instruments **both** ESM and CJS user code — via -> `Module.registerHooks` where available, otherwise `Module.register` plus the -> CJS `Module._compile` patch (`ModulePatch.patch()`). So `--import` is the -> single runtime entry point for both module systems; no `require-hook`/`require` -> condition is needed. CJS apps run `node --import @sentry/node/orchestrion app.js`. -> -> Experiment branch: `experiment/orchestrionjs-auto-instrumentation` -> -> Goal: prototype a future where `@sentry/node` does its own auto-instrumentation -> via Node.js [`TracingChannel`](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel), -> with channel injection driven by [orchestrion.js](https://github.com/nodejs/orchestrion-js) -> instead of OpenTelemetry's `require-in-the-middle` / -> `import-in-the-middle` machinery. -> -> The `orchestrion.js` machinery lives in the shared -> `server-utils` package, for eventual use in the bun and deno -> SDKs, which will be done as a subsequent project. -> -> First target: the `mysql` integration. - -## Background - -Orchestrion-JS is published as three coordinated packages: - -| Package | What it does | We use it for | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -| `@apm-js-collab/code-transformer` | Rust/WASM AST walker. Given an `InstrumentationConfig[]`, returns a `Transformer` that rewrites function bodies to publish to a `TracingChannel`. | Indirectly — via the two below. | -| `@apm-js-collab/tracing-hooks` | Node ESM loader (`register('@apm-js-collab/tracing-hooks/hook.mjs', ..., { data: { instrumentations } })`) + a CJS `ModulePatch` for `--require`. | **Runtime** channel injection. | -| `@apm-js-collab/code-transformer-bundler-plugins` | One plugin per bundler (`/vite`, `/webpack`, `/rollup`, `/esbuild`), all taking the same `{ instrumentations }` object. | **Build-time** channel injection. | - -All three accept the same `InstrumentationConfig` shape: - -```ts -type InstrumentationConfig = { - channelName: string; // diagnostics_channel TracingChannel name - module: { name: string; versionRange: string; filePath: string }; - functionQuery: FunctionQuery; // className+methodName / functionName / expressionName / ... -}; -``` - -This means **one config array** can drive both the runtime hook and every bundler plugin — that is the leverage point this plan is built around. - -## Architectural goals - -1. **Integrations only know channels.** A Sentry integration (e.g. `mysqlIntegration`) subscribes to a published channel name and creates spans. It never imports orchestrion, never knows how the channel got there, and would work identically against a native `diagnostics_channel` that some library already publishes itself. -2. **Single source of truth for orchestrion config.** Channel names + module matchers + function queries live in **one** TypeScript module. Both the runtime hook and the bundler plugin import from it. Adding a new instrumentation = one edit. When this config is being consumed by multiple SDKs, one edit can add instrumentation to multiple platforms. -3. **Two equally good user paths, one of which must be active.** - - **Bundler path** (preferred when bundling): the user adds `sentryOrchestrionPlugin()` to their `vite.config.ts`. Nothing else. - - **Runtime path** (preferred for unbundled Node servers): the user runs `node --import @sentry/node/orchestrion app.js` (ESM) or `node --require @sentry/node/orchestrion app.js` (CJS). The same import path resolves to the ESM `import-hook.mjs` or the CJS `require-hook.cjs` based on the active loader condition, so the user doesn't have to know which one to pick. -4. **Loud about misconfiguration.** When orchestrion setup runs, the SDK must detect (a) "no orchestrion hook was set up at all" and (b) "both paths ran — code is double-wrapped" and warn clearly. -5. **No mixing with the existing OTel-based init, and tree-shakable.** The opt-in is split into two pieces so users who don't opt in never pull in any orchestrion code: - - A new `_experimentalUseOrchestrion: true` flag on `Sentry.init()` that does the _base_ adjustments — i.e. skip registering the OTel auto-instrumentations that have a channel-based replacement (mysql, …). This is all `init()` itself does; it pulls in zero orchestrion-specific code. - - A new top-level export `_experimentalSetupOrchestrion()` that the user calls **after** `Sentry.init()`. This is where all orchestrion-specific code lives: the channel subscribers, the integration registrations, and the runtime/bundler detection warnings. If the user never calls it, the bundler can drop everything under `orchestrion/` from their bundle. - When the flag is unset (the default), `init()` behaves exactly as today and `_experimentalSetupOrchestrion` — if imported — is a no-op that only warns. Existing users keep using `@opentelemetry/instrumentation-*` integrations untouched. - -## Repository layout - -All new code lives under `packages/node/`. The existing OTel-based mysql integration stays untouched so we can A/B them. - -``` -packages/node/ -├── package.json (NEW subpath exports — see below) -└── src/ - └── orchestrion/ (NEW directory — all experiment code) - ├── index.ts public re-exports for the integrations subdir - ├── setup.ts ★ _experimentalSetupOrchestrion() — the only user-facing entry into this dir - ├── config.ts ★ central InstrumentationConfig[] — single source of truth - ├── channels.ts channel-name string constants (imported by configs AND integrations) - ├── detect.ts globalThis marker + warning logic - ├── runtime/ - │ ├── import-hook.mjs --import target: register() + marker - │ └── require-hook.cjs --require target: ModulePatch.patch() + marker - └── bundler/ - ├── vite.ts sentryOrchestrionVitePlugin() — wraps code-transformer/vite + marker - └── marker-banner.ts shared "inject `globalThis.__SENTRY_ORCHESTRION__.bundler = true`" plugin -packages/node/src/integrations/tracing-channel/ - └── mysql.ts ★ subscribes to channels; creates Sentry spans -``` - -All channel-consumer integrations live together under `integrations/tracing-channel/` — one file per library (`mysql.ts`, future `pg.ts`, `redis.ts`, …). This mirrors the existing `integrations/tracing/` layout for the OTel path, keeps related code visually grouped, and makes the boundary the user wants explicit: a contributor adding a new channel-driven integration edits `orchestrion/config.ts` (one entry) + `integrations/tracing-channel/.ts` (one subscriber) + adds it to the default list in `orchestrion/setup.ts`. Nothing else. - -`orchestrion/setup.ts` is the **only** file under `orchestrion/` that user code imports from at runtime (via the top-level `@sentry/node` re-export of `_experimentalSetupOrchestrion`). Everything else under `orchestrion/` is reachable only transitively through that one entry point — which is what makes the experiment tree-shakable for opted-out users. - -## Central config — the load-bearing file - -`packages/server-utils/src/orchestrion/channels.ts` - -```ts -// String constants shared between config.ts (producer) and integrations (consumer). -// Single source of truth for channel names — keeps the channel string from being -// misspelled in one place and silently never firing. -export const CHANNELS = { - MYSQL_QUERY: 'sentry:mysql:query', -} as const; -``` - -`packages/server-utils/src/orchestrion/config.ts` - -```ts -import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; -import { CHANNELS } from './channels'; - -export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ - { - channelName: CHANNELS.MYSQL_QUERY, - module: { name: 'mysql', versionRange: '>=2.0.0', filePath: 'lib/Connection.js' }, - functionQuery: { className: 'Connection', methodName: 'query', kind: 'Callback' }, - }, - // … future entries: mysql2, pg, redis, etc. One line per instrumented method. -]; -``` - -`config.ts` has **no side effects** — it is the only thing both `runtime/*` and `bundler/*` import. This is what makes it cheap to maintain: adding a new instrumented method is one entry here + one subscriber file. - -## The integration — channel consumer - -`packages/server-utils/src/integrations/tracing-channel/mysql.ts` (sketch): - -```ts -import { channel, tracingChannel } from 'node:diagnostics_channel'; -import { defineIntegration, startSpan, SPAN_STATUS_ERROR } from '@sentry/core'; -import { CHANNELS } from '../../orchestrion/channels'; - -const _mysqlChannelIntegration = (() => { - const queryCh = tracingChannel(CHANNELS.MYSQL_QUERY); - // store per-context state on a WeakMap keyed by the `context` object - // that orchestrion passes to start/end/asyncStart/asyncEnd/error. - const spans = new WeakMap void }>(); - - return { - name: 'MysqlChannel', - setupOnce() { - queryCh.subscribe({ - start(ctx) { - // ctx.arguments contains the original call args — extract SQL for span name. - const sql = String((ctx as any).arguments?.[0] ?? 'mysql.query'); - // startSpan returns synchronously when we pass `{ forceTransaction: false }` semantics; - // for true async correlation we wrap startInactiveSpan + manual end here. - const span = startInactiveSpanForChannel(sql); - spans.set(ctx as object, { - finish: () => span.end(), - }); - }, - error(ctx) { - // pull error from ctx, mark span status - }, - asyncEnd(ctx) { - spans.get(ctx as object)?.finish(); - }, - // end() fires for sync paths; asyncEnd() for callback / promise paths - end(ctx) { - // only finish if asyncEnd hasn't (mysql Connection.query is callback-based — asyncEnd is the one) - }, - }); - }, - }; -}) satisfies IntegrationFn; - -export const mysqlChannelIntegration = defineIntegration(_mysqlChannelIntegration); -``` - -The integration imports **`CHANNELS.MYSQL_QUERY`, not the orchestrion config**. It is unaware orchestrion exists; if some day `mysql` publishes that channel natively we just stop injecting it. - -## Subpath exports - -Add to `packages/node/package.json`: - -```jsonc -"exports": { - // … existing entries … - "./orchestrion": { - // Only a --import hook is supported; this fully covers both - // CJS and ESM modules. - "import": { "default": "./build/orchestrion/import-hook.mjs" }, - "require": { "default": "./build/orchestrion/require-hook.cjs" } - }, - "./orchestrion/vite": { - // Vite plugin factory. - "import": { "types": "./build/types/orchestrion/bundler/vite.d.ts", "default": "./build/esm/orchestrion/bundler/vite.js" }, - "require": { "types": "./build/types/orchestrion/bundler/vite.d.ts", "default": "./build/cjs/orchestrion/bundler/vite.js" } - } -} -``` - -End-user friction is minimized: either - -```bash -node --import @sentry/node/orchestrion app.js -``` - -or - -```ts -// vite.config.ts -import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; -export default { plugins: [sentryOrchestrionPlugin()] }; -``` - -No `instrumentations: [...]` array to copy-paste, no channel names to remember. - -## Runtime hook — `--import` ESM target - -`packages/server-utils/src/orchestrion/runtime/import-hook.mjs` - -```js -import { register } from 'node:module'; -import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; - -// 1) Double-wrap guard. Set this BEFORE register() so even if a second --import -// is added, we won't double-register. -const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); -if (g.runtime) { - console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --import. Ignoring the second load.'); -} else { - g.runtime = true; - register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { - data: { instrumentations: SENTRY_INSTRUMENTATIONS }, - }); -} -``` - -The import hook sets `globalThis.__SENTRY_ORCHESTRION__.runtime = true`. That marker is how `detect.ts` knows the runtime path is active later. - -## Vite plugin — build-time path - -`packages/server-utils/src/orchestrion/bundler/vite.ts` - -```ts -import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; -import type { Plugin } from 'vite'; -import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; - -export function sentryOrchestrionPlugin(): Plugin[] { - return [ - // 1) Inject the runtime marker into the bundle so detect.ts can see it. - markerPlugin(), - // 2) The actual orchestrion transformer, fed our central config. - codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }), - ]; -} - -function markerPlugin(): Plugin { - // Emits/injects a one-liner into the bundle output: - // globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {}); - // if (globalThis.__SENTRY_ORCHESTRION__.bundler) { console.warn('[Sentry] orchestrion bundler plugin loaded twice'); } - // globalThis.__SENTRY_ORCHESTRION__.bundler = true; - return { - name: 'sentry-orchestrion-marker', - enforce: 'pre', - // Easiest: hook `renderChunk` and prepend to entry chunks. - // Alternative: emit a virtual module + use `banner` config injection. - // To be decided during implementation — both work; the renderChunk approach - // avoids requiring the user to import anything. - }; -} -``` - -**Design decision — where the marker comes from in the bundler path:** -the plugin injects runtime JS into the bundle, not just a build-time flag. Build-time markers (e.g. `define`) are useless to `detect.ts`, which runs at app start. The marker must execute when the bundled app boots. - -## Detection — `detect.ts` - -`packages/server-utils/src/orchestrion/detect.ts` - -```ts -import { logger } from '@sentry/core'; - -declare global { - // eslint-disable-next-line no-var - var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; -} - -export function detectOrchestrionSetup(): void { - const marker = globalThis.__SENTRY_ORCHESTRION__; - const runtime = !!marker?.runtime; - const bundler = !!marker?.bundler; - - if (runtime && bundler) { - logger.warn( - '[Sentry] Detected BOTH the @sentry/node/orchestrion runtime hook AND the bundler plugin. ' + - 'Functions will be instrumented twice and produce duplicate spans. ' + - 'Remove `--import @sentry/node/orchestrion` if you are using the bundler plugin, or vice versa.', - ); - return; - } - - if (!runtime && !bundler) { - logger.warn( - '[Sentry] No auto-instrumentation hook detected. Channel-based integrations (mysql, …) will not record spans. ' + - 'Either run with `node --import @sentry/node/orchestrion app.js`, or add `sentryOrchestrionPlugin()` to your bundler config.', - ); - } -} -``` - -## Two-step user setup — flag on `init()` + `_experimentalSetupOrchestrion()` - -The opt-in is deliberately split so the orchestrion code path stays tree-shakable. `Sentry.init()` only learns about a boolean flag; it does **not** import anything from `orchestrion/`. The orchestrion-specific code only runs if the user explicitly imports and calls `_experimentalSetupOrchestrion()` after `init()`. - -This is used by for the Node orchestrion integration to keep it -opt-in, since it replaces existing working integrations. Other -SDKs that use orchestrion for net-new integrations will be -automatically enabled if available. - -### Step 1 — `_experimentalUseOrchestrion` flag on `NodeOptions` - -```ts -// packages/node/src/types.ts (or wherever NodeOptions lives) -export interface NodeOptions extends ClientOptions { - // … existing options … - /** - * EXPERIMENTAL — opt into the orchestrion.js-based auto-instrumentation path. - * When `true`, `Sentry.init()` will skip registering the default OTel - * auto-instrumentations for libraries that have a channel-based alternative - * (mysql, …). It does **not** install any channel subscribers on its own — - * call `_experimentalSetupOrchestrion()` after `init()` for that. - * - * Defaults to `false`. The flag name is intentionally underscore-prefixed and - * will be renamed or removed once the experiment graduates. - */ - _experimentalUseOrchestrion?: boolean; -} -``` - -```ts -// packages/node/src/sdk/index.ts (sketch of the additional lines in init()) -export function init(options: NodeOptions | undefined = {}): NodeClient | undefined { - // … existing init body, with one change: when assembling the default integrations - // list, skip entries whose libraries are covered by the orchestrion experiment. - if (options._experimentalUseOrchestrion) { - defaultIntegrations = defaultIntegrations.filter(i => !ORCHESTRION_REPLACED_INTEGRATIONS.has(i.name)); - } - // … the rest of init() is unchanged, and crucially does NOT import from ../orchestrion/* … -} - -// A tiny string-set constant — no orchestrion code imported. -const ORCHESTRION_REPLACED_INTEGRATIONS = new Set([ - 'Mysql', // matches the existing OTel mysql integration's `name` -]); -``` - -The list of replaced integration names is a plain string set defined alongside `init()` itself — it does not import from `server-utils/orchestrion/`, so toggling the flag doesn't pull orchestrion code into a user's bundle. - -### Step 2 — `_experimentalSetupOrchestrion()` as a separate export - -```ts -// packages/node/src/orchestrion/setup.ts -import { logger } from '@sentry/core'; -import type { NodeClient } from '../sdk/client'; -import { detectOrchestrionSetup } from './detect'; -import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; - -export interface ExperimentalSetupOrchestrionOptions { - /** - * Override or extend the default set of channel-based integrations. - * If omitted, all orchestrion integrations shipped by @sentry/node are added. - */ - integrations?: Integration[]; -} - -export function _experimentalSetupOrchestrion( - client: NodeClient | undefined, - options: ExperimentalSetupOrchestrionOptions = {}, -): void { - if (!client) { - logger.warn( - '[Sentry] _experimentalSetupOrchestrion() was called without a client. ' + - 'Pass the value returned by `Sentry.init()`.', - ); - return; - } - if (!client.getOptions()._experimentalUseOrchestrion) { - logger.warn( - '[Sentry] _experimentalSetupOrchestrion() called but Sentry.init() was not given ' + - '`_experimentalUseOrchestrion: true`. The default OTel integrations are still active — ' + - 'you will get duplicate spans. Add the flag to Sentry.init().', - ); - } - - // 1) Verify the runtime/bundler hook actually ran. - detectOrchestrionSetup(); - - // 2) Register the channel-based integrations on the passed-in client. - const integrations = options.integrations ?? [ - mysqlChannelIntegration(), - // … future channel integrations default-on here. - ]; - for (const integration of integrations) { - client.addIntegration(integration); - } -} -``` - -Taking the client as an explicit argument (instead of pulling it from `getClient()`) makes the call order unambiguous, avoids surprises when multiple clients exist (tests, multi-tenant setups), and gives TypeScript users a clear type on what `_experimentalSetupOrchestrion` operates against. - -`_experimentalSetupOrchestrion` is the **only** export through which orchestrion-specific code is reachable from a user's app graph. Bundlers can statically determine that an app which never imports it has no live edges into `orchestrion/`, so all the channel subscribers, detection code, and integration factories drop out. - -The function is also where we sanity-check the user's setup: it warns if `init()` wasn't told about the flag, and it runs `detectOrchestrionSetup()` to confirm exactly one of the runtime / bundler paths is active. - -### Usage - -```ts -import * as Sentry from '@sentry/node'; -import { _experimentalSetupOrchestrion } from '@sentry/node'; - -const client = Sentry.init({ - dsn: '…', - _experimentalUseOrchestrion: true, -}); - -_experimentalSetupOrchestrion(client); -// Or, to override which integrations are registered: -// _experimentalSetupOrchestrion(client, { integrations: [mysqlChannelIntegration()] }); -``` - -This keeps the experiment self-contained — no parallel `init` function, no separate entry point — while still being fully tree-shakable for users who don't opt in. - -## End-user surface - -**Bundled app (Vite):** - -```ts -// vite.config.ts -import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; -export default { plugins: [sentryOrchestrionPlugin()] }; - -// app.ts -import * as Sentry from '@sentry/node'; -import { _experimentalSetupOrchestrion } from '@sentry/node'; - -const client = Sentry.init({ - dsn: '…', - _experimentalUseOrchestrion: true, -}); -_experimentalSetupOrchestrion(client); -``` - -**Unbundled Node ESM app:** - -```bash -node --import @sentry/node/orchestrion app.js -``` - -```ts -// app.ts — same two-step init + setup as above, no plugin needed. -``` - -**Unbundled Node CJS app:** - -```bash -node --require @sentry/node/orchestrion app.js -``` - -If the user does **neither** runtime nor bundler hook, `_experimentalSetupOrchestrion()` warns at startup. If they do **both**, it also warns. If they set `_experimentalUseOrchestrion: true` but never call `_experimentalSetupOrchestrion()`, they get no channel-based spans and no OTel-based spans for the replaced libraries — also a warning case (emitted lazily the first time the client tries to flush, since we can't observe the missing call directly at `init()` time). TBD whether this third warning is worth the complexity. - -## Double-wrap analysis — what orchestrion does and doesn't protect against - -| Failure mode | Who catches it | How | -| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| Bundler plugin added twice in the same Vite config | orchestrion's bundler plugin itself? **Unverified** — needs a test during the spike. If not, our marker plugin warns. | `__SENTRY_ORCHESTRION__.bundler` already true at second plugin invocation. | -| `--import @sentry/node/orchestrion` passed twice on CLI | Our hook | Marker set before `register()`, second load short-circuits with a warn. | -| Bundler plugin + runtime hook both run | Our `detect.ts` at `Sentry.init` | Warn — this is the most likely real-world footgun, since a Vite-built app may still launch with a stray `--import` from prod tooling. | -| Neither runs | Our `detect.ts` | Warn — user thinks Sentry instruments their DB but it silently doesn't. | -| Orchestrion patches a function the user already patched manually | **Out of scope** for this experiment. Document it. | n/a | - -## Implementation phases - -1. **Plumbing first** — branch (done), add the three orchestrion packages to `packages/node/package.json` as `dependencies`, create `orchestrion/` directory with empty `config.ts`, `channels.ts`, `detect.ts`. No real channels yet. Build passes. -2. **Runtime path end-to-end** — wire `import-hook.mjs` + the rollup config in `packages/node/rollup.npm.config.mjs` to emit it. Verify with a throwaway script that has _one_ instrumentation in `config.ts` (a function in a tiny local fixture module) that publishing fires. -3. **Mysql channel integration** — write `integrations/tracing-channel/mysql.ts`. Plug into a `dev-packages/node-integration-tests/` scenario that runs against a real mysql container, asserts spans. -4. **Bundler path** — add `sentryOrchestrionPlugin()` for Vite, including marker injection. Test in a small fixture under `dev-packages/e2e-tests/` (Vite-built Node entry hitting mysql). -5. **Detection + setup entry point** — add `detect.ts` + `setup.ts` (exporting `_experimentalSetupOrchestrion`), wire the `_experimentalUseOrchestrion` flag into `init()` so it filters the default integrations, and re-export `_experimentalSetupOrchestrion` from the package root. Test all four hook states (runtime only / bundler only / both / neither) via the e2e fixtures, plus a bundler-size assertion that not importing `_experimentalSetupOrchestrion` drops `orchestrion/*` from the output. -6. **Decide & write up** — capture findings in a follow-up doc: does this beat the OTel path on (a) bundle size, (b) cold start, (c) reliability, (d) maintenance cost? - -## Open questions to settle during the spike - -- **Does `@apm-js-collab/tracing-hooks` ship its own double-register guard?** Cheap to test — register twice, see if it complains. If yes, our runtime-path warning is belt-and-suspenders; if no, our marker is the only guard. -- **Does `code-transformer-bundler-plugins/vite` work cleanly with Vite's SSR / library modes?** Our likely consumers (Next, Nuxt, SvelteKit server bundles) all go through SSR pipelines. -- **`TracingChannel` callback context shape** — orchestrion docs describe the channel name + the `kind` (Sync/Async/Callback) but not the exact `context` payload (what `arguments`, `this`, `result`, `error` keys are present). Needs a quick `subscribe` + `console.log` smoke test before writing `mysql.ts`. -- **CJS vs ESM coverage** — does the runtime require-hook see ESM imports of mysql? Does the import-hook see CJS requires? The mysql package itself is CJS, but the consuming app may be either. Likely we need to wire both hooks together in `--import @sentry/node/orchestrion` (the ESM hook also patches CJS via the require-hook path). -- **How do we keep `SENTRY_INSTRUMENTATIONS` tree-shakable?** If a user only wants mysql, the unused configs shouldn't ship. Probably each integration owns its config fragment and `config.ts` aggregates via barrel import — TBD during phase 1. diff --git a/dev-packages/bun-integration-tests/package.json b/dev-packages/bun-integration-tests/package.json index 560fc51f7d20..c4d4605349e5 100644 --- a/dev-packages/bun-integration-tests/package.json +++ b/dev-packages/bun-integration-tests/package.json @@ -15,7 +15,8 @@ "dependencies": { "@sentry/bun": "10.58.0", "@sentry/hono": "10.58.0", - "hono": "^4.12.25" + "hono": "^4.12.25", + "mysql": "^2.18.1" }, "devDependencies": { "@sentry-internal/test-utils": "10.58.0", diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/build.ts b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/build.ts new file mode 100644 index 000000000000..b1fceaa424fc --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/build.ts @@ -0,0 +1,43 @@ +// Builds the smoke scenario with the orchestrion `bun build` plugin and writes +// the bundle to a temp dir, printing the output path for test.ts to execute. +// +// A successful build proves `bun build` runs with the plugin; running the bundle +// (see test.ts) then proves the bundled `mysql` is actually instrumented. + +// @ts-ignore -- subpath export resolved by Bun at runtime; the package +// tsconfig's node module resolution can't see `exports` subpaths. +import { sentryBunPlugin } from '@sentry/bun/plugin'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +void (async () => { + const outdir = join(tmpdir(), `sentry-bun-orchestrion-${process.pid}-${Date.now()}`); + const result = await Bun.build({ + entrypoints: [join(__dirname, 'scenario.ts')], + target: 'bun', + outdir, + // Deliberately mark `mysql` external. An externalized dependency is resolved + // from `node_modules` at runtime and never passes through the transform's + // `onLoad`, so its channel injection would be silently skipped. The plugin + // must strip instrumented packages back out of `external` so they get + // bundled (and thus transformed). + external: ['mysql'], + plugins: [sentryBunPlugin()], + }); + + if (!result.success) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED', result.logs); + process.exit(1); + } + + const output = result.outputs[0]; + if (!output) { + // eslint-disable-next-line no-console + console.error('BUILD_FAILED no outputs'); + process.exit(1); + } + + // eslint-disable-next-line no-console + console.log(`BUILD_OK outfile=${output.path}`); +})(); diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/scenario.ts b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/scenario.ts new file mode 100644 index 000000000000..0afcd19579f0 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/scenario.ts @@ -0,0 +1,66 @@ +// Bundled entry for the `bun build` smoke test. +// +// Once `Bun.build` (with the orchestrion plugin) has transformed `mysql`, +// calling `connection.query()` publishes to the `orchestrion:mysql:query` +// tracing channel. +// +// `start` fires synchronously on the call, so no live database is needed. +// +// We subscribe, run a query, and report which channel events fired +// (plus the detection marker the plugin's banner sets at boot). + +import { tracingChannel } from 'node:diagnostics_channel'; + +// @ts-ignore -- `mysql` ships no type declarations; only needed at runtime. +import mysql from 'mysql'; + +interface QueryContext { + arguments?: unknown[]; +} +interface Connection { + query(sql: string, cb: () => void): void; + destroy(): void; +} +interface MysqlModule { + createConnection(opts: { host: string; user: string }): Connection; +} + +const events: string[] = []; +let statement = ''; + +tracingChannel('orchestrion:mysql:query').subscribe({ + start(message: unknown) { + events.push('start'); + const first = (message as QueryContext).arguments?.[0]; + statement = typeof first === 'string' ? first : ''; + }, + end() { + events.push('end'); + }, + asyncStart() {}, + asyncEnd() { + events.push('asyncEnd'); + }, + error() {}, +}); + +const conn = (mysql as MysqlModule).createConnection({ host: '127.0.0.1', user: 'root' }); +try { + conn.query('SELECT 1 AS solution', () => {}); +} catch { + // No live server — `start` has already published synchronously by this point. +} +try { + conn.destroy(); +} catch { + // ignore +} + +const marker = (globalThis as { __SENTRY_ORCHESTRION__?: { runtime?: boolean; bundler?: boolean } }) + .__SENTRY_ORCHESTRION__; + +setTimeout(() => { + // eslint-disable-next-line no-console + console.log(`SCENARIO events=${events.join(',')} statement=${statement} marker=${JSON.stringify(marker ?? null)}`); + process.exit(0); +}, 200); diff --git a/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts new file mode 100644 index 000000000000..d29903b76970 --- /dev/null +++ b/dev-packages/bun-integration-tests/suites/orchestrion-mysql/test.ts @@ -0,0 +1,63 @@ +import { spawnSync } from 'child_process'; +import { rmSync } from 'fs'; +import { dirname, join } from 'path'; +import { describe, expect, it } from 'vitest'; + +const dir = __dirname; + +// Cap each `bun` subprocess. The test runs two of them sequentially, so its own +// timeout (see the `it(...)` below) must exceed `2 * SUBPROCESS_TIMEOUT_MS` — +// otherwise the suite's default `testTimeout` (20s) fails the test before these +// caps do, e.g. on a slow CI runner where the build+run legitimately takes >20s. +const SUBPROCESS_TIMEOUT_MS = 60_000; + +function runBun(args: string[]): { stdout: string; stderr: string; status: number | null } { + const res = spawnSync('bun', args, { cwd: dir, encoding: 'utf8', timeout: SUBPROCESS_TIMEOUT_MS }); + return { stdout: res.stdout ?? '', stderr: res.stderr ?? '', status: res.status }; +} + +// Bun orchestrion instrumentation is BUILD-ONLY (`@sentry/bun/plugin` is a +// `Bun.build` plugin; there is no `bun run` preload). +// +// A `bun run` runtime plugin cannot instrument CommonJS dependencies like +// `mysql`: any module returned by a runtime `onLoad` plugin in Bun loses its +// CommonJS named exports +// +// When https://github.com/oven-sh/bun/pull/31770 lands, we can revisit an +// auto-load plugin for `bun run`. +describe('orchestrion mysql instrumentation (Bun)', () => { + it( + 'bundles `mysql` with the plugin, and the built output fires the mysql channel when run', + () => { + // Build the scenario with the orchestrion `bun build` plugin. + const build = runBun(['run', join(dir, 'build.ts')]); + expect(build.status, `build failed:\nstderr:\n${build.stderr}\nstdout:\n${build.stdout}`).toBe(0); + + const outfile = build.stdout.match(/BUILD_OK outfile=(.+)/)?.[1]?.trim(); + expect(outfile, `no outfile in build output:\n${build.stdout}`).toBeTruthy(); + + try { + // Run the built bundle. The bundled (transformed) `mysql` should publish + // to the `orchestrion:mysql:query` channel when `connection.query()` is + // called, and the plugin's banner should set the `bundler` marker at boot. + const run = runBun(['run', outfile as string]); + expect(run.status, `run failed:\nstderr:\n${run.stderr}\nstdout:\n${run.stdout}`).toBe(0); + + const line = run.stdout.split('\n').find(l => l.startsWith('SCENARIO')) ?? ''; + // channel `start` fired on `connection.query()` + expect(line).toContain('events=start'); + // with the expected SQL + expect(line).toContain('statement=SELECT 1 AS solution'); + // injected banner ran at bundle boot + expect(line).toContain('"bundler":true'); + } finally { + if (outfile) { + rmSync(dirname(outfile), { recursive: true, force: true }); + } + } + // Allow for both sequential `runBun` calls hitting their subprocess cap, so + // the `spawnSync` timeouts — not Vitest's 20s default — are the binding limit. + }, + 2 * SUBPROCESS_TIMEOUT_MS, + ); +}); diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 86a717459850..1436e35fcc6b 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -21,11 +21,12 @@ const NODE_EXPORTS_IGNORE = [ 'SentryContextManager', 'validateOpenTelemetrySetup', 'preloadOpenTelemetry', + // Experimental, Node-runtime-only opt-in (diagnostics-channel injection); it + // registers Node module hooks and is not surfaced through the framework / + // serverless SDKs. + 'experimentalUseDiagnosticsChannelInjection', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', - // Experimental - '_experimentalSetupOrchestrion', - 'mysqlChannelIntegration', ]; const nodeExports = Object.keys(SentryNode).filter(e => !NODE_EXPORTS_IGNORE.includes(e)); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json index 4123b3e3d43b..e3f3bbf2efe7 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/package.json @@ -4,7 +4,7 @@ "private": true, "type": "commonjs", "scripts": { - "start": "node --import @sentry/node/orchestrion ./src/app.js", + "start": "node --import @sentry/node/import ./src/app.js", "test": "playwright test", "clean": "npx rimraf node_modules pnpm-lock.yaml dist", "test:build": "pnpm install", diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js index cc54b773b3c0..867a932b23bd 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-cjs/src/app.js @@ -1,16 +1,18 @@ const Sentry = require('@sentry/node'); -const client = Sentry.init({ +// The channels are injected by `node --import @sentry/node/import` (see the +// `start` script); opting in via this method makes the SDK subscribe to +// them instead of using the OTel instrumentation. +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, - _experimentalUseOrchestrion: true, }); -Sentry._experimentalSetupOrchestrion(client); - const express = require('express'); const mysql = require('mysql'); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/.gitignore b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/.gitignore deleted file mode 100644 index 1521c8b7652b..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/.gitignore +++ /dev/null @@ -1 +0,0 @@ -dist diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/package.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/package.json deleted file mode 100644 index dfcf44b6b889..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "node-express-orchestrion-vite-app", - "version": "1.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "vite build", - "start": "node --import ./dist/instrument.js ./dist/app.js", - "test": "playwright test", - "clean": "npx rimraf node_modules pnpm-lock.yaml dist", - "test:build": "pnpm install && pnpm build", - "test:assert": "pnpm test" - }, - "dependencies": { - "@sentry/node": "file:../../packed/sentry-node-packed.tgz", - "@types/express": "^4.17.21", - "@types/node": "^18.19.1", - "express": "^5.1.0", - "mysql": "2.18.1", - "typescript": "~5.0.0" - }, - "devDependencies": { - "@playwright/test": "~1.56.0", - "@sentry-internal/test-utils": "link:../../../test-utils", - "@sentry/core": "file:../../packed/sentry-core-packed.tgz", - "vite": "^5.4.11" - }, - "resolutions": { - "@types/qs": "6.9.17" - }, - "volta": { - "extends": "../../package.json" - } -} diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/playwright.config.mjs deleted file mode 100644 index 31f2b913b58b..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/playwright.config.mjs +++ /dev/null @@ -1,7 +0,0 @@ -import { getPlaywrightConfig } from '@sentry-internal/test-utils'; - -const config = getPlaywrightConfig({ - startCommand: `pnpm start`, -}); - -export default config; diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/app.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/app.ts deleted file mode 100644 index f34260393bb6..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/app.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as Sentry from '@sentry/node'; -import express from 'express'; -import mysql from 'mysql'; - -const connection = mysql.createConnection({ - user: 'root', - password: 'docker', -}); - -const app = express(); -const port = 3030; - -app.get('/test-success', function (req, res) { - res.send({ version: 'v1' }); -}); - -app.get('/test-param/:param', function (req, res) { - res.send({ paramWas: req.params.param }); -}); - -app.get('/test-mysql', function (req, res) { - connection.query('SELECT 1 + 1 AS solution', function () { - connection.query('SELECT NOW()', ['1', '2'], () => { - res.send({ status: 'ok' }); - }); - }); -}); - -app.get('/test-transaction', function (_req, res) { - Sentry.startSpan({ name: 'test-span' }, () => undefined); - - res.send({ status: 'ok' }); -}); - -app.get('/test-error', async function (req, res) { - const exceptionId = Sentry.captureException(new Error('This is an error')); - - await Sentry.flush(2000); - - res.send({ exceptionId }); -}); - -app.get('/test-exception/:id', function (req, _res) { - throw new Error(`This is an exception with id ${req.params.id}`); -}); - -Sentry.setupExpressErrorHandler(app); - -// @ts-ignore -app.use(function onError(err, req, res, next) { - // The error id is attached to `res.sentry` to be returned - // and optionally displayed to the user for support. - res.statusCode = 500; - res.end(res.sentry + '\n'); -}); - -app.listen(port, () => { - console.log(`Example app listening on port ${port}`); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/instrument.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/instrument.ts deleted file mode 100644 index 109beefafba6..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/src/instrument.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as Sentry from '@sentry/node'; - -const client = Sentry.init({ - environment: 'qa', // dynamic sampling bias to keep transactions - dsn: process.env.E2E_TEST_DSN, - debug: !!process.env.DEBUG, - tunnel: `http://localhost:3031/`, // proxy server - tracesSampleRate: 1, - _experimentalUseOrchestrion: true, -}); - -Sentry._experimentalSetupOrchestrion(client); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/start-event-proxy.mjs deleted file mode 100644 index 7cb02eee13af..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/start-event-proxy.mjs +++ /dev/null @@ -1,6 +0,0 @@ -import { startEventProxyServer } from '@sentry-internal/test-utils'; - -startEventProxyServer({ - port: 3031, - proxyServerName: 'node-express-orchestrion-vite', -}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/errors.test.ts deleted file mode 100644 index dd94052af1fe..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/errors.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForError } from '@sentry-internal/test-utils'; - -test('Sends correct error event', async ({ baseURL }) => { - const errorEventPromise = waitForError('node-express-orchestrion-vite', event => { - return !event.type && event.exception?.values?.[0]?.value === 'This is an exception with id 123'; - }); - - await fetch(`${baseURL}/test-exception/123`); - - const errorEvent = await errorEventPromise; - - expect(errorEvent.exception?.values).toHaveLength(1); - expect(errorEvent.exception?.values?.[0]?.value).toBe('This is an exception with id 123'); - - expect(errorEvent.request).toEqual({ - method: 'GET', - cookies: {}, - headers: expect.any(Object), - url: 'http://localhost:3030/test-exception/123', - }); - - expect(errorEvent.transaction).toEqual('GET /test-exception/:id'); - - expect(errorEvent.contexts?.trace).toEqual({ - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - }); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/transactions.test.ts deleted file mode 100644 index 1890a6af44ec..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tests/transactions.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { waitForTransaction } from '@sentry-internal/test-utils'; - -test('Sends an API route transaction', async ({ baseURL }) => { - const pageloadTransactionEventPromise = waitForTransaction('node-express-orchestrion-vite', transactionEvent => { - return ( - transactionEvent?.contexts?.trace?.op === 'http.server' && - transactionEvent?.transaction === 'GET /test-transaction' - ); - }); - - await fetch(`${baseURL}/test-transaction`); - - const transactionEvent = await pageloadTransactionEventPromise; - - expect(transactionEvent.contexts?.trace).toEqual({ - data: { - 'sentry.source': 'route', - 'sentry.origin': 'auto.http.otel.http', - 'sentry.op': 'http.server', - 'sentry.sample_rate': 1, - url: 'http://localhost:3030/test-transaction', - 'otel.kind': 'SERVER', - 'http.response.status_code': 200, - 'http.url': 'http://localhost:3030/test-transaction', - 'http.host': 'localhost:3030', - 'net.host.name': 'localhost', - 'http.method': 'GET', - 'http.scheme': 'http', - 'http.target': '/test-transaction', - 'http.user_agent': 'node', - 'http.flavor': '1.1', - 'net.transport': 'ip_tcp', - 'net.host.ip': expect.any(String), - 'net.host.port': expect.any(Number), - 'net.peer.ip': expect.any(String), - 'net.peer.port': expect.any(Number), - 'http.status_code': 200, - 'http.status_text': 'OK', - 'http.route': '/test-transaction', - 'http.request.header.accept': '*/*', - 'http.request.header.accept_encoding': 'gzip, deflate', - 'http.request.header.accept_language': '*', - 'http.request.header.connection': 'keep-alive', - 'http.request.header.host': expect.any(String), - 'http.request.header.sec_fetch_mode': 'cors', - 'http.request.header.user_agent': 'node', - }, - op: 'http.server', - span_id: expect.stringMatching(/[a-f0-9]{16}/), - status: 'ok', - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - origin: 'auto.http.otel.http', - }); - - expect(transactionEvent.contexts?.response).toEqual({ - status_code: 200, - }); - - expect(transactionEvent).toEqual( - expect.objectContaining({ - transaction: 'GET /test-transaction', - type: 'transaction', - transaction_info: { - source: 'route', - }, - }), - ); - - const spans = transactionEvent.spans || []; - - // Manually started span - expect(spans).toContainEqual({ - data: { 'sentry.origin': 'manual' }, - description: 'test-span', - origin: 'manual', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); - - // auto instrumented span - expect(spans).toContainEqual({ - data: { - 'sentry.origin': 'auto.http.express', - 'sentry.op': 'request_handler.express', - 'http.route': '/test-transaction', - 'express.name': '/test-transaction', - 'express.type': 'request_handler', - }, - description: '/test-transaction', - op: 'request_handler.express', - origin: 'auto.http.express', - parent_span_id: expect.stringMatching(/[a-f0-9]{16}/), - span_id: expect.stringMatching(/[a-f0-9]{16}/), - start_timestamp: expect.any(Number), - status: 'ok', - timestamp: expect.any(Number), - trace_id: expect.stringMatching(/[a-f0-9]{32}/), - }); -}); - -test('Sends an API route transaction for an errored route', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('node-express-orchestrion-vite', transactionEvent => { - return ( - transactionEvent.contexts?.trace?.op === 'http.server' && - transactionEvent.transaction === 'GET /test-exception/:id' && - transactionEvent.request?.url === 'http://localhost:3030/test-exception/777' - ); - }); - - await fetch(`${baseURL}/test-exception/777`); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); - expect(transactionEvent.transaction).toEqual('GET /test-exception/:id'); - expect(transactionEvent.contexts?.trace?.status).toEqual('internal_error'); - expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(500); -}); - -test('Instruments MySQL via Orchestrion', async ({ baseURL }) => { - const transactionEventPromise = waitForTransaction('node-express-orchestrion-vite', transactionEvent => { - return transactionEvent.contexts?.trace?.op === 'http.server' && transactionEvent.transaction === 'GET /test-mysql'; - }); - - await fetch(`${baseURL}/test-mysql`); - - const transactionEvent = await transactionEventPromise; - - expect(transactionEvent.contexts?.trace?.op).toEqual('http.server'); - expect(transactionEvent.transaction).toEqual('GET /test-mysql'); - expect(transactionEvent.contexts?.trace?.status).toEqual('ok'); - expect(transactionEvent.contexts?.trace?.data?.['http.status_code']).toEqual(200); - - const spans = transactionEvent.spans || []; - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.mysql', - description: 'SELECT 1 + 1 AS solution', - }), - ); - expect(spans).toContainEqual( - expect.objectContaining({ - op: 'db', - origin: 'auto.db.orchestrion.mysql', - description: 'SELECT NOW()', - }), - ); -}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tsconfig.json b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tsconfig.json deleted file mode 100644 index c46f5dea4945..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/tsconfig.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "compilerOptions": { - "types": ["node"], - "module": "ESNext", - "moduleResolution": "Bundler", - "esModuleInterop": true, - "lib": ["es2020"], - "strict": true, - "skipLibCheck": true - }, - "include": ["src/**/*.ts", "vite.config.ts"] -} diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/vite.config.ts b/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/vite.config.ts deleted file mode 100644 index daa0417a5e3a..000000000000 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion-vite/vite.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [sentryOrchestrionPlugin()], - build: { - target: 'node18', - ssr: true, - outDir: 'dist', - emptyOutDir: true, - rollupOptions: { - input: ['src/app.ts', 'src/instrument.ts'], - output: { - format: 'esm', - }, - }, - }, -}); diff --git a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs index bdce1c09630c..2f47402bb02a 100644 --- a/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs +++ b/dev-packages/e2e-tests/test-applications/node-express-orchestrion/src/instrument.mjs @@ -1,13 +1,15 @@ -import '@sentry/node/orchestrion'; import * as Sentry from '@sentry/node'; -const client = Sentry.init({ +// Opting in via `experimentalUseDiagnosticsChannelInjection()` (before `init`) +// is all that's needed. Because this file runs via `node --import` before +// `app.mjs` imports `mysql`, `Sentry.init()` synchronously installs the +// channel-injection hooks. +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ environment: 'qa', // dynamic sampling bias to keep transactions dsn: process.env.E2E_TEST_DSN, debug: !!process.env.DEBUG, tunnel: `http://localhost:3031/`, // proxy server tracesSampleRate: 1, - _experimentalUseOrchestrion: true, }); - -Sentry._experimentalSetupOrchestrion(client); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs new file mode 100644 index 000000000000..e2ddb56b9ffa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/assert.mjs @@ -0,0 +1,48 @@ +/** + * Asserts the orchestrion subtree is tree-shaken out of the bundle unless the + * app opted in via `experimentalUseDiagnosticsChannelInjection()`. + * + * @module + */ +import { readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// `orchestrion:mysql:query` lives only in @sentry/server-utils' orchestrion +// subtree (channels.ts), never in @sentry/node — so finding it in a bundle +// means the orchestrion code path was pulled in. +const MARKER = 'orchestrion:mysql:query'; + +function bundleText(name) { + const dir = join(__dirname, 'dist', name); + return readdirSync(dir) + .map(f => readFileSync(join(dir, f), 'utf8')) + .join('\n'); +} + +let failed = false; +function check(condition, message) { + // eslint-disable-next-line no-console + console.log(`${condition ? 'ok ' : 'FAIL'} - ${message}`); + if (!condition) failed = true; +} + +const noOrchestrion = bundleText('no-orchestrion'); +const withOrchestrion = bundleText('with-orchestrion'); + +check( + !noOrchestrion.includes(MARKER), + 'orchestrion is EXCLUDED when experimentalUseDiagnosticsChannelInjection() is NOT called', +); +check( + withOrchestrion.includes(MARKER), + 'orchestrion is INCLUDED when experimentalUseDiagnosticsChannelInjection() IS called', +); + +if (failed) { + process.exit(1); +} +// eslint-disable-next-line no-console +console.log('All bundle tree-shaking assertions passed.'); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs new file mode 100644 index 000000000000..81f1e661d9e1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/build.mjs @@ -0,0 +1,43 @@ +// Bundles both entrypoints with webpack (the pinned version in package.json +// kept current, since webpack's `createRequire` following has changed across +// releases). Outputs go to ./dist// for assert.mjs to inspect. +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import webpack from 'webpack'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function build(name) { + return new Promise((resolve, reject) => { + webpack( + { + entry: join(__dirname, 'src', `${name}.mjs`), + mode: 'production', + target: 'node', + experiments: { topLevelAwait: true, outputModule: true }, + output: { + path: join(__dirname, 'dist', name), + filename: 'main.mjs', + module: true, + library: { type: 'module' }, + chunkFormat: 'module', + }, + // Keep output readable; tree-shaking (module elimination via + // `sideEffects: false`) happens regardless of minification, and + // it's important to be able to debug when it messes up. + optimization: { minimize: false }, + }, + (err, stats) => { + if (err) return reject(err); + if (stats.hasErrors()) { + return reject(new Error(`webpack build of ${name} failed:\n${stats.toString({ errors: true })}`)); + } + // eslint-disable-next-line no-console + console.log(`built ${name} (webpack ${webpack.version})`); + resolve(); + }, + ); + }); +} + +await Promise.all([build('no-orchestrion'), build('with-orchestrion')]); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json new file mode 100644 index 000000000000..69dd20caf346 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/package.json @@ -0,0 +1,22 @@ +{ + "name": "node-orchestrion-webpack", + "description": "ensure that orchestrion is not bundled inappropriately", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "clean": "npx rimraf node_modules dist pnpm-lock.yaml", + "test:build": "pnpm install && node ./build.mjs", + "test:assert": "node ./assert.mjs" + }, + "dependencies": { + "@sentry/node": "file:../../packed/sentry-node-packed.tgz", + "@sentry/server-utils": "file:../../packed/sentry-server-utils-packed.tgz" + }, + "devDependencies": { + "webpack": "5.107.2" + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs new file mode 100644 index 000000000000..e66db6685328 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/app.mjs @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-console +console.log('this is the application'); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/no-orchestrion.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/no-orchestrion.mjs new file mode 100644 index 000000000000..104d9144f5f9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/no-orchestrion.mjs @@ -0,0 +1,10 @@ +// Does NOT call `experimentalUseDiagnosticsChannelInjection()`, so a bundler +// must be able to drop the entire orchestrion subtree from the output. +import * as Sentry from '@sentry/node'; + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, +}); + +await import('./app.mjs'); diff --git a/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/with-orchestrion.mjs b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/with-orchestrion.mjs new file mode 100644 index 000000000000..29c9ab3d5de8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/node-orchestrion-webpack/src/with-orchestrion.mjs @@ -0,0 +1,12 @@ +// Calls `experimentalUseDiagnosticsChannelInjection()`, so the orchestrion +// subtree MUST be reachable and end up in the bundle. +import * as Sentry from '@sentry/node'; + +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, +}); + +await import('./app.mjs'); diff --git a/dev-packages/node-integration-tests/package.json b/dev-packages/node-integration-tests/package.json index d3a61f5814f0..fa5396cc7eae 100644 --- a/dev-packages/node-integration-tests/package.json +++ b/dev-packages/node-integration-tests/package.json @@ -101,6 +101,7 @@ "@types/amqplib": "^0.10.5", "@types/node-cron": "^3.0.11", "@types/node-schedule": "^2.1.7", + "esbuild": "0.28.0", "eslint-plugin-regexp": "^3.1.0", "globby": "11", "react": "^18.3.1", diff --git a/dev-packages/node-integration-tests/suites/esbuild/app.ts b/dev-packages/node-integration-tests/suites/esbuild/app.ts new file mode 100644 index 000000000000..6b11a4ce7fda --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/app.ts @@ -0,0 +1,10 @@ +import * as Sentry from '@sentry/node'; + +// `@sentry/node` is `sideEffects: false`, so esbuild only evaluates the +// module if we reference an export. +// a module-scope `createRequire(import.meta.url)` throws +// in a CJS bundle, because esbuild rewrites `import.meta.url` to `{}`, +// so it becomes `createRequire(undefined)`, which would break apps that +// do not opt into orchestrion. +// eslint-disable-next-line no-console +console.log(`SENTRY_NODE_LOADED typeof_init=${typeof Sentry.init}`); diff --git a/dev-packages/node-integration-tests/suites/esbuild/test.ts b/dev-packages/node-integration-tests/suites/esbuild/test.ts new file mode 100644 index 000000000000..1d1806f0b843 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/esbuild/test.ts @@ -0,0 +1,35 @@ +import { spawnSync } from 'child_process'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { build } from 'esbuild'; +import { describe, expect, test } from 'vitest'; + +describe('esbuild bundling', () => { + test('@sentry/node loads when bundled to CommonJS with esbuild', async () => { + const outDir = mkdtempSync(join(tmpdir(), 'sentry-esbuild-cjs-')); + const outfile = join(outDir, 'bundle.cjs'); + + try { + await build({ + entryPoints: [join(__dirname, 'app.ts')], + outfile, + platform: 'node', + format: 'cjs', + bundle: true, + logLevel: 'silent', + }); + + const result = spawnSync('node', [outfile], { encoding: 'utf-8' }); + + // The specific failure signature this guards against. + expect(result.stderr).not.toContain('ERR_INVALID_ARG_VALUE'); + expect(result.stderr).not.toContain('createRequire'); + // The bundle loaded and ran to completion. + expect(result.status).toBe(0); + expect(result.stdout).toContain('SENTRY_NODE_LOADED'); + } finally { + rmSync(outDir, { recursive: true, force: true }); + } + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs index 02bdca0e776d..032187efe33b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs @@ -1,16 +1,17 @@ -// The orchestrion runtime hook is loaded via the `--import @sentry/node/orchestrion` -// CLI flag (see test.ts), mirroring real usage. That single ESM hook instruments -// both ESM and CJS user code, so the same flag works for the esm and cjs scenarios. +// Opting in via `experimentalUseDiagnosticsChannelInjection()` (before `init`) +// is all that's needed. +// +// `Sentry.init()` swaps the OTel `mysql` instrumentation +// for the diagnostics-channel one and synchronously +// installs the module hooks that inject the channels. import * as Sentry from '@sentry/node'; import { loggingTransport } from '@sentry-internal/node-integration-tests'; -const client = Sentry.init({ +Sentry.experimentalUseDiagnosticsChannelInjection(); + +Sentry.init({ dsn: 'https://public@dsn.ingest.sentry.io/1337', release: '1.0', tracesSampleRate: 1.0, transport: loggingTransport, - _experimentalUseOrchestrion: true, - debug: true, }); - -Sentry._experimentalSetupOrchestrion(client); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts index 1bea6b25706b..6d5ba767cea1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -6,92 +6,92 @@ describe('mysql auto instrumentation', () => { cleanupChildProcesses(); }); - const EXPECTED_TRANSACTION = { - transaction: 'Test Transaction', - spans: expect.arrayContaining([ + // Builds the expected transaction. When `origin` is given, the spans must also + // carry that `sentry.origin`, which is how we assert that the + // diagnostics-channel instrumentation (not the OTel one) produced them. + function expectedTransaction(origin?: string): Record { + const span = (description: string): ReturnType => expect.objectContaining({ - description: 'SELECT 1 + 1 AS solution', + description, op: 'db', + ...(origin ? { origin } : {}), data: expect.objectContaining({ 'db.system': 'mysql', 'net.peer.name': 'localhost', 'net.peer.port': 3306, 'db.user': 'root', }), - }), - expect.objectContaining({ - description: 'SELECT NOW()', - op: 'db', - data: expect.objectContaining({ - 'db.system': 'mysql', - 'net.peer.name': 'localhost', - 'net.peer.port': 3306, - 'db.user': 'root', - }), - }), - ]), - }; + }); - describe.each([ - ['opentelemetry-based', 'instrument.mjs'], - ['orchestrion-based', 'instrument-orchestrion.mjs'], - ])('%s', (instrumentation, instrumentFile) => { - // esm is not supported for the otel instrumentation - const failsOnEsm = instrumentation === 'opentelemetry-based'; + return { + transaction: 'Test Transaction', + spans: expect.arrayContaining([span('SELECT 1 + 1 AS solution'), span('SELECT NOW()')]), + }; + } - // The orchestrion path is activated via the `--import @sentry/node/orchestrion` - // CLI flag. That single ESM hook instruments both ESM and CJS user code (via - // `Module.registerHooks` where available, otherwise `Module.register` + the - // CJS `Module._compile` patch), so the same flag covers the esm and cjs - // scenarios. The OTel path needs no extra flag. - const orchestrionFlags = instrumentation === 'orchestrion-based' ? ['--import', '@sentry/node/orchestrion'] : []; + const CHANNEL_ORIGIN = 'auto.db.orchestrion.mysql'; - createEsmAndCjsTests( - __dirname, - 'scenario-withConnect.mjs', - instrumentFile, - (createRunner, test) => { - test('should auto-instrument `mysql` package when using connection.connect()', async () => { - await createRunner() - .withFlags(...orchestrionFlags) - .expect({ transaction: EXPECTED_TRANSACTION }) - .start() - .completed(); - }); - }, - { failsOnEsm }, - ); + // Each case maps to one of the two documented use cases, in opt-in and + // non-opt-in form. `flags` are extra Node CLI flags; the instrument file is + // always loaded via `--import` (esm) / `--require` (cjs) by the runner. + const CASES = [ + // OpenTelemetry default — no opt-in, no injection. (OTel does not support ESM.) + { label: 'opentelemetry (default)', instrument: 'instrument.mjs', flags: [], origin: undefined, failsOnEsm: true }, + // Opt-in via init only. `Sentry.init()` injects the channels synchronously. + { + label: 'diagnostics-channel (init opt-in)', + instrument: 'instrument-orchestrion.mjs', + flags: [], + origin: CHANNEL_ORIGIN, + failsOnEsm: false, + }, + // Opt-in and rely on `node --import @sentry/node/import`. + { + label: 'diagnostics-channel (--import @sentry/node/import opt-in)', + instrument: 'instrument-orchestrion.mjs', + flags: ['--import', '@sentry/node/import'], + origin: CHANNEL_ORIGIN, + failsOnEsm: false, + }, + // Without opt-in: channels are injected unconditionally but not subscribed + // to, so the OTel instrumentation records the spans — proves injecting the + // channels has no downside. (OTel does not support ESM.) + { + label: 'opentelemetry (channels injected, no opt-in)', + instrument: 'instrument.mjs', + flags: ['--import', '@sentry/node/import'], + origin: undefined, + failsOnEsm: true, + }, + ] as const; - createEsmAndCjsTests( - __dirname, - 'scenario-withoutCallback.mjs', - instrumentFile, - (createRunner, test) => { - test('should auto-instrument `mysql` package when using query without callback', async () => { - await createRunner() - .withFlags(...orchestrionFlags) - .expect({ transaction: EXPECTED_TRANSACTION }) - .start() - .completed(); - }); - }, - { failsOnEsm }, - ); + const SCENARIOS = [ + ['scenario-withConnect.mjs', 'using connection.connect()'], + ['scenario-withoutCallback.mjs', 'using query without callback'], + ['scenario-withoutConnect.mjs', 'without connection.connect()'], + ] as const; - createEsmAndCjsTests( - __dirname, - 'scenario-withoutConnect.mjs', - instrumentFile, - (createRunner, test) => { - test('should auto-instrument `mysql` package without connection.connect()', async () => { - await createRunner() - .withFlags(...orchestrionFlags) - .expect({ transaction: EXPECTED_TRANSACTION }) - .start() - .completed(); - }); - }, - { failsOnEsm }, - ); - }); + for (const { label, instrument, flags, origin, failsOnEsm } of CASES) { + describe(label, () => { + const expected = expectedTransaction(origin); + + for (const [scenario, description] of SCENARIOS) { + createEsmAndCjsTests( + __dirname, + scenario, + instrument, + (createRunner, test) => { + test(`should auto-instrument \`mysql\` package when ${description}`, async () => { + await createRunner() + .withFlags(...flags) + .expect({ transaction: expected }) + .start() + .completed(); + }); + }, + { failsOnEsm }, + ); + } + }); + } }); diff --git a/dev-packages/rollup-utils/code/otelEsmImportHookWithDiagnosticsChannelTemplate.js b/dev-packages/rollup-utils/code/otelEsmImportHookWithDiagnosticsChannelTemplate.js new file mode 100644 index 000000000000..e221b22cbd4a --- /dev/null +++ b/dev-packages/rollup-utils/code/otelEsmImportHookWithDiagnosticsChannelTemplate.js @@ -0,0 +1,10 @@ +// Like otelEsmImportHookTemplate.js, but also registers the diagnostics-channel +// injection so that `node --import @sentry/node/import app.js` injects the +// channels unconditionally (they are only *subscribed* to when the app opts in +// via `experimentalUseDiagnosticsChannelInjection()`). +import '@sentry/server-utils/orchestrion/import-hook'; +import { register } from 'module'; + +register('@opentelemetry/instrumentation/hook.mjs', import.meta.url); + +globalThis._sentryEsmLoaderHookRegistered = true; diff --git a/dev-packages/rollup-utils/npmHelpers.mjs b/dev-packages/rollup-utils/npmHelpers.mjs index 4a184d1ea4e5..10d3132cb84f 100644 --- a/dev-packages/rollup-utils/npmHelpers.mjs +++ b/dev-packages/rollup-utils/npmHelpers.mjs @@ -179,12 +179,25 @@ export function makeNPMConfigVariants(baseConfig, options = {}) { /** * This creates a loader file at the target location as part of the rollup build. * This loader script can then be used in combination with various Node.js flags (like --import=...) to monkeypatch 3rd party modules. + * + * @param {string} outputFolder Build output folder. + * @param {'otel' | 'sentry-node'} hookVariant Which hook template to use. + * @param {{ injectDiagnosticsChannel?: boolean }} [options] When `injectDiagnosticsChannel` + * is set (only valid for the `'otel'` variant), the generated `import-hook.mjs` + * additionally imports `@sentry/server-utils/orchestrion/import-hook`, which + * registers the diagnostics-channel injection. Used by `@sentry/node` so that + * `node --import @sentry/node/import` injects the channels unconditionally. */ -export function makeOtelLoaders(outputFolder, hookVariant) { +export function makeOtelLoaders(outputFolder, hookVariant, options = {}) { if (hookVariant !== 'otel' && hookVariant !== 'sentry-node') { throw new Error('hookVariant is neither "otel" nor "sentry-node". Pick one.'); } + const { injectDiagnosticsChannel = false } = options; + if (injectDiagnosticsChannel && hookVariant !== 'otel') { + throw new Error('injectDiagnosticsChannel is only supported with the "otel" hookVariant.'); + } + const expectedRegisterLoaderLocation = `${outputFolder}/import-hook.mjs`; const foundRegisterLoaderExport = Object.keys(packageDotJSON.exports ?? {}).some(key => { return packageDotJSON?.exports?.[key]?.import?.default === expectedRegisterLoaderLocation; @@ -229,7 +242,11 @@ export function makeOtelLoaders(outputFolder, hookVariant) { input: path.join( __dirname, 'code', - hookVariant === 'otel' ? 'otelEsmImportHookTemplate.js' : 'sentryNodeEsmImportHookTemplate.js', + hookVariant === 'otel' + ? injectDiagnosticsChannel + ? 'otelEsmImportHookWithDiagnosticsChannelTemplate.js' + : 'otelEsmImportHookTemplate.js' + : 'sentryNodeEsmImportHookTemplate.js', ), external: /.*/, output: { diff --git a/packages/bun/package.json b/packages/bun/package.json index b103dac9f2a9..b9bb7c8aca41 100644 --- a/packages/bun/package.json +++ b/packages/bun/package.json @@ -26,6 +26,16 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } + }, + "./plugin": { + "import": { + "types": "./build/types/plugin.d.ts", + "default": "./build/esm/plugin.js" + }, + "require": { + "types": "./build/types/plugin.d.ts", + "default": "./build/cjs/plugin.js" + } } }, "typesVersions": { @@ -39,8 +49,10 @@ "access": "public" }, "dependencies": { + "@apm-js-collab/code-transformer-bundler-plugins": "^0.3.0", "@sentry/core": "10.58.0", - "@sentry/node": "10.58.0" + "@sentry/node": "10.58.0", + "@sentry/server-utils": "10.58.0" }, "devDependencies": { "bun-types": "^1.2.9" diff --git a/packages/bun/rollup.npm.config.mjs b/packages/bun/rollup.npm.config.mjs index 84a06f2fb64a..3dd32fb92399 100644 --- a/packages/bun/rollup.npm.config.mjs +++ b/packages/bun/rollup.npm.config.mjs @@ -1,3 +1,10 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollup-utils'; -export default makeNPMConfigVariants(makeBaseNPMConfig()); +export default makeNPMConfigVariants( + makeBaseNPMConfig({ + // `src/plugin.ts` backs the `@sentry/bun/plugin` subpath (the orchestrion + // `bun build` plugin). It isn't reachable from `src/index.ts`, so we list it + // as a separate entrypoint to get both ESM and CJS builds. + entrypoints: ['src/index.ts', 'src/plugin.ts'], + }), +); diff --git a/packages/bun/src/plugin.ts b/packages/bun/src/plugin.ts new file mode 100644 index 000000000000..9c35010722a7 --- /dev/null +++ b/packages/bun/src/plugin.ts @@ -0,0 +1,98 @@ +/** + * orchestrion code-transform plugin for Bun's bundler (`bun build`). + * + * Usage: + * + * ```ts + * import { sentryBunPlugin } from '@sentry/bun/plugin'; + * await Bun.build({ + * entrypoints: ['./app.ts'], + * plugins: [sentryBunPlugin()], + * }); + * ``` + * + * This is BUILD-ONLY. Runtime instrumentation (`bun run`) is intentionally not + * offered: a module returned by a runtime `onLoad` plugin in Bun loses its + * CommonJS named exports. + * + * When https://github.com/oven-sh/bun/pull/31770 lands, we can revisit. + * + * Until then, Bun apps must bundle to get orchestrion instrumentation. In dev + * (ie, `bun run`) there is simply no instrumentation, which is clearer than + * partial/inconsistent coverage. + * + * Shipped as both ESM and CJS (via the `@sentry/bun/plugin` subpath) so a user's + * `bun build` script can be authored in either module system. It's a plain + * library import here (not a `--import`/`--preload` hook), so CJS is fine; Bun + * resolves the underlying ESM-only transformer in either module system. + * + * @module + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type UnknownPlugin = any; + +// `@apm-js-collab/code-transformer-bundler-plugins/bun` is published ESM-only +// (no `require` arm, unlike its `/vite` entry). The ESM build imports it; the +// CJS build requires it. Bun resolves correctly for ESM modules in either +// module system. +import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/bun'; +import { SENTRY_INSTRUMENTATIONS, withoutInstrumentedExternals } from '@sentry/server-utils/orchestrion/config'; + +const BUNDLER_MARKER_BANNER = + ';(globalThis.__SENTRY_ORCHESTRION__=(globalThis.__SENTRY_ORCHESTRION__||{})).bundler=true;'; + +// Minimal shape of Bun's `PluginBuilder` that we touch. Typed locally instead +// of depending on `bun-types`, which would pull Bun's globals. +interface BunPluginBuilder { + config?: { banner?: string; external?: string[] }; +} + +/** + * Returns the orchestrion code-transform plugin for Bun's bundler, configured + * with the central `SENTRY_INSTRUMENTATIONS`. The plugin injects + * `diagnostics_channel.tracingChannel` calls into the instrumented libraries as + * `bun build` bundles them, and injects a banner that sets + * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` when the bundle boots + * + * Pass the result to `Bun.build({ plugins: [...] })`. + * + * @example + * ```ts + * import { sentryBunPlugin } from '@sentry/bun/plugin'; + * await Bun.build({ entrypoints: ['./app.ts'], plugins: [sentryBunPlugin()] }); + * ``` + */ +export function sentryBunPlugin(): UnknownPlugin { + // Typed upstream as an esbuild `Plugin`, but Bun passes its own + // `PluginBuilder` (which has the `onLoad` the transform uses) to `setup`. + // Cast to the Bun-compatible shape so we can forward Bun's builder to its + // `setup`. + const transformer = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }) as unknown as { + setup: (build: BunPluginBuilder) => void; + }; + + return { + name: 'sentry-orchestrion', + setup(build: BunPluginBuilder): void { + // Inject a banner so the bundled output sets `bundler: true` at boot. + // `config` is the `Bun.build` config and is present when this plugin + // is passed to `Bun.build({ plugins: [...] })`. + if (build.config) { + const existing = build.config.banner ?? ''; + build.config.banner = existing ? `${existing}\n${BUNDLER_MARKER_BANNER}` : BUNDLER_MARKER_BANNER; + + // Force-bundle every instrumented package. An externalized dependency + // is resolved from `node_modules` at runtime and never passes throug + // the transform's `onLoad`, so its diagnostics_channel calls would + // be silently never injected. Bun has no runtime fallback here, so + // bundling is the only injection path. + build.config.external = withoutInstrumentedExternals(build.config.external); + } + + // Delegate to the upstream code-transformer, which registers the `onLoad` + // hook that does the actual channel injection. + transformer.setup(build); + }, + }; +} diff --git a/packages/bun/tsconfig.json b/packages/bun/tsconfig.json index dcbef254b942..79b619df8fa8 100644 --- a/packages/bun/tsconfig.json +++ b/packages/bun/tsconfig.json @@ -5,6 +5,8 @@ "compilerOptions": { // package-specific options - "types": ["bun-types"] + "types": ["bun-types"], + "module": "nodenext", + "moduleResolution": "nodenext" } } diff --git a/packages/node/package.json b/packages/node/package.json index 33f3ce6f39ad..914b882bed4b 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -52,17 +52,6 @@ "require": { "default": "./build/cjs/preload.js" } - }, - "./orchestrion": { - "import": { - "default": "./build/orchestrion/import-hook.mjs" - } - }, - "./orchestrion/vite": { - "import": { - "types": "./build/types/orchestrion/bundler/vite.d.ts", - "default": "./build/esm/orchestrion/bundler/vite.js" - } } }, "typesVersions": { @@ -123,7 +112,8 @@ "{projectRoot}/build/cjs", "{projectRoot}/build/npm/esm", "{projectRoot}/build/npm/cjs", - "{projectRoot}/build/orchestrion" + "{projectRoot}/build/import-hook.mjs", + "{projectRoot}/build/loader-hook.mjs" ] } } diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index ac547f786201..3f6d1b28bf93 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -1,32 +1,15 @@ -import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils'; -// EXPERIMENTAL — orchestrion.js runtime hook. A tiny hand-written `.mjs` shim -// that the user references via `node --import @sentry/node/orchestrion`. It -// installs both the ESM loader and a `Module.prototype._compile` patch, so it -// also covers CJS-internal `require()` calls — no separate `--require` hook is -// needed. We pass it through rollup only to copy it into `build/` at the path -// the package.json `exports` map expects; `external: /.*/` keeps every import -// (e.g. `@sentry/server-utils/orchestrion/import-hook`) as a runtime -// resolution against the installed package. -const orchestrionRuntimeHooks = [ - defineConfig({ - input: 'src/orchestrion/runtime/import-hook.mjs', - external: /.*/, - output: { format: 'esm', file: 'build/orchestrion/import-hook.mjs' }, - }), -]; - export default [ - ...makeOtelLoaders('./build', 'otel'), - ...orchestrionRuntimeHooks, + // `injectDiagnosticsChannel` makes the generated `@sentry/node/import` hook + // also register the diagnostics-channel injection, so `node --import + // @sentry/node/import app.js` injects the channels unconditionally (they are + // only subscribed to when the app opts in via + // `experimentalUseDiagnosticsChannelInjection()`). + ...makeOtelLoaders('./build', 'otel', { injectDiagnosticsChannel: true }), ...makeNPMConfigVariants( makeBaseNPMConfig({ - // `src/orchestrion/bundler/vite.ts` is loaded via the dedicated - // `@sentry/node/orchestrion/vite` subpath export and is not reachable from - // `src/index.ts`, so we list it as a separate entrypoint to guarantee it - // ends up in build/esm and build/cjs. - entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts', 'src/orchestrion/bundler/vite.ts'], + entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts'], packageSpecificConfig: { external: [/^@sentry\/opentelemetry/], output: { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index cff78492a37f..df90fd85e755 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -47,7 +47,7 @@ export { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations, } from './sdk'; -export { _experimentalSetupOrchestrion, mysqlChannelIntegration } from './orchestrion'; +export { experimentalUseDiagnosticsChannelInjection } from './sdk/experimentalUseDiagnosticsChannelInjection'; export { initOpenTelemetry, preloadOpenTelemetry } from './sdk/initOtel'; export { getAutoPerformanceIntegrations } from './integrations/tracing'; diff --git a/packages/node/src/orchestrion/bundler/vite.ts b/packages/node/src/orchestrion/bundler/vite.ts deleted file mode 100644 index 0939415fcd5f..000000000000 --- a/packages/node/src/orchestrion/bundler/vite.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Re-export of the shared orchestrion Vite plugin. The implementation lives in -// `@sentry/server-utils`; this file preserves the -// `@sentry/node/orchestrion/vite` subpath export. ESM-only, matching the -// upstream `@apm-js-collab/code-transformer-bundler-plugins` package. -export { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; diff --git a/packages/node/src/orchestrion/index.ts b/packages/node/src/orchestrion/index.ts deleted file mode 100644 index 34b63a9c8076..000000000000 --- a/packages/node/src/orchestrion/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { _experimentalSetupOrchestrion } from './setup'; -export type { ExperimentalSetupOrchestrionOptions } from './setup'; -export { mysqlChannelIntegration } from '@sentry/server-utils/orchestrion'; diff --git a/packages/node/src/orchestrion/runtime/import-hook.mjs b/packages/node/src/orchestrion/runtime/import-hook.mjs deleted file mode 100644 index 36d06e8622e3..000000000000 --- a/packages/node/src/orchestrion/runtime/import-hook.mjs +++ /dev/null @@ -1,8 +0,0 @@ -// EXPERIMENTAL — entry point for `node --import @sentry/node/orchestrion app.js`. -// -// Delegates to the shared orchestrion runtime hook in -// `@sentry/server-utils`, which registers the orchestrion ESM loader -// (and CJS `Module.prototype._compile` patch) with the central instrumentation -// config and sets `globalThis.__SENTRY_ORCHESTRION__.runtime`. Kept as a thin -// node-resident shim so the `@sentry/node/orchestrion` subpath keeps working. -import '@sentry/server-utils/orchestrion/import-hook'; diff --git a/packages/node/src/orchestrion/setup.ts b/packages/node/src/orchestrion/setup.ts deleted file mode 100644 index 2d5b47b8f3e0..000000000000 --- a/packages/node/src/orchestrion/setup.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { debug } from '@sentry/core'; -import type { SetupOrchestrionOptions } from '@sentry/server-utils/orchestrion'; -import { setupOrchestrion } from '@sentry/server-utils/orchestrion'; -import type { NodeClient } from '@sentry/node-core'; -import { DEBUG_BUILD } from '../debug-build'; - -export type ExperimentalSetupOrchestrionOptions = SetupOrchestrionOptions; - -/** - * EXPERIMENTAL — wires up orchestrion-driven channel integrations. - * - * Must be called after `Sentry.init({ _experimentalUseOrchestrion: true })`, with - * the client returned by `init()`: - * - * ```ts - * const client = Sentry.init({ dsn: '…', _experimentalUseOrchestrion: true }); - * _experimentalSetupOrchestrion(client); - * ``` - * - * This is the ONLY exported entry into the orchestrion code path. Bundlers can - * statically determine that apps which never import this drop the entire - * `orchestrion/` subtree from their output — that is the tree-shaking guarantee. - * - * The actual implementation lives in `@sentry/server-utils`; this Node - * wrapper only adds the experimental opt-in check tied to `NodeOptions`. - */ -export function _experimentalSetupOrchestrion( - client: NodeClient | undefined, - options: ExperimentalSetupOrchestrionOptions = {}, -): void { - // Node-specific: verify the user remembered to set the experimental flag on - // init(), which is what makes `init()` skip the OTel integrations these - // channel-based ones replace. Without it, both systems instrument the same - // library and produce duplicate spans. - if (client && !(client.getOptions() as { _experimentalUseOrchestrion?: boolean })._experimentalUseOrchestrion) { - DEBUG_BUILD && - debug.warn( - '[Sentry] _experimentalSetupOrchestrion() called but Sentry.init() was not given ' + - '`_experimentalUseOrchestrion: true` — it will use default instrumentation instead of ' + - 'channel-based instrumentation. Add the flag to Sentry.init().', - ); - } - - setupOrchestrion(client, options); -} diff --git a/packages/node/src/sdk/diagnosticsChannelInjection.ts b/packages/node/src/sdk/diagnosticsChannelInjection.ts new file mode 100644 index 000000000000..9f51c053d30a --- /dev/null +++ b/packages/node/src/sdk/diagnosticsChannelInjection.ts @@ -0,0 +1,56 @@ +import type { Integration } from '@sentry/core'; + +/** + * The orchestrion-driven pieces, resolved lazily by the opt-in loader. + * + * IMPORTANT: this module (and everything `init()` imports) must NOT reference + * the orchestrion code (`@sentry/server-utils/orchestrion/*`). The only + * reference lives inside `experimentalUseDiagnosticsChannelInjection()` (a + * separate module, reachable solely through that public export). That's the + * tree-shaking boundary: if an app never calls the opt-in function, then a + * bundler drops the entire orchestrion subtree, including its transitive + * dependencies, while an app that does call it gets it bundled + * normally. + */ +export interface DiagnosticsChannelInjection { + /** Channel-based integrations to register, replacing their OTel equivalents. */ + integrations: Integration[]; + /** OTel integration names these replace; filtered out of the default set. */ + replacedOtelIntegrationNames: string[]; + /** Installs the module hooks that inject the diagnostics channels. */ + register: () => void; + /** Warns (DEBUG only) about missing or doubled channel injection. */ + detect: () => void; +} + +let loader: (() => DiagnosticsChannelInjection) | undefined; +let cached: DiagnosticsChannelInjection | undefined; + +/** + * Set by `experimentalUseDiagnosticsChannelInjection()`. The loader + * is the only thing that pulls in the orchestrion modules; see + * {@link DiagnosticsChannelInjection) re tree-shaking concerns this addresses. + * + * @internal + */ +export function setDiagnosticsChannelInjectionLoader(load: () => DiagnosticsChannelInjection): void { + loader = load; +} + +/** Whether `experimentalUseDiagnosticsChannelInjection()` was called. */ +export function isDiagnosticsChannelInjectionEnabled(): boolean { + return !!loader; +} + +/** + * Resolve and memoize the orchestrion pieces. This is what actually loads + * the orchestrion modules. Returns `undefined` if the app never opted in. + * Callers gate this on span recording, so the modules load only when both + * opted in and tracing is enabled. + */ +export function resolveDiagnosticsChannelInjection(): DiagnosticsChannelInjection | undefined { + if (!loader) { + return undefined; + } + return (cached ??= loader()); +} diff --git a/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts new file mode 100644 index 000000000000..d51f2d86a610 --- /dev/null +++ b/packages/node/src/sdk/experimentalUseDiagnosticsChannelInjection.ts @@ -0,0 +1,47 @@ +import { mysqlChannelIntegration, detectOrchestrionSetup } from '@sentry/server-utils/orchestrion'; +import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; +import type { DiagnosticsChannelInjection } from './diagnosticsChannelInjection'; +import { setDiagnosticsChannelInjectionLoader } from './diagnosticsChannelInjection'; + +/** + * EXPERIMENTAL: opt into diagnostics-channel-based auto-instrumentation. + * + * Call this BEFORE `Sentry.init()`: + * + * ```ts + * import * as Sentry from '@sentry/node'; + * + * Sentry.experimentalUseDiagnosticsChannelInjection(); + * Sentry.init({ + * dsn: '__DSN__', + * // other settings... + * }); + * ``` + * + * When this has been called AND span recording is enabled, `Sentry.init()` + * uses the diagnostics-channel-injection-based integrations instead of the + * OpenTelemetry ones, and installs the module hooks that inject the channels + * (so libraries imported after `init()` publish the channel events). + * + * This is a standalone function rather than an `init()` option so that a + * bundler drops all of it (and its transitive deps) when this function isn't + * called. `init()` reads the loader registered below. + * + * An app that DOES call it gets the orchestrion code bundled as intended. + * + * In an unbundled (server-side runtime) app this eagerly loads only the small + * subscriber/channel modules; the heavy code-transform dependencies stay lazy + * inside `register()` and load only when injection actually runs. + * + * @experimental May change or be removed in any release. + */ +export function experimentalUseDiagnosticsChannelInjection(): void { + setDiagnosticsChannelInjectionLoader( + (): DiagnosticsChannelInjection => ({ + integrations: [mysqlChannelIntegration()], + replacedOtelIntegrationNames: ['Mysql'], + register: registerDiagnosticsChannelInjection, + detect: detectOrchestrionSetup, + }), + ); +} diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 49632b8c3998..72a563bf958c 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -10,6 +10,10 @@ import { httpIntegration } from '../integrations/http'; import { nativeNodeFetchIntegration } from '../integrations/node-fetch'; import { getAutoPerformanceIntegrations } from '../integrations/tracing'; import type { NodeOptions } from '../types'; +import { + isDiagnosticsChannelInjectionEnabled, + resolveDiagnosticsChannelInjection, +} from './diagnosticsChannelInjection'; import { initOpenTelemetry } from './initOtel'; /** @@ -24,19 +28,6 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { .concat(httpIntegration(), nativeNodeFetchIntegration()); } -/** - * Names of OTel-based default integrations that the orchestrion experiment - * replaces with channel-based equivalents. When - * `_experimentalUseOrchestrion: true` is set on `Sentry.init()`, these are - * filtered out of the default integration list so the two systems don't both - * instrument the same library and produce duplicate spans. - * - * Kept as a plain string set (instead of importing the orchestrion integrations - * themselves) so the orchestrion code path stays tree-shakable: `init()` never - * pulls in anything from `server-utils/orchestrion/*`. - */ -const ORCHESTRION_REPLACED_INTEGRATIONS = new Set(['Mysql']); - /** Get the default integrations for the Node SDK. */ export function getDefaultIntegrations(options: Options): Integration[] { const integrations: Integration[] = [ @@ -48,8 +39,21 @@ export function getDefaultIntegrations(options: Options): Integration[] { ...(hasSpansEnabled(options) ? getAutoPerformanceIntegrations() : []), ]; - if ((options as NodeOptions)._experimentalUseOrchestrion) { - return integrations.filter(i => !ORCHESTRION_REPLACED_INTEGRATIONS.has(i.name)); + // When the app opted into diagnostics-channel injection (via + // `experimentalUseDiagnosticsChannelInjection()`) AND span recording is + // enabled, swap the channel-based integrations in place of OTel equivalents + // so the two don't both instrument the same library. + // + // Every channel-based integration we ship today is a 1:1 replacement for an + // OTel performance/tracing integration and produces nothing but spans (those + // only come from `getAutoPerformanceIntegrations()` above), so it's gated on + // span recording. + if (isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options)) { + const dci = resolveDiagnosticsChannelInjection(); + if (dci) { + const replaced = new Set(dci.replacedOtelIntegrationNames); + return [...integrations.filter(i => !replaced.has(i.name)), ...dci.integrations]; + } } return integrations; } @@ -70,6 +74,22 @@ function _init( ): NodeClient | undefined { applySdkMetadata(options, 'node'); + // EXPERIMENTAL: diagnostics-channel injection, opted into via + // `experimentalUseDiagnosticsChannelInjection()`. Gated on span recording to + // match the OTel integrations it replaces. With tracing off there are no + // channel subscribers, so injecting is pointless work. `resolve...()` is + // memoized, so `getDefaultIntegrations()` (below) sees the same instance. + const dci = + isDiagnosticsChannelInjectionEnabled() && hasSpansEnabled(options) + ? resolveDiagnosticsChannelInjection() + : undefined; + + // Install the channel-injection hooks as early as possible, before the app + // imports its instrumented modules. + if (dci) { + dci.register(); + } + const client = initNodeCore({ ...options, // Only use Node SDK defaults if none provided @@ -84,6 +104,12 @@ function _init( validateOpenTelemetrySetup(); } + // Warn about missing or doubled channel injection. Runs after the client + // is created so the debug logger is enabled and the warning is emitted. + if (dci) { + dci.detect(); + } + return client; } diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 869fd4098b78..3a0cb1e7e5fc 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -65,23 +65,6 @@ export interface BaseNodeOptions extends OpenTelemetryServerRuntimeOptions { * Defaults to `true`. */ registerEsmLoaderHooks?: boolean; - - /** - * EXPERIMENTAL — opt into the orchestrion.js-based auto-instrumentation path. - * - * When `true`, `Sentry.init()` skips registering the default OTel - * auto-instrumentations for libraries that have a channel-based alternative - * (currently: `mysql`). It does NOT install any channel subscribers on its - * own — call `_experimentalSetupOrchestrion(client)` after `init()` for that. - * - * Splitting the opt-in across two calls keeps the orchestrion code path - * tree-shakable: bundlers can drop `orchestrion/*` from apps that don't - * import `_experimentalSetupOrchestrion`. - * - * Defaults to `false`. The flag name is intentionally underscore-prefixed and - * will be renamed or removed once the experiment graduates. - */ - _experimentalUseOrchestrion?: boolean; } /** diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index c4c847b9512d..b0eb9ecb6476 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -4,9 +4,4 @@ "include": ["src/**/*"], "compilerOptions": {} - // The orchestrion runtime hooks are hand-written `.mjs` / `.cjs` files that - // rollup copies through to `build/orchestrion/` unchanged. We exclude them so - // tsc doesn't try to type-check or emit declarations for them and stays - // focused on the `.ts` sources. - "exclude": ["src/orchestrion/runtime/**/*.mjs", "src/orchestrion/runtime/**/*.cjs"] } diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 088b5d968d41..ccd6a7be66d6 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -18,45 +18,31 @@ "exports": { "./package.json": "./package.json", ".": { - "import": { - "types": "./build/types/index.d.ts", - "default": "./build/esm/index.js" - }, - "require": { - "types": "./build/types/index.d.ts", - "default": "./build/cjs/index.js" - } + "types": "./build/types/index.d.ts", + "import": "./build/esm/index.js", + "require": "./build/cjs/index.js" }, "./orchestrion": { - "import": { - "types": "./build/types/orchestrion/index.d.ts", - "default": "./build/esm/orchestrion/index.js" - }, - "require": { - "types": "./build/types/orchestrion/index.d.ts", - "default": "./build/cjs/orchestrion/index.js" - } + "types": "./build/types/orchestrion/index.d.ts", + "import": "./build/esm/orchestrion/index.js", + "require": "./build/cjs/orchestrion/index.js" }, "./orchestrion/config": { - "import": { - "types": "./build/types/orchestrion/config.d.ts", - "default": "./build/esm/orchestrion/config.js" - }, - "require": { - "types": "./build/types/orchestrion/config.d.ts", - "default": "./build/cjs/orchestrion/config.js" - } + "types": "./build/types/orchestrion/config.d.ts", + "import": "./build/esm/orchestrion/config.js", + "require": "./build/cjs/orchestrion/config.js" + }, + "./orchestrion/register": { + "types": "./build/types/orchestrion/runtime/register.d.ts", + "import": "./build/esm/orchestrion/runtime/register.js", + "require": "./build/cjs/orchestrion/runtime/register.js" }, "./orchestrion/vite": { "types": "./build/types/orchestrion/bundler/vite.d.ts", - "import": { - "default": "./build/esm/orchestrion/bundler/vite.js" - } + "import": "./build/esm/orchestrion/bundler/vite.js" }, "./orchestrion/import-hook": { - "import": { - "default": "./build/orchestrion/import-hook.mjs" - } + "import": "./build/orchestrion/import-hook.mjs" } }, "typesVersions": { @@ -70,6 +56,9 @@ "orchestrion/config": [ "build/types-ts3.8/orchestrion/config.d.ts" ], + "orchestrion/register": [ + "build/types-ts3.8/orchestrion/runtime/register.d.ts" + ], "orchestrion/vite": [ "build/types-ts3.8/orchestrion/bundler/vite.d.ts" ] @@ -81,6 +70,9 @@ "orchestrion/config": [ "build/types/orchestrion/config.d.ts" ], + "orchestrion/register": [ + "build/types/orchestrion/runtime/register.d.ts" + ], "orchestrion/vite": [ "build/types/orchestrion/bundler/vite.d.ts" ] diff --git a/packages/server-utils/rollup.npm.config.mjs b/packages/server-utils/rollup.npm.config.mjs index 161e42eb5c23..21942c9340c8 100644 --- a/packages/server-utils/rollup.npm.config.mjs +++ b/packages/server-utils/rollup.npm.config.mjs @@ -29,6 +29,10 @@ export default [ 'src/index.ts', 'src/orchestrion/index.ts', 'src/orchestrion/config.ts', + // `src/orchestrion/runtime/register.ts` backs the `./orchestrion/register` + // subpath export; the Node SDK `require`s it synchronously from + // `Sentry.init()` to install the channel-injection hooks. + 'src/orchestrion/runtime/register.ts', 'src/orchestrion/bundler/vite.ts', ], packageSpecificConfig: { diff --git a/packages/server-utils/src/orchestrion/bundler/vite.ts b/packages/server-utils/src/orchestrion/bundler/vite.ts index 52032d4ae3a3..4cadc7b40925 100644 --- a/packages/server-utils/src/orchestrion/bundler/vite.ts +++ b/packages/server-utils/src/orchestrion/bundler/vite.ts @@ -2,12 +2,12 @@ // 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` +// This file is published ESM-only via the `@sentry/server-utils/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 +// ESM entry, so attempts to `require('@sentry/server-utils/orchestrion/vite')` will // fail at resolution time rather than producing a half-broken plugin. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -15,10 +15,10 @@ type UnknownPlugin = any; import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; import MagicString from 'magic-string'; -import { SENTRY_INSTRUMENTATIONS } from '../config'; +import { INSTRUMENTED_MODULE_NAMES, 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 +// resolution with TS treating @sentry/server-utils 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. @@ -64,8 +64,6 @@ function bundlerMarkerPlugin(): UnknownPlugin { '', ].join('\n'); - const instrumentedModules = Array.from(new Set(SENTRY_INSTRUMENTATIONS.map(i => i.module.name))); - return { name: 'sentry-orchestrion-marker', enforce: 'pre' as const, @@ -77,7 +75,7 @@ function bundlerMarkerPlugin(): UnknownPlugin { // diagnostics_channel calls never get injected. Vite merges array // `noExternal` entries with the user's config, so we don't overwrite // their additions. - return { ssr: { noExternal: instrumentedModules } }; + return { ssr: { noExternal: INSTRUMENTED_MODULE_NAMES } }; }, renderChunk(code: string, chunk: { isEntry: boolean }): { code: string; map: unknown } | null { if (!chunk.isEntry) return null; diff --git a/packages/server-utils/src/orchestrion/config.ts b/packages/server-utils/src/orchestrion/config.ts index 03985cc64686..35b326fb8eb1 100644 --- a/packages/server-utils/src/orchestrion/config.ts +++ b/packages/server-utils/src/orchestrion/config.ts @@ -33,3 +33,33 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ functionQuery: { expressionName: 'query', kind: 'Auto' }, }, ]; + +/** + * The unique set of package names instrumented by `SENTRY_INSTRUMENTATIONS` + * (e.g. `['mysql']`). + * + * Bundler plugins MUST ensure these are actually bundled rather than + * externalized: an externalized dependency is resolved from `node_modules` at + * runtime and never passes through the code transform's `onLoad`, so its + * diagnostics_channel calls are silently never injected. + */ +export const INSTRUMENTED_MODULE_NAMES: string[] = Array.from(new Set(SENTRY_INSTRUMENTATIONS.map(i => i.module.name))); + +/** + * Returns `external` with any instrumented packages removed, so a bundler that + * uses an "external" denylist (esbuild, Bun, Rollup) still bundles — and thus + * transforms — them. Matches an exact package name (`'mysql'`) or a subpath + * (`'mysql/lib/...'`); wildcard/other patterns are left untouched. `undefined` + * is returned unchanged. + * + * (Vite uses an `ssr.noExternal` allowlist instead, so it consumes + * `INSTRUMENTED_MODULE_NAMES` directly rather than this helper.) + */ +export function withoutInstrumentedExternals(external: readonly string[] | undefined): string[] | undefined { + if (!external) { + return undefined; + } + return external.filter( + entry => !INSTRUMENTED_MODULE_NAMES.some(name => entry === name || entry.startsWith(`${name}/`)), + ); +} diff --git a/packages/server-utils/src/orchestrion/detect.ts b/packages/server-utils/src/orchestrion/detect.ts index 5513650aafc0..60b6070740ba 100644 --- a/packages/server-utils/src/orchestrion/detect.ts +++ b/packages/server-utils/src/orchestrion/detect.ts @@ -7,12 +7,19 @@ declare global { } /** - * Verifies that orchestrion has been setup, either: - * - the runtime hook (`node --import @sentry/node/orchestrion app.js`), OR - * - the bundler plugin (`sentryOrchestrionPlugin()`) + * Verifies that the diagnostics channels have been injected either by the + * runtime `--import` hook (or init-time registration), a bundler plugin, or + * both, and warns if not. * - * Note: do NOT warn in production, only in debug builds, because - * production warnings are reserved for truly critical issues. + * Both injectors being active at once is fine: they operate on disjoint module + * sets (a module is either loaded through Node's loader and transformed by the + * runtime hook, or inlined by the bundler and transformed by the plugin), so + * a single module can't be double-wrapped. A hybrid setup, with some deps + * external and runtime-instrumented, others bundled and plugin-instrumented, + * is fine. + * + * Note: intentionally does NOT warn in production, only in debug builds, + * because production warnings are reserved for truly critical issues. */ export function detectOrchestrionSetup(): void { if (!DEBUG_BUILD) return; @@ -21,14 +28,14 @@ export function detectOrchestrionSetup(): void { const runtime = !!marker?.runtime; const bundler = !!marker?.bundler; - debug.log(`[orchestrion] detect: runtime=${runtime} bundler=${bundler}`); + DEBUG_BUILD && debug.log(`[orchestrion] detect: runtime=${runtime} bundler=${bundler}`); if (!runtime && !bundler) { - debug.warn( - '[Sentry] No orchestrion auto-instrumentation hook detected. Channel-based integrations ' + - '(mysql, …) will not record spans. Either run with ' + - '`node --import @sentry/node/orchestrion app.js`, or add `sentryOrchestrionPlugin()` ' + - 'to your bundler config.', - ); + DEBUG_BUILD && + debug.warn( + '[Sentry] No diagnostics-channel injection detected. Channel-based integrations ' + + '(mysql, …) will not record spans. Make sure the diagnostics channels are injected ' + + 'via the runtime `--import` hook or a bundler plugin before the instrumented modules load.', + ); } } diff --git a/packages/server-utils/src/orchestrion/index.ts b/packages/server-utils/src/orchestrion/index.ts index b0c2ea982c33..dd3ecd0f8f19 100644 --- a/packages/server-utils/src/orchestrion/index.ts +++ b/packages/server-utils/src/orchestrion/index.ts @@ -1,4 +1,2 @@ -export { setupOrchestrion } from './setup'; -export type { SetupOrchestrionOptions } from './setup'; export { detectOrchestrionSetup } from './detect'; export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; diff --git a/packages/server-utils/src/orchestrion/runtime/import-hook.mjs b/packages/server-utils/src/orchestrion/runtime/import-hook.mjs index 63d7a0b624fa..49a08597cfba 100644 --- a/packages/server-utils/src/orchestrion/runtime/import-hook.mjs +++ b/packages/server-utils/src/orchestrion/runtime/import-hook.mjs @@ -1,65 +1,18 @@ -// EXPERIMENTAL — entry point for `node --import @sentry/node/orchestrion app.js`. +// EXPERIMENTAL — diagnostics-channel injection runtime hook. The side-effecting +// `--import` entry (e.g. `node --import @sentry/node/import app.js`) that injects +// the channels unconditionally before the app loads. // -// Registers the orchestrion ESM loader with the central instrumentation config, -// and sets a global marker (`globalThis.__SENTRY_ORCHESTRION__.runtime`) so -// `detectOrchestrionSetup()` at `_experimentalSetupOrchestrion(client)` time can -// see that the runtime hook ran. +// All of the registration logic lives in `register.ts` — it has to be a +// CJS-compatible, dual-built module so `Sentry.init()` can `require()` it +// synchronously, and keeping a single source of truth means the `--import` path +// and the `init()` path can never drift apart. This file is just the +// side-effecting wrapper that invokes it. // // This file is shipped as-is to `build/orchestrion/import-hook.mjs`. Keep it a // single self-contained `.mjs` file with no relative-path imports — `--import` -// resolves it via Node's module resolution against the installed package. +// resolves it (and the bare specifier below) via Node's module resolution +// against the installed package. -import Module from 'node:module'; -import { initialize, resolve, load } from '@apm-js-collab/tracing-hooks/hook-sync.mjs'; -import ModulePatch from '@apm-js-collab/tracing-hooks'; -import { SENTRY_INSTRUMENTATIONS } from '@sentry/server-utils/orchestrion/config'; +import { registerDiagnosticsChannelInjection } from '@sentry/server-utils/orchestrion/register'; -const DEBUG = !!(process.env.DEBUG || process.env.debug || process.env.SENTRY_DEBUG); -// eslint-disable-next-line no-console -const debug = (...args) => DEBUG && console.log('[Sentry orchestrion]', ...args); - -debug('import-hook.mjs loaded, instrumentations:', SENTRY_INSTRUMENTATIONS); - -// detection to decide module loader hooks to use -// registerHooks was present but not stable until 24.13 and 25.1 -const nodeVersion = (process.versions.node ?? '0.0.0').split('.').map(n => parseInt(n, 10)); -// registerHooks available in Deno 2.8.0 -const denoVersion = (globalThis.Deno?.version?.deno ?? '0.0.0').split('.').map(n => parseInt(n, 10)); -const stableSyncHooks = - nodeVersion[0] > 25 || - (nodeVersion[0] === 25 && nodeVersion[1] >= 1) || - (nodeVersion[0] === 24 && nodeVersion[1] >= 13) || - denoVersion[0] > 2 || - (denoVersion[0] === 2 && denoVersion[1] >= 8); - -const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); - -// double-load guard -if (!g.runtime) { - if (typeof Module.registerHooks === 'function' && stableSyncHooks) { - initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); - Module.registerHooks({ resolve, load }); - debug('Module.registerHooks() called for @apm-js-collab/tracing-hooks/hook-sync.mjs'); - } else if (typeof Module.register === 'function' && !globalThis.Bun && !globalThis.Deno) { - Module.register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { - data: { instrumentations: SENTRY_INSTRUMENTATIONS }, - }); - debug('Module.register() called for @apm-js-collab/tracing-hooks/hook.mjs'); - - // ALSO patch `Module.prototype._compile` for the CJS side: when - // an ESM file `import`s a CJS package, Node loads the package's - // entry through the ESM bridge but resolves the package's - // INTERNAL `require()` calls through the CJS machinery. - // Those internal requires never reach the ESM resolve hook, so - // without this patch the file we actually want to instrument is - // loaded untransformed. - // This isn't necessary in the registerHooks case, because Node - // applies those hooks to all CJS and ESM modules. - new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); - } else { - throw new Error('No available API to apply module load hooks'); - } - - // successfully added runtime hooks, set the flag. - g.runtime = true; -} +registerDiagnosticsChannelInjection(); diff --git a/packages/server-utils/src/orchestrion/runtime/register.ts b/packages/server-utils/src/orchestrion/runtime/register.ts new file mode 100644 index 000000000000..48a63a732a5d --- /dev/null +++ b/packages/server-utils/src/orchestrion/runtime/register.ts @@ -0,0 +1,120 @@ +import { debug } from '@sentry/core'; +import { createRequire } from 'node:module'; +import * as Module from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { DEBUG_BUILD } from '../../debug-build'; +import { SENTRY_INSTRUMENTATIONS } from '../config'; + +declare global { + // eslint-disable-next-line no-var + var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; +} + +/** + * Synchronously register the diagnostics-channel injection module hooks. + * + * This is the single source of truth for the registration logic. It is used by: + * - `Sentry.init()` (the Node SDK calls it directly — that's why this module + * must be CJS-compatible / dual-built, so it can be `require()`d synchronously + * before the app's `import`s resolve), and + * - `import-hook.mjs`, the side-effecting `--import` entry, which just calls it. + * + * Libraries imported *after* this call publish the `tracingChannel` events that + * the channel-based integrations subscribe to. + * + * Idempotent via `globalThis.__SENTRY_ORCHESTRION__` — a no-op if the runtime + * `--import` hook or a bundler plugin already injected the channels. + */ +export function registerDiagnosticsChannelInjection(): void { + const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); + + // Already injected (runtime --import hook or bundler plugin) — nothing to do. + if (g.runtime || g.bundler) { + return; + } + + const globalAny = globalThis as { Bun?: unknown; Deno?: { version?: { deno?: string } } }; + const parseVersion = (v: string): number[] => v.split('.').map(n => parseInt(n, 10)); + const nodeVersion = parseVersion(process.versions.node ?? '0.0.0'); + const denoVersion = parseVersion(globalAny.Deno?.version?.deno ?? '0.0.0'); + // `Module.registerHooks` only became stable in Node 24.13 / 25.1 and Deno 2.8. + const stableSyncHooks = + (nodeVersion[0] ?? 0) > 25 || + (nodeVersion[0] === 25 && (nodeVersion[1] ?? 0) >= 1) || + (nodeVersion[0] === 24 && (nodeVersion[1] ?? 0) >= 13) || + (denoVersion[0] ?? 0) > 2 || + (denoVersion[0] === 2 && (denoVersion[1] ?? 0) >= 8); + + // Prefer the builtin `require` if possible. This is present in CommonJS, + // including a bundler's CJS output, so no need to ever have to evaluate + // `import.meta.url` there. + // + // esbuild and friends rewrite `import.meta.url` to `{}` for CJS output, + // which would make `createRequire(undefined)` throw. + // Only use `import.meta.url` in true ESM, where there's no `require` + const nodeRequire = typeof require === 'function' ? require : createRequire(import.meta.url); + + // `Module.registerHooks` / `Module.register` are newer than the @types/node + // we build against, hence the cast. + const mod = Module as unknown as { + registerHooks?: (hooks: unknown) => void; + register?: (specifier: string, options: unknown) => void; + }; + + // runs both at `--import` time and (synchronously) inside `Sentry.init()`, + // so an unguarded throw would either abort startup or make `init()` throw. + // On any failure (e.g. dep resolution, `require(esm)` / Node-compat + // incompatibility) we warn (DEBUG only) and continue without channel + // injection + try { + if (typeof mod.registerHooks === 'function' && stableSyncHooks) { + // Sync hooks cover CJS and ESM, no separate `_compile` patch needed. + // We require() the module here so that we can synchronously load it, + // including from a CommonJS Sentry build, without bundlers pulling in. + // All versions in stableSyncHooks support this. + const { initialize, resolve, load } = nodeRequire('@apm-js-collab/tracing-hooks/hook-sync.mjs') as { + initialize: (opts: { instrumentations: unknown }) => void; + resolve: unknown; + load: unknown; + }; + initialize({ instrumentations: SENTRY_INSTRUMENTATIONS }); + mod.registerHooks({ resolve, load }); + DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.registerHooks()'); + } else if (typeof mod.register === 'function' && !globalAny.Bun && !globalAny.Deno) { + // `Module.register` + the `_compile` patch is Node 18.19–24.12 / 25.0 + // path. Bun/Deno are excluded: they don't support this combination and + // must use the stable `registerHooks` path above (or none at all). + // Resolve the hook to an absolute file URL ourselves so + // `Module.register` needs no `parentURL`, so no need for + // `import.meta.url` polyfilling + mod.register(pathToFileURL(nodeRequire.resolve('@apm-js-collab/tracing-hooks/hook.mjs')).href, { + data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + }); + + // ALSO patch `Module.prototype._compile` for the CJS side: when an ESM + // file `import`s a CJS package, the package's internal `require()` calls + // are resolved through the CJS machinery and never reach the ESM + // register hook, so without this patch the file we want to instrument + // loads untransformed. + const ModulePatch = nodeRequire('@apm-js-collab/tracing-hooks') as new (opts: { instrumentations: unknown }) => { + patch: () => void; + }; + new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); + DEBUG_BUILD && debug.log('[orchestrion] registered diagnostics-channel injection via Module.register()'); + } else { + DEBUG_BUILD && + debug.warn('[Sentry] No available Node API to register diagnostics-channel injection hooks; skipping.'); + return; + } + } catch (error) { + DEBUG_BUILD && + debug.warn( + '[Sentry] Failed to register diagnostics-channel injection hooks; channel-based integrations ' + + 'will not record spans.', + error, + ); + return; + } + + g.runtime = true; +} diff --git a/packages/server-utils/src/orchestrion/setup.ts b/packages/server-utils/src/orchestrion/setup.ts deleted file mode 100644 index b27805c8a9e8..000000000000 --- a/packages/server-utils/src/orchestrion/setup.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { Client, Integration } from '@sentry/core'; -import { debug } from '@sentry/core'; -import { DEBUG_BUILD } from '../debug-build'; -import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; -import { detectOrchestrionSetup } from './detect'; - -export interface SetupOrchestrionOptions { - /** - * Override the default set of channel-based integrations. - * If omitted, all orchestrion integrations shipped by server-utils are added. - */ - integrations?: Integration[]; -} - -/** - * Wires up orchestrion-driven channel integrations on the given client. - * - * Must be called after the SDK's `init()`, with the client returned by it: - * - * ```ts - * const client = Sentry.init({ dsn: '…' }); - * setupOrchestrion(client); - * ``` - * - * This is the only exported entry into `orchestrion/*` that registers - * integrations. Bundlers can statically determine that apps which never import - * it drop the entire `orchestrion/` subtree from their output — that is the - * tree-shaking guarantee. - * - * The orchestrion runtime hook (`--import .../orchestrion/import-hook`) or the - * bundler plugin (`sentryOrchestrionPlugin()`) must be active for the - * channel-based integrations to record spans; `detectOrchestrionSetup()` warns - * if neither ran. - */ -export function setupOrchestrion(client: Client | undefined, options: SetupOrchestrionOptions = {}): void { - DEBUG_BUILD && debug.log('[orchestrion] setupOrchestrion() called'); - - if (!client) { - DEBUG_BUILD && - debug.warn('[Sentry] setupOrchestrion() was called without a client. Pass the value returned by `init()`.'); - return; - } - - detectOrchestrionSetup(); - - const integrations = options.integrations ?? [mysqlChannelIntegration()]; - DEBUG_BUILD && - debug.log( - '[orchestrion] registering channel integrations:', - integrations.map(i => i.name), - ); - for (const integration of integrations) { - client.addIntegration(integration); - } -} diff --git a/yarn.lock b/yarn.lock index 5389064314bf..63312cb92154 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15984,6 +15984,38 @@ esbuild@0.27.3: "@esbuild/win32-ia32" "0.27.3" "@esbuild/win32-x64" "0.27.3" +esbuild@0.28.0, esbuild@^0.28.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" + integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.0" + "@esbuild/android-arm" "0.28.0" + "@esbuild/android-arm64" "0.28.0" + "@esbuild/android-x64" "0.28.0" + "@esbuild/darwin-arm64" "0.28.0" + "@esbuild/darwin-x64" "0.28.0" + "@esbuild/freebsd-arm64" "0.28.0" + "@esbuild/freebsd-x64" "0.28.0" + "@esbuild/linux-arm" "0.28.0" + "@esbuild/linux-arm64" "0.28.0" + "@esbuild/linux-ia32" "0.28.0" + "@esbuild/linux-loong64" "0.28.0" + "@esbuild/linux-mips64el" "0.28.0" + "@esbuild/linux-ppc64" "0.28.0" + "@esbuild/linux-riscv64" "0.28.0" + "@esbuild/linux-s390x" "0.28.0" + "@esbuild/linux-x64" "0.28.0" + "@esbuild/netbsd-arm64" "0.28.0" + "@esbuild/netbsd-x64" "0.28.0" + "@esbuild/openbsd-arm64" "0.28.0" + "@esbuild/openbsd-x64" "0.28.0" + "@esbuild/openharmony-arm64" "0.28.0" + "@esbuild/sunos-x64" "0.28.0" + "@esbuild/win32-arm64" "0.28.0" + "@esbuild/win32-ia32" "0.28.0" + "@esbuild/win32-x64" "0.28.0" + esbuild@^0.15.0: version "0.15.18" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.18.tgz#ea894adaf3fbc036d32320a00d4d6e4978a2f36d" @@ -16160,38 +16192,6 @@ esbuild@^0.25.0, esbuild@^0.25.3, esbuild@^0.25.6: "@esbuild/win32-ia32" "0.25.12" "@esbuild/win32-x64" "0.25.12" -esbuild@^0.28.0: - version "0.28.0" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" - integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== - optionalDependencies: - "@esbuild/aix-ppc64" "0.28.0" - "@esbuild/android-arm" "0.28.0" - "@esbuild/android-arm64" "0.28.0" - "@esbuild/android-x64" "0.28.0" - "@esbuild/darwin-arm64" "0.28.0" - "@esbuild/darwin-x64" "0.28.0" - "@esbuild/freebsd-arm64" "0.28.0" - "@esbuild/freebsd-x64" "0.28.0" - "@esbuild/linux-arm" "0.28.0" - "@esbuild/linux-arm64" "0.28.0" - "@esbuild/linux-ia32" "0.28.0" - "@esbuild/linux-loong64" "0.28.0" - "@esbuild/linux-mips64el" "0.28.0" - "@esbuild/linux-ppc64" "0.28.0" - "@esbuild/linux-riscv64" "0.28.0" - "@esbuild/linux-s390x" "0.28.0" - "@esbuild/linux-x64" "0.28.0" - "@esbuild/netbsd-arm64" "0.28.0" - "@esbuild/netbsd-x64" "0.28.0" - "@esbuild/openbsd-arm64" "0.28.0" - "@esbuild/openbsd-x64" "0.28.0" - "@esbuild/openharmony-arm64" "0.28.0" - "@esbuild/sunos-x64" "0.28.0" - "@esbuild/win32-arm64" "0.28.0" - "@esbuild/win32-ia32" "0.28.0" - "@esbuild/win32-x64" "0.28.0" - escalade@3.2.0, escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"