diff --git a/ORCHESTRIONJS_PLAN.md b/ORCHESTRIONJS_PLAN.md new file mode 100644 index 000000000000..3794535df0aa --- /dev/null +++ b/ORCHESTRIONJS_PLAN.md @@ -0,0 +1,482 @@ +# Orchestrion.js Auto-Instrumentation Experiment Plan + +> Experiment branch: `experiment/orchestrionjs-auto-instrumentation` +> +> Goal: prototype a future where `@sentry/node` does its own auto-instrumentation +> via Node.js [`TracingChannel`](https://nodejs.org/api/diagnostics_channel.html#class-tracingchannel), +> with channel injection driven by [orchestrion.js](https://github.com/nodejs/orchestrion-js) +> instead of OpenTelemetry's `require-in-the-middle` / `import-in-the-middle` machinery. +> +> First target: the `mysql` integration. + +## Background + +Orchestrion-JS is published as three coordinated packages: + +| Package | What it does | We use it for | +| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | +| `@apm-js-collab/code-transformer` | Rust/WASM AST walker. Given an `InstrumentationConfig[]`, returns a `Transformer` that rewrites function bodies to publish to a `TracingChannel`. | Indirectly — via the two below. | +| `@apm-js-collab/tracing-hooks` | Node ESM loader (`register('@apm-js-collab/tracing-hooks/hook.mjs', ..., { data: { instrumentations } })`) + a CJS `ModulePatch` for `--require`. | **Runtime** channel injection. | +| `@apm-js-collab/code-transformer-bundler-plugins` | One plugin per bundler (`/vite`, `/webpack`, `/rollup`, `/esbuild`), all taking the same `{ instrumentations }` object. | **Build-time** channel injection. | + +All three accept the same `InstrumentationConfig` shape: + +```ts +type InstrumentationConfig = { + channelName: string; // diagnostics_channel TracingChannel name + module: { name: string; versionRange: string; filePath: string }; + functionQuery: FunctionQuery; // className+methodName / functionName / expressionName / ... +}; +``` + +This means **one config array** can drive both the runtime hook and every bundler plugin — that is the leverage point this plan is built around. + +## Architectural goals + +1. **Integrations only know channels.** A Sentry integration (e.g. `mysqlIntegration`) subscribes to a published channel name and creates spans. It never imports orchestrion, never knows how the channel got there, and would work identically against a native `diagnostics_channel` that some library already publishes itself. +2. **Single source of truth for orchestrion config.** Channel names + module matchers + function queries live in **one** TypeScript module. Both the runtime hook and the bundler plugin import from it. Adding a new instrumentation = one edit. +3. **Two equally good user paths, one of which must be active.** + - **Bundler path** (preferred when bundling): the user adds `sentryOrchestrionPlugin()` to their `vite.config.ts`. Nothing else. + - **Runtime path** (preferred for unbundled Node servers): the user runs `node --import @sentry/node/orchestrion app.js` (ESM) or `node --require @sentry/node/orchestrion app.js` (CJS). The same import path resolves to the ESM `import-hook.mjs` or the CJS `require-hook.cjs` based on the active loader condition, so the user doesn't have to know which one to pick. +4. **Loud about misconfiguration.** When orchestrion setup runs, the SDK must detect (a) "no orchestrion hook was set up at all" and (b) "both paths ran — code is double-wrapped" and warn clearly. +5. **No mixing with the existing OTel-based init, and tree-shakable.** The opt-in is split into two pieces so users who don't opt in never pull in any orchestrion code: + - A new `_experimentalUseOrchestrion: true` flag on `Sentry.init()` that does the _base_ adjustments — i.e. skip registering the OTel auto-instrumentations that have a channel-based replacement (mysql, …). This is all `init()` itself does; it pulls in zero orchestrion-specific code. + - A new top-level export `_experimentalSetupOrchestrion()` that the user calls **after** `Sentry.init()`. This is where all orchestrion-specific code lives: the channel subscribers, the integration registrations, and the runtime/bundler detection warnings. If the user never calls it, the bundler can drop everything under `orchestrion/` from their bundle. + When the flag is unset (the default), `init()` behaves exactly as today and `_experimentalSetupOrchestrion` — if imported — is a no-op that only warns. Existing users keep using `@opentelemetry/instrumentation-*` integrations untouched. + +## Repository layout + +All new code lives under `packages/node/`. The existing OTel-based mysql integration stays untouched so we can A/B them. + +``` +packages/node/ +├── package.json (NEW subpath exports — see below) +└── src/ + └── orchestrion/ (NEW directory — all experiment code) + ├── index.ts public re-exports for the integrations subdir + ├── setup.ts ★ _experimentalSetupOrchestrion() — the only user-facing entry into this dir + ├── config.ts ★ central InstrumentationConfig[] — single source of truth + ├── channels.ts channel-name string constants (imported by configs AND integrations) + ├── detect.ts globalThis marker + warning logic + ├── runtime/ + │ ├── import-hook.mjs --import target: register() + marker + │ └── require-hook.cjs --require target: ModulePatch.patch() + marker + └── bundler/ + ├── vite.ts sentryOrchestrionVitePlugin() — wraps code-transformer/vite + marker + └── marker-banner.ts shared "inject `globalThis.__SENTRY_ORCHESTRION__.bundler = true`" plugin +packages/node/src/integrations/tracing-channel/ + └── mysql.ts ★ subscribes to channels; creates Sentry spans +``` + +All channel-consumer integrations live together under `integrations/tracing-channel/` — one file per library (`mysql.ts`, future `pg.ts`, `redis.ts`, …). This mirrors the existing `integrations/tracing/` layout for the OTel path, keeps related code visually grouped, and makes the boundary the user wants explicit: a contributor adding a new channel-driven integration edits `orchestrion/config.ts` (one entry) + `integrations/tracing-channel/.ts` (one subscriber) + adds it to the default list in `orchestrion/setup.ts`. Nothing else. + +`orchestrion/setup.ts` is the **only** file under `orchestrion/` that user code imports from at runtime (via the top-level `@sentry/node` re-export of `_experimentalSetupOrchestrion`). Everything else under `orchestrion/` is reachable only transitively through that one entry point — which is what makes the experiment tree-shakable for opted-out users. + +## Central config — the load-bearing file + +`packages/node/src/orchestrion/channels.ts` + +```ts +// String constants shared between config.ts (producer) and integrations (consumer). +// Single source of truth for channel names — keeps the channel string from being +// misspelled in one place and silently never firing. +export const CHANNELS = { + MYSQL_QUERY: 'sentry:mysql:query', +} as const; +``` + +`packages/node/src/orchestrion/config.ts` + +```ts +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import { CHANNELS } from './channels'; + +export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ + { + channelName: CHANNELS.MYSQL_QUERY, + module: { name: 'mysql', versionRange: '>=2.0.0', filePath: 'lib/Connection.js' }, + functionQuery: { className: 'Connection', methodName: 'query', kind: 'Callback' }, + }, + // … future entries: mysql2, pg, redis, etc. One line per instrumented method. +]; +``` + +`config.ts` has **no side effects** — it is the only thing both `runtime/*` and `bundler/*` import. This is what makes it cheap to maintain: adding a new instrumented method is one entry here + one subscriber file. + +## The integration — channel consumer + +`packages/node/src/integrations/tracing-channel/mysql.ts` (sketch): + +```ts +import { channel, tracingChannel } from 'node:diagnostics_channel'; +import { defineIntegration, startSpan, SPAN_STATUS_ERROR } from '@sentry/core'; +import { CHANNELS } from '../../orchestrion/channels'; + +const _mysqlChannelIntegration = (() => { + const queryCh = tracingChannel(CHANNELS.MYSQL_QUERY); + // store per-context state on a WeakMap keyed by the `context` object + // that orchestrion passes to start/end/asyncStart/asyncEnd/error. + const spans = new WeakMap void }>(); + + return { + name: 'MysqlChannel', + setupOnce() { + queryCh.subscribe({ + start(ctx) { + // ctx.arguments contains the original call args — extract SQL for span name. + const sql = String((ctx as any).arguments?.[0] ?? 'mysql.query'); + // startSpan returns synchronously when we pass `{ forceTransaction: false }` semantics; + // for true async correlation we wrap startInactiveSpan + manual end here. + const span = startInactiveSpanForChannel(sql); + spans.set(ctx as object, { + finish: () => span.end(), + }); + }, + error(ctx) { + // pull error from ctx, mark span status + }, + asyncEnd(ctx) { + spans.get(ctx as object)?.finish(); + }, + // end() fires for sync paths; asyncEnd() for callback / promise paths + end(ctx) { + // only finish if asyncEnd hasn't (mysql Connection.query is callback-based — asyncEnd is the one) + }, + }); + }, + }; +}) satisfies IntegrationFn; + +export const mysqlChannelIntegration = defineIntegration(_mysqlChannelIntegration); +``` + +The integration imports **`CHANNELS.MYSQL_QUERY`, not the orchestrion config**. It is unaware orchestrion exists; if some day `mysql` publishes that channel natively we just stop injecting it. + +## Subpath exports + +Add to `packages/node/package.json`: + +```jsonc +"exports": { + // … existing entries … + "./orchestrion": { + // Single subpath, two condition arms — Node picks the right file based on + // whether the user passed `--import` (ESM hook) or `--require` (CJS hook). + "import": { "default": "./build/orchestrion/import-hook.mjs" }, + "require": { "default": "./build/orchestrion/require-hook.cjs" } + }, + "./orchestrion/vite": { + // Vite plugin factory. + "import": { "types": "./build/types/orchestrion/bundler/vite.d.ts", "default": "./build/esm/orchestrion/bundler/vite.js" }, + "require": { "types": "./build/types/orchestrion/bundler/vite.d.ts", "default": "./build/cjs/orchestrion/bundler/vite.js" } + } +} +``` + +End-user friction is minimized: either + +```bash +node --import @sentry/node/orchestrion app.js +``` + +or + +```ts +// vite.config.ts +import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; +export default { plugins: [sentryOrchestrionPlugin()] }; +``` + +No `instrumentations: [...]` array to copy-paste, no channel names to remember. + +## Runtime hook — `--import` ESM target + +`packages/node/src/orchestrion/runtime/import-hook.mjs` + +```js +import { register } from 'node:module'; +import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; + +// 1) Double-wrap guard. Set this BEFORE register() so even if a second --import +// is added, we won't double-register. +const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); +if (g.runtime) { + console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --import. Ignoring the second load.'); +} else { + g.runtime = true; + register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { + data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + }); +} +``` + +`packages/node/src/orchestrion/runtime/require-hook.cjs` + +```js +const ModulePatch = require('@apm-js-collab/tracing-hooks'); +const { SENTRY_INSTRUMENTATIONS } = require('@sentry/node/orchestrion/config'); + +const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); +if (g.runtime) { + console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --require. Ignoring.'); +} else { + g.runtime = true; + new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); +} +``` + +Both files set `globalThis.__SENTRY_ORCHESTRION__.runtime = true`. That marker is how `detect.ts` knows the runtime path is active later. + +## Vite plugin — build-time path + +`packages/node/src/orchestrion/bundler/vite.ts` + +```ts +import codeTransformer from '@apm-js-collab/code-transformer-bundler-plugins/vite'; +import type { Plugin } from 'vite'; +import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; + +export function sentryOrchestrionPlugin(): Plugin[] { + return [ + // 1) Inject the runtime marker into the bundle so detect.ts can see it. + markerPlugin(), + // 2) The actual orchestrion transformer, fed our central config. + codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }), + ]; +} + +function markerPlugin(): Plugin { + // Emits/injects a one-liner into the bundle output: + // globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {}); + // if (globalThis.__SENTRY_ORCHESTRION__.bundler) { console.warn('[Sentry] orchestrion bundler plugin loaded twice'); } + // globalThis.__SENTRY_ORCHESTRION__.bundler = true; + return { + name: 'sentry-orchestrion-marker', + enforce: 'pre', + // Easiest: hook `renderChunk` and prepend to entry chunks. + // Alternative: emit a virtual module + use `banner` config injection. + // To be decided during implementation — both work; the renderChunk approach + // avoids requiring the user to import anything. + }; +} +``` + +**Design decision — where the marker comes from in the bundler path:** +the plugin injects runtime JS into the bundle, not just a build-time flag. Build-time markers (e.g. `define`) are useless to `detect.ts`, which runs at app start. The marker must execute when the bundled app boots. + +## Detection — `detect.ts` + +`packages/node/src/orchestrion/detect.ts` + +```ts +import { logger } from '@sentry/core'; + +declare global { + // eslint-disable-next-line no-var + var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; +} + +export function detectOrchestrionSetup(): void { + const marker = globalThis.__SENTRY_ORCHESTRION__; + const runtime = !!marker?.runtime; + const bundler = !!marker?.bundler; + + if (runtime && bundler) { + logger.warn( + '[Sentry] Detected BOTH the @sentry/node/orchestrion runtime hook AND the bundler plugin. ' + + 'Functions will be instrumented twice and produce duplicate spans. ' + + 'Remove `--import @sentry/node/orchestrion` if you are using the bundler plugin, or vice versa.', + ); + return; + } + + if (!runtime && !bundler) { + logger.warn( + '[Sentry] No auto-instrumentation hook detected. Channel-based integrations (mysql, …) will not record spans. ' + + 'Either run with `node --import @sentry/node/orchestrion app.js`, or add `sentryOrchestrionPlugin()` to your bundler config.', + ); + } +} +``` + +## Two-step user setup — flag on `init()` + `_experimentalSetupOrchestrion()` + +The opt-in is deliberately split so the orchestrion code path stays tree-shakable. `Sentry.init()` only learns about a boolean flag; it does **not** import anything from `orchestrion/`. The orchestrion-specific code only runs if the user explicitly imports and calls `_experimentalSetupOrchestrion()` after `init()`. + +### Step 1 — `_experimentalUseOrchestrion` flag on `NodeOptions` + +```ts +// packages/node-core/src/types.ts (or wherever NodeOptions lives) +export interface NodeOptions extends ClientOptions { + // … existing options … + /** + * EXPERIMENTAL — opt into the orchestrion.js-based auto-instrumentation path. + * When `true`, `Sentry.init()` will skip registering the default OTel + * auto-instrumentations for libraries that have a channel-based alternative + * (mysql, …). It does **not** install any channel subscribers on its own — + * call `_experimentalSetupOrchestrion()` after `init()` for that. + * + * Defaults to `false`. The flag name is intentionally underscore-prefixed and + * will be renamed or removed once the experiment graduates. + */ + _experimentalUseOrchestrion?: boolean; +} +``` + +```ts +// packages/node/src/sdk/index.ts (sketch of the additional lines in init()) +export function init(options: NodeOptions | undefined = {}): NodeClient | undefined { + // … existing init body, with one change: when assembling the default integrations + // list, skip entries whose libraries are covered by the orchestrion experiment. + if (options._experimentalUseOrchestrion) { + defaultIntegrations = defaultIntegrations.filter(i => !ORCHESTRION_REPLACED_INTEGRATIONS.has(i.name)); + } + // … the rest of init() is unchanged, and crucially does NOT import from ../orchestrion/* … +} + +// A tiny string-set constant — no orchestrion code imported. +const ORCHESTRION_REPLACED_INTEGRATIONS = new Set([ + 'Mysql', // matches the existing OTel mysql integration's `name` +]); +``` + +The list of replaced integration names is a plain string set defined alongside `init()` itself — it does not import from `orchestrion/`, so toggling the flag doesn't pull orchestrion code into a user's bundle. + +### Step 2 — `_experimentalSetupOrchestrion()` as a separate export + +```ts +// packages/node/src/orchestrion/setup.ts +import { logger } from '@sentry/core'; +import type { NodeClient } from '../sdk/client'; +import { detectOrchestrionSetup } from './detect'; +import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; + +export interface ExperimentalSetupOrchestrionOptions { + /** + * Override or extend the default set of channel-based integrations. + * If omitted, all orchestrion integrations shipped by @sentry/node are added. + */ + integrations?: Integration[]; +} + +export function _experimentalSetupOrchestrion( + client: NodeClient | undefined, + options: ExperimentalSetupOrchestrionOptions = {}, +): void { + if (!client) { + logger.warn( + '[Sentry] _experimentalSetupOrchestrion() was called without a client. ' + + 'Pass the value returned by `Sentry.init()`.', + ); + return; + } + if (!client.getOptions()._experimentalUseOrchestrion) { + logger.warn( + '[Sentry] _experimentalSetupOrchestrion() called but Sentry.init() was not given ' + + '`_experimentalUseOrchestrion: true`. The default OTel integrations are still active — ' + + 'you will get duplicate spans. Add the flag to Sentry.init().', + ); + } + + // 1) Verify the runtime/bundler hook actually ran. + detectOrchestrionSetup(); + + // 2) Register the channel-based integrations on the passed-in client. + const integrations = options.integrations ?? [ + mysqlChannelIntegration(), + // … future channel integrations default-on here. + ]; + for (const integration of integrations) { + client.addIntegration(integration); + } +} +``` + +Taking the client as an explicit argument (instead of pulling it from `getClient()`) makes the call order unambiguous, avoids surprises when multiple clients exist (tests, multi-tenant setups), and gives TypeScript users a clear type on what `_experimentalSetupOrchestrion` operates against. + +`_experimentalSetupOrchestrion` is the **only** export through which orchestrion-specific code is reachable from a user's app graph. Bundlers can statically determine that an app which never imports it has no live edges into `orchestrion/`, so all the channel subscribers, detection code, and integration factories drop out. + +The function is also where we sanity-check the user's setup: it warns if `init()` wasn't told about the flag, and it runs `detectOrchestrionSetup()` to confirm exactly one of the runtime / bundler paths is active. + +### Usage + +```ts +import * as Sentry from '@sentry/node'; +import { _experimentalSetupOrchestrion } from '@sentry/node'; + +const client = Sentry.init({ + dsn: '…', + _experimentalUseOrchestrion: true, +}); + +_experimentalSetupOrchestrion(client); +// Or, to override which integrations are registered: +// _experimentalSetupOrchestrion(client, { integrations: [mysqlChannelIntegration()] }); +``` + +This keeps the experiment self-contained — no parallel `init` function, no separate entry point — while still being fully tree-shakable for users who don't opt in. + +## End-user surface + +**Bundled app (Vite):** + +```ts +// vite.config.ts +import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; +export default { plugins: [sentryOrchestrionPlugin()] }; + +// app.ts +import * as Sentry from '@sentry/node'; +import { _experimentalSetupOrchestrion } from '@sentry/node'; + +const client = Sentry.init({ + dsn: '…', + _experimentalUseOrchestrion: true, +}); +_experimentalSetupOrchestrion(client); +``` + +**Unbundled Node ESM app:** + +```bash +node --import @sentry/node/orchestrion app.js +``` + +```ts +// app.ts — same two-step init + setup as above, no plugin needed. +``` + +**Unbundled Node CJS app:** + +```bash +node --require @sentry/node/orchestrion app.js +``` + +If the user does **neither** runtime nor bundler hook, `_experimentalSetupOrchestrion()` warns at startup. If they do **both**, it also warns. If they set `_experimentalUseOrchestrion: true` but never call `_experimentalSetupOrchestrion()`, they get no channel-based spans and no OTel-based spans for the replaced libraries — also a warning case (emitted lazily the first time the client tries to flush, since we can't observe the missing call directly at `init()` time). TBD whether this third warning is worth the complexity. + +## Double-wrap analysis — what orchestrion does and doesn't protect against + +| Failure mode | Who catches it | How | +| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| Bundler plugin added twice in the same Vite config | orchestrion's bundler plugin itself? **Unverified** — needs a test during the spike. If not, our marker plugin warns. | `__SENTRY_ORCHESTRION__.bundler` already true at second plugin invocation. | +| `--import @sentry/node/orchestrion` passed twice on CLI | Our hook | Marker set before `register()`, second load short-circuits with a warn. | +| Bundler plugin + runtime hook both run | Our `detect.ts` at `Sentry.init` | Warn — this is the most likely real-world footgun, since a Vite-built app may still launch with a stray `--import` from prod tooling. | +| Neither runs | Our `detect.ts` | Warn — user thinks Sentry instruments their DB but it silently doesn't. | +| Orchestrion patches a function the user already patched manually | **Out of scope** for this experiment. Document it. | n/a | + +## Implementation phases + +1. **Plumbing first** — branch (done), add the three orchestrion packages to `packages/node/package.json` as `dependencies`, create `orchestrion/` directory with empty `config.ts`, `channels.ts`, `detect.ts`. No real channels yet. Build passes. +2. **Runtime path end-to-end** — wire `import-hook.mjs` + the rollup config in `packages/node/rollup.npm.config.mjs` to emit it. Verify with a throwaway script that has _one_ instrumentation in `config.ts` (a function in a tiny local fixture module) that publishing fires. +3. **Mysql channel integration** — write `integrations/tracing-channel/mysql.ts`. Plug into a `dev-packages/node-integration-tests/` scenario that runs against a real mysql container, asserts spans. +4. **Bundler path** — add `sentryOrchestrionPlugin()` for Vite, including marker injection. Test in a small fixture under `dev-packages/e2e-tests/` (Vite-built Node entry hitting mysql). +5. **Detection + setup entry point** — add `detect.ts` + `setup.ts` (exporting `_experimentalSetupOrchestrion`), wire the `_experimentalUseOrchestrion` flag into `init()` so it filters the default integrations, and re-export `_experimentalSetupOrchestrion` from the package root. Test all four hook states (runtime only / bundler only / both / neither) via the e2e fixtures, plus a bundler-size assertion that not importing `_experimentalSetupOrchestrion` drops `orchestrion/*` from the output. +6. **Decide & write up** — capture findings in a follow-up doc: does this beat the OTel path on (a) bundle size, (b) cold start, (c) reliability, (d) maintenance cost? + +## Open questions to settle during the spike + +- **Does `@apm-js-collab/tracing-hooks` ship its own double-register guard?** Cheap to test — register twice, see if it complains. If yes, our runtime-path warning is belt-and-suspenders; if no, our marker is the only guard. +- **Does `code-transformer-bundler-plugins/vite` work cleanly with Vite's SSR / library modes?** Our likely consumers (Next, Nuxt, SvelteKit server bundles) all go through SSR pipelines. +- **`TracingChannel` callback context shape** — orchestrion docs describe the channel name + the `kind` (Sync/Async/Callback) but not the exact `context` payload (what `arguments`, `this`, `result`, `error` keys are present). Needs a quick `subscribe` + `console.log` smoke test before writing `mysql.ts`. +- **CJS vs ESM coverage** — does the runtime require-hook see ESM imports of mysql? Does the import-hook see CJS requires? The mysql package itself is CJS, but the consuming app may be either. Likely we need to wire both hooks together in `--import @sentry/node/orchestrion` (the ESM hook also patches CJS via the require-hook path). +- **How do we keep `SENTRY_INSTRUMENTATIONS` tree-shakable?** If a user only wants mysql, the unused configs shouldn't ship. Probably each integration owns its config fragment and `config.ts` aggregates via barrel import — TBD during phase 1. diff --git a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts index 6ae689b80da3..5a78ba1f8a72 100644 --- a/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts +++ b/dev-packages/e2e-tests/test-applications/node-exports-test-app/scripts/consistentExports.ts @@ -23,6 +23,8 @@ const NODE_EXPORTS_IGNORE = [ 'preloadOpenTelemetry', // Internal helper only needed within integrations (e.g. bunRuntimeMetricsIntegration) '_INTERNAL_normalizeCollectionInterval', + // Experimental + '_experimentalSetupOrchestrion', ]; const nodeExports = Object.keys(SentryNode).filter(e => !NODE_EXPORTS_IGNORE.includes(e)); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs new file mode 100644 index 000000000000..385f79a52bd9 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/instrument-orchestrion.mjs @@ -0,0 +1,11 @@ +// Loaded BEFORE the scenario (via `--import` in ESM mode, `--require` in CJS +// mode). Pulling in `@sentry/node/orchestrion` triggers the runtime channel +// injection: the ESM build calls `module.register()` to install the +// orchestrion loader; the CJS build patches `Module.prototype._compile`. +// +// `createEsmAndCjsTests` converts this file's `import` statements to `require()` +// for the CJS variant by string substitution — the import specifier is +// unchanged. The `./orchestrion` subpath export resolves to a different file +// under the two conditions (`import` → import-hook.mjs, `require` → +// require-hook.cjs), so the same instrument file works in both modes. +import '@sentry/node/orchestrion'; diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs b/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs new file mode 100644 index 000000000000..2794dd814827 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/scenario-orchestrion.mjs @@ -0,0 +1,39 @@ +import { loggingTransport } from '@sentry-internal/node-integration-tests'; +import * as Sentry from '@sentry/node'; +import { _experimentalSetupOrchestrion } from '@sentry/node'; +import mysql from 'mysql'; + +// EXPERIMENTAL — verifies the orchestrion runtime hook path for `mysql`. +// +// Pre-conditions set up by `instrument.mjs` (loaded via `--import` or `--require` +// before this file runs): orchestrion has rewritten `mysql/lib/Connection.js` +// so `Connection.prototype.query` publishes to `node:diagnostics_channel`. +// `_experimentalSetupOrchestrion()` below subscribes our channel-based mysql +// integration to those publications. + +const client = Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + release: '1.0', + tracesSampleRate: 1.0, + transport: loggingTransport, + _experimentalUseOrchestrion: true, +}); + +_experimentalSetupOrchestrion(client); + +// Stop the process from exiting before the transaction is sent. +setInterval(() => {}, 1000); + +const connection = mysql.createConnection({ + user: 'root', + password: 'docker', +}); + +Sentry.startSpanManual({ op: 'transaction', name: 'Test Transaction' }, span => { + connection.query('SELECT 1 + 1 AS solution', () => { + connection.query('SELECT NOW()', ['1', '2'], () => { + span.end(); + connection.end(); + }); + }); +}); diff --git a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts index adb35f1c0025..22c573752cc2 100644 --- a/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/mysql/test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, test } from 'vitest'; -import { cleanupChildProcesses, createRunner } from '../../../utils/runner'; +import { cleanupChildProcesses, createEsmAndCjsTests, createRunner } from '../../../utils/runner'; describe('mysql auto instrumentation', () => { afterAll(() => { @@ -104,4 +104,36 @@ describe('mysql auto instrumentation', () => { .start() .completed(); }); + + createEsmAndCjsTests(__dirname, 'scenario-orchestrion.mjs', 'instrument-orchestrion.mjs', (createRunner, test) => { + test('records db spans for `Connection.query` via the channel-based integration', { timeout: 75_000 }, async () => { + const EXPECTED_TRANSACTION = { + transaction: 'Test Transaction', + spans: expect.arrayContaining([ + expect.objectContaining({ + description: 'SELECT 1 + 1 AS solution', + op: 'db', + origin: 'auto.db.orchestrion.mysql', + data: expect.objectContaining({ + 'db.system.name': 'mysql', + 'db.query.text': 'SELECT 1 + 1 AS solution', + 'db.operation.name': 'SELECT', + }), + }), + expect.objectContaining({ + description: 'SELECT NOW()', + op: 'db', + origin: 'auto.db.orchestrion.mysql', + data: expect.objectContaining({ + 'db.system.name': 'mysql', + 'db.query.text': 'SELECT NOW()', + 'db.operation.name': 'SELECT', + }), + }), + ]), + }; + + await createRunner().expect({ transaction: EXPECTED_TRANSACTION }).start().completed(); + }); + }); }); diff --git a/packages/node/package.json b/packages/node/package.json index 5e74867a0a88..2ba15fcb3fb4 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -52,6 +52,30 @@ "require": { "default": "./build/cjs/preload.js" } + }, + "./orchestrion": { + "import": { + "default": "./build/orchestrion/import-hook.mjs" + }, + "require": { + "default": "./build/orchestrion/require-hook.cjs" + } + }, + "./orchestrion/config": { + "import": { + "types": "./build/types/orchestrion/config.d.ts", + "default": "./build/esm/orchestrion/config.js" + }, + "require": { + "types": "./build/types/orchestrion/config.d.ts", + "default": "./build/cjs/orchestrion/config.js" + } + }, + "./orchestrion/vite": { + "import": { + "types": "./build/types/orchestrion/bundler/vite.d.ts", + "default": "./build/esm/orchestrion/bundler/vite.js" + } } }, "typesVersions": { @@ -65,6 +89,9 @@ "access": "public" }, "dependencies": { + "@apm-js-collab/code-transformer": "^0.13.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.1.0", + "@apm-js-collab/tracing-hooks": "^0.7.0", "@opentelemetry/api": "^1.9.1", "@opentelemetry/core": "^2.6.1", "@opentelemetry/instrumentation": "^0.214.0", @@ -96,7 +123,16 @@ "import-in-the-middle": "^3.0.0" }, "devDependencies": { - "@types/node": "^18.19.1" + "@types/node": "^18.19.1", + "vite": "^5.0.0" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } }, "scripts": { "build": "run-p build:transpile build:types", diff --git a/packages/node/rollup.npm.config.mjs b/packages/node/rollup.npm.config.mjs index 741c6ec27fe5..de826931691a 100644 --- a/packages/node/rollup.npm.config.mjs +++ b/packages/node/rollup.npm.config.mjs @@ -1,10 +1,42 @@ +import { defineConfig } from 'rollup'; import { makeBaseNPMConfig, makeNPMConfigVariants, makeOtelLoaders } from '@sentry-internal/rollup-utils'; +// EXPERIMENTAL — orchestrion.js runtime hooks. Each one is a tiny hand-written +// `.mjs`/`.cjs` shim that the user references via `node --import` or +// `node --require`. We pass them through rollup only to copy them into `build/` +// at the path the package.json `exports` map expects; `external: /.*/` keeps +// every import (e.g. `@sentry/node/orchestrion/config`) as a runtime resolution +// against the installed package. +const orchestrionRuntimeHooks = [ + defineConfig({ + input: 'src/orchestrion/runtime/import-hook.mjs', + external: /.*/, + output: { format: 'esm', file: 'build/orchestrion/import-hook.mjs' }, + }), + defineConfig({ + input: 'src/orchestrion/runtime/require-hook.cjs', + external: /.*/, + output: { format: 'cjs', file: 'build/orchestrion/require-hook.cjs', strict: false }, + }), +]; + export default [ ...makeOtelLoaders('./build', 'otel'), + ...orchestrionRuntimeHooks, ...makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/init.ts', 'src/preload.ts'], + // `src/orchestrion/config.ts` and `src/orchestrion/bundler/vite.ts` are + // loaded via dedicated subpath exports (`@sentry/node/orchestrion/config`, + // `@sentry/node/orchestrion/vite`) — neither is reachable from `src/index.ts`, + // so we list them as separate entrypoints to guarantee they end up in + // build/esm and build/cjs. + entrypoints: [ + 'src/index.ts', + 'src/init.ts', + 'src/preload.ts', + 'src/orchestrion/config.ts', + 'src/orchestrion/bundler/vite.ts', + ], packageSpecificConfig: { external: [/^@sentry\/opentelemetry/], output: { diff --git a/packages/node/src/index.ts b/packages/node/src/index.ts index 3bd5e1edba1c..0023d8d9ec26 100644 --- a/packages/node/src/index.ts +++ b/packages/node/src/index.ts @@ -46,6 +46,7 @@ export { getDefaultIntegrationsWithoutPerformance, initWithoutDefaultIntegrations, } from './sdk'; +export { _experimentalSetupOrchestrion, mysqlChannelIntegration } from './orchestrion'; export { initOpenTelemetry, preloadOpenTelemetry } from './sdk/initOtel'; export { getAutoPerformanceIntegrations } from './integrations/tracing'; diff --git a/packages/node/src/integrations/tracing-channel/mysql.ts b/packages/node/src/integrations/tracing-channel/mysql.ts new file mode 100644 index 000000000000..58d2bd88838b --- /dev/null +++ b/packages/node/src/integrations/tracing-channel/mysql.ts @@ -0,0 +1,140 @@ +import { tracingChannel } from 'node:diagnostics_channel'; +import type { IntegrationFn, Span } from '@sentry/core'; +import { debug, defineIntegration, SPAN_STATUS_ERROR, startInactiveSpan } from '@sentry/core'; +import { addOriginToSpan } from '@sentry/node-core'; +import { DEBUG_BUILD } from '../../debug-build'; +import { CHANNELS } from '../../orchestrion/channels'; + +const INTEGRATION_NAME = 'Mysql'; + +// OpenTelemetry semantic-conventions strings. We inline them rather than +// importing `@opentelemetry/semantic-conventions` to keep this integration's +// dependency surface free of OTel — orchestrion's whole point is to step away +// from the OTel auto-instrumentation stack. +const ATTR_DB_SYSTEM_NAME = 'db.system.name'; +const ATTR_DB_QUERY_TEXT = 'db.query.text'; +const ATTR_DB_OPERATION_NAME = 'db.operation.name'; + +const SQL_OPERATION_REGEX = + /^\s*(SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|TRUNCATE|REPLACE|MERGE|CALL|SHOW|USE|BEGIN|COMMIT|ROLLBACK)\b/i; + +/** + * The shape orchestrion's wrapCallback transform attaches to the tracing-channel + * `context` object. Documented here rather than imported because orchestrion's + * runtime doesn't export it — see `node_modules/@apm-js-collab/code-transformer/lib/transforms.js`. + */ +interface MysqlQueryChannelContext { + arguments: unknown[]; + self?: unknown; + moduleVersion?: string; + result?: unknown; + error?: unknown; +} + +const _mysqlChannelIntegration = (() => { + return { + name: INTEGRATION_NAME, + setupOnce() { + DEBUG_BUILD && debug.log(`[orchestrion:mysql] subscribing to channel "${CHANNELS.MYSQL_QUERY}"`); + const queryCh = tracingChannel(CHANNELS.MYSQL_QUERY); + + // Each `context` object is shared across start/end/asyncStart/asyncEnd/error + // for one call (orchestrion creates one per invocation). We key the span + // off the same identity. WeakMap so we don't leak if a path never reaches + // asyncEnd for some reason. + const spans = new WeakMap(); + + // `subscribe()` requires all five lifecycle hooks. The orchestrion + // `wrapCallback` transform fires them in one of three orders: + // - sync throw from `query()` : start → error → end (NO asyncEnd) + // - async error from callback : start → end → error → asyncStart → asyncEnd + // - async success : start → end → asyncStart → asyncEnd + // We end the span on `asyncEnd` for the two async paths (so the span + // covers the full network round-trip + callback duration), and fall back + // to `end` for the sync-throw path so the span isn't left unfinished. + // The discriminator between "end fired before any error" and "end fired + // after a sync throw" is whether `ctx.error` is set when `end` runs — + // orchestrion populates it before publishing `error`. + queryCh.subscribe({ + start(rawCtx) { + const ctx = rawCtx as MysqlQueryChannelContext; + const sql = extractSql(ctx.arguments[0]); + const operation = sql ? extractOperation(sql) : undefined; + + const span = startInactiveSpan({ + name: sql ?? 'mysql.query', + op: 'db', + attributes: { + [ATTR_DB_SYSTEM_NAME]: 'mysql', + ...(sql ? { [ATTR_DB_QUERY_TEXT]: sql } : {}), + ...(operation ? { [ATTR_DB_OPERATION_NAME]: operation } : {}), + }, + }); + addOriginToSpan(span, 'auto.db.orchestrion.mysql'); + spans.set(rawCtx, span); + }, + + end(rawCtx) { + // Only acts for sync throws: `end` fires AFTER `error` (both inside + // the wrapper's `try/catch/finally`), so `ctx.error` is already set. + // For async paths `end` fires before `error`, so `ctx.error` is still + // undefined here and we leave the span open for `asyncEnd` to close. + const ctx = rawCtx as MysqlQueryChannelContext; + if (ctx.error === undefined) return; + finishSpan(rawCtx); + }, + + error(rawCtx) { + const ctx = rawCtx as MysqlQueryChannelContext; + const span = spans.get(rawCtx); + if (!span) return; + span.setStatus({ + code: SPAN_STATUS_ERROR, + message: ctx.error instanceof Error ? ctx.error.message : 'unknown_error', + }); + }, + + asyncStart() { + // No-op: we end on `asyncEnd` so the span covers the full callback duration. + }, + + asyncEnd(rawCtx) { + finishSpan(rawCtx); + }, + }); + + function finishSpan(rawCtx: object): void { + const span = spans.get(rawCtx); + if (!span) return; + span.end(); + spans.delete(rawCtx); + } + }, + }; +}) satisfies IntegrationFn; + +function extractSql(firstArg: unknown): string | undefined { + if (typeof firstArg === 'string') { + return firstArg; + } + if (firstArg && typeof firstArg === 'object' && 'sql' in firstArg) { + const sql = (firstArg as { sql?: unknown }).sql; + return typeof sql === 'string' ? sql : undefined; + } + return undefined; +} + +function extractOperation(sql: string): string | undefined { + const match = sql.match(SQL_OPERATION_REGEX); + return match?.[1]?.toUpperCase(); +} + +/** + * EXPERIMENTAL — orchestrion-driven mysql integration. + * + * Subscribes to the `orchestrion:mysql:query` diagnostics_channel that the + * orchestrion code transform injects into `mysql/lib/Connection.js`'s + * `Connection.prototype.query`. Requires the orchestrion runtime hook or + * bundler plugin to be active — wire that up via `_experimentalSetupOrchestrion`. + */ +export const mysqlChannelIntegration = defineIntegration(_mysqlChannelIntegration); diff --git a/packages/node/src/orchestrion/bundler/vite.ts b/packages/node/src/orchestrion/bundler/vite.ts new file mode 100644 index 000000000000..d222fd1fd0b4 --- /dev/null +++ b/packages/node/src/orchestrion/bundler/vite.ts @@ -0,0 +1,74 @@ +// EXPERIMENTAL — Vite plugin that runs the orchestrion code transform at build +// time, injecting `diagnostics_channel.tracingChannel` calls into the libraries +// listed in `SENTRY_INSTRUMENTATIONS`. +// +// This file is published ESM-only via the `@sentry/node/orchestrion/vite` +// subpath export. `@apm-js-collab/code-transformer-bundler-plugins` is +// `"type": "module"`, so consuming it from a CJS build is intentionally +// unsupported — vite.config.ts is almost always ESM in practice. The CJS +// rollup variant still emits this file, but `package.json` only exposes the +// ESM entry, so attempts to `require('@sentry/node/orchestrion/vite')` will +// fail at resolution time rather than producing a half-broken plugin. + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type UnknownPlugin = any; + +import { SENTRY_INSTRUMENTATIONS } from '../config'; + +// `vite` types live in the package's ESM-only subpath; under Node16 module +// resolution with TS treating @sentry/node as CJS, importing them produces a +// false positive. We don't need the runtime value for typing — `UnknownPlugin` +// is sufficient — so we omit the import entirely. + +/** + * Vite plugin that runs the orchestrion code transform on the bundled output. + * + * Use when bundling a Node app with Vite (e.g. Vite SSR builds, Nuxt's Nitro + * pipeline, SvelteKit). For unbundled Node processes use the runtime hook + * instead (`node --import @sentry/node/orchestrion app.js`). + * + * Returns two plugins: + * 1. `sentry-orchestrion-marker` — a `renderChunk` hook that prepends a + * single-line banner to entry chunks. The banner sets + * `globalThis.__SENTRY_ORCHESTRION__.bundler = true` at app boot, so the + * `_experimentalSetupOrchestrion()` detector can confirm the bundler path + * ran (rather than relying on a build-time flag that wouldn't be visible + * to the runtime). + * 2. The upstream `@apm-js-collab/code-transformer-bundler-plugins/vite` + * plugin, fed our central `SENTRY_INSTRUMENTATIONS` config. + * + * @example + * ```ts + * // vite.config.ts + * import { sentryOrchestrionPlugin } from '@sentry/node/orchestrion/vite'; + * export default { plugins: [sentryOrchestrionPlugin()] }; + * ``` + */ +export async function sentryOrchestrionPlugin(): Promise { + const { default: codeTransformer } = await import('@apm-js-collab/code-transformer-bundler-plugins/vite'); + const codeTransformerPlugins = codeTransformer({ instrumentations: SENTRY_INSTRUMENTATIONS }); + const codeTransformerArray: UnknownPlugin[] = Array.isArray(codeTransformerPlugins) + ? codeTransformerPlugins + : [codeTransformerPlugins]; + return [bundlerMarkerPlugin(), ...codeTransformerArray]; +} + +function bundlerMarkerPlugin(): UnknownPlugin { + const banner = [ + 'globalThis.__SENTRY_ORCHESTRION__ = (globalThis.__SENTRY_ORCHESTRION__ || {});', + 'if (globalThis.__SENTRY_ORCHESTRION__.bundler) {', + ' console.warn("[Sentry] sentryOrchestrionPlugin() ran twice in the same bundle. Functions may be instrumented twice.");', + '}', + 'globalThis.__SENTRY_ORCHESTRION__.bundler = true;', + '', + ].join('\n'); + + return { + name: 'sentry-orchestrion-marker', + enforce: 'pre' as const, + renderChunk(code: string, chunk: { isEntry: boolean }): { code: string; map: null } | null { + if (!chunk.isEntry) return null; + return { code: banner + code, map: null }; + }, + }; +} diff --git a/packages/node/src/orchestrion/channels.ts b/packages/node/src/orchestrion/channels.ts new file mode 100644 index 000000000000..28dcf0c33468 --- /dev/null +++ b/packages/node/src/orchestrion/channels.ts @@ -0,0 +1,18 @@ +/** + * Fully-qualified `diagnostics_channel` names that orchestrion publishes to. + * + * Orchestrion's transform always prefixes the configured `channelName` with + * `orchestrion:${module.name}:`. So a config of + * `{ channelName: 'query', module: { name: 'mysql' } }` + * publishes to `orchestrion:mysql:query`. + * + * Subscribers (`integrations//tracing-channel.ts`) consume the full + * prefixed string from this map; the config files set only the unprefixed + * suffix in `channelName`. Keeping both pieces in one file is what guarantees + * they don't drift apart and silently stop firing. + */ +export const CHANNELS = { + MYSQL_QUERY: 'orchestrion:mysql:query', +} as const; + +export type ChannelName = (typeof CHANNELS)[keyof typeof CHANNELS]; diff --git a/packages/node/src/orchestrion/config.ts b/packages/node/src/orchestrion/config.ts new file mode 100644 index 000000000000..db68156c4120 --- /dev/null +++ b/packages/node/src/orchestrion/config.ts @@ -0,0 +1,28 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +/** + * The central list of channel injections orchestrion should perform. + * + * This module has NO side effects — it's the only thing both the runtime hooks + * (`runtime/import-hook.mjs`, `runtime/require-hook.cjs`) and the bundler plugins + * (`bundler/vite.ts`, …) import from. Adding a new instrumented method is one + * entry here plus one subscriber in `integrations//tracing-channel.ts`. + * + * `channelName` here is the unprefixed suffix; the actual diagnostics_channel + * name is `orchestrion:${module.name}:${channelName}` (see `channels.ts`). + */ +export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ + { + channelName: 'query', + module: { name: 'mysql', versionRange: '>=2.0.0 <3', filePath: 'lib/Connection.js' }, + // `Connection` in mysql v2 is a constructor function (NOT a class): + // `function Connection(options) { ... }` + // `Connection.prototype.query = function query(sql, values, cb) { ... }` + // orchestrion's `className`+`methodName` query only matches `class` declarations. + // The named function expression on the right-hand side of the prototype + // assignment is what we want — that's matched by `expressionName: 'query'`, + // which produces the esquery selector + // `AssignmentExpression[left.property.name="query"] > FunctionExpression[async]`. + functionQuery: { expressionName: 'query', kind: 'Callback' }, + }, +]; diff --git a/packages/node/src/orchestrion/detect.ts b/packages/node/src/orchestrion/detect.ts new file mode 100644 index 000000000000..9b2f1d3711e8 --- /dev/null +++ b/packages/node/src/orchestrion/detect.ts @@ -0,0 +1,43 @@ +import { debug } from '@sentry/core'; +import { DEBUG_BUILD } from '../debug-build'; + +declare global { + // eslint-disable-next-line no-var + var __SENTRY_ORCHESTRION__: { runtime?: boolean; bundler?: boolean } | undefined; +} + +/** + * Verifies that exactly one of the two orchestrion setup paths is active: + * - the runtime hook (`node --import @sentry/node/orchestrion app.js`), OR + * - the bundler plugin (`sentryOrchestrionPlugin()`). + * + * Warns if neither (channels never fire — integrations silently record nothing) + * or both (double-wrapped — duplicate spans) ran. + */ +export function detectOrchestrionSetup(): void { + const marker = globalThis.__SENTRY_ORCHESTRION__; + const runtime = !!marker?.runtime; + const bundler = !!marker?.bundler; + + DEBUG_BUILD && debug.log(`[orchestrion] detect: runtime=${runtime} bundler=${bundler}`); + + if (runtime && bundler) { + DEBUG_BUILD && + debug.warn( + '[Sentry] Detected BOTH the @sentry/node/orchestrion runtime hook AND the bundler plugin. ' + + 'Functions will be instrumented twice and produce duplicate spans. ' + + 'Remove `--import @sentry/node/orchestrion` if you are using the bundler plugin, or vice versa.', + ); + return; + } + + if (!runtime && !bundler) { + DEBUG_BUILD && + debug.warn( + '[Sentry] No orchestrion auto-instrumentation hook detected. Channel-based integrations ' + + '(mysql, …) will not record spans. Either run with ' + + '`node --import @sentry/node/orchestrion app.js`, or add `sentryOrchestrionPlugin()` ' + + 'to your bundler config.', + ); + } +} diff --git a/packages/node/src/orchestrion/index.ts b/packages/node/src/orchestrion/index.ts new file mode 100644 index 000000000000..4cd65c41648c --- /dev/null +++ b/packages/node/src/orchestrion/index.ts @@ -0,0 +1,3 @@ +export { _experimentalSetupOrchestrion } from './setup'; +export type { ExperimentalSetupOrchestrionOptions } from './setup'; +export { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; diff --git a/packages/node/src/orchestrion/runtime/import-hook.mjs b/packages/node/src/orchestrion/runtime/import-hook.mjs new file mode 100644 index 000000000000..7071a6a24283 --- /dev/null +++ b/packages/node/src/orchestrion/runtime/import-hook.mjs @@ -0,0 +1,46 @@ +// EXPERIMENTAL — entry point for `node --import @sentry/node/orchestrion app.js`. +// +// Registers the orchestrion ESM loader with the central instrumentation config, +// and sets a global marker (`globalThis.__SENTRY_ORCHESTRION__.runtime`) so +// `detectOrchestrionSetup()` at `_experimentalSetupOrchestrion(client)` time can +// see that the runtime hook ran. +// +// This file is shipped as-is to `build/orchestrion/import-hook.mjs`. Keep it a +// single self-contained `.mjs` file with no relative-path imports — `--import` +// resolves it via Node's module resolution against the installed package. + +import { createRequire } from 'node:module'; +import { register } from 'node:module'; +import { SENTRY_INSTRUMENTATIONS } from '@sentry/node/orchestrion/config'; + +const DEBUG = !!(process.env.DEBUG || process.env.debug || process.env.SENTRY_DEBUG); +// eslint-disable-next-line no-console +const debug = (...args) => DEBUG && console.log('[Sentry orchestrion]', ...args); + +debug('import-hook.mjs loaded, instrumentations:', SENTRY_INSTRUMENTATIONS); + +const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); +if (g.runtime) { + // eslint-disable-next-line no-console + console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --import. Ignoring the second load.'); +} else { + g.runtime = true; + + // ESM loader for `import`-ed modules. + register('@apm-js-collab/tracing-hooks/hook.mjs', import.meta.url, { + data: { instrumentations: SENTRY_INSTRUMENTATIONS }, + }); + debug('module.register() called for @apm-js-collab/tracing-hooks/hook.mjs'); + + // ALSO patch `Module.prototype._compile` for the CJS side: when an ESM file + // `import`s a CJS package (e.g. `import mysql from 'mysql'`), Node loads the + // package's entry through the ESM bridge but resolves the package's INTERNAL + // `require()` calls (mysql/index.js → `require('./lib/Connection.js')`) + // through the CJS machinery. Those internal requires never reach the ESM + // resolve hook, so without this patch the file we actually want to instrument + // (mysql/lib/Connection.js) is loaded untransformed. + const require = createRequire(import.meta.url); + const ModulePatch = require('@apm-js-collab/tracing-hooks'); + new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); + debug('Module.patch() called for CJS-internal requires'); +} diff --git a/packages/node/src/orchestrion/runtime/require-hook.cjs b/packages/node/src/orchestrion/runtime/require-hook.cjs new file mode 100644 index 000000000000..e16f53a7c96f --- /dev/null +++ b/packages/node/src/orchestrion/runtime/require-hook.cjs @@ -0,0 +1,33 @@ +// EXPERIMENTAL — entry point for `node --require @sentry/node/orchestrion app.js`. +// +// Installs orchestrion's CJS `_compile` patch with the central instrumentation +// config, and sets a global marker (`globalThis.__SENTRY_ORCHESTRION__.runtime`) +// so `detectOrchestrionSetup()` at `_experimentalSetupOrchestrion(client)` time +// can see that the runtime hook ran. +// +// This file is shipped as-is to `build/orchestrion/require-hook.cjs`. Keep it a +// single self-contained `.cjs` file with no relative-path requires — `--require` +// resolves it via Node's module resolution against the installed package's +// `./orchestrion` subpath export, which picks this file under the `require` +// condition and `import-hook.mjs` under the `import` condition. + +'use strict'; + +const ModulePatch = require('@apm-js-collab/tracing-hooks'); +const { SENTRY_INSTRUMENTATIONS } = require('@sentry/node/orchestrion/config'); + +const DEBUG = !!(process.env.DEBUG || process.env.debug || process.env.SENTRY_DEBUG); +// eslint-disable-next-line no-console +const debug = (...args) => DEBUG && console.log('[Sentry orchestrion]', ...args); + +debug('require-hook.cjs loaded, instrumentations:', SENTRY_INSTRUMENTATIONS); + +const g = (globalThis.__SENTRY_ORCHESTRION__ ??= {}); +if (g.runtime) { + // eslint-disable-next-line no-console + console.warn('[Sentry] @sentry/node/orchestrion was loaded twice via --require. Ignoring the second load.'); +} else { + g.runtime = true; + new ModulePatch({ instrumentations: SENTRY_INSTRUMENTATIONS }).patch(); + debug('ModulePatch.patch() called'); +} diff --git a/packages/node/src/orchestrion/setup.ts b/packages/node/src/orchestrion/setup.ts new file mode 100644 index 000000000000..4d07b1a75c89 --- /dev/null +++ b/packages/node/src/orchestrion/setup.ts @@ -0,0 +1,69 @@ +import type { Integration } from '@sentry/core'; +import { debug } from '@sentry/core'; +import type { NodeClient } from '@sentry/node-core'; +import { DEBUG_BUILD } from '../debug-build'; +import { mysqlChannelIntegration } from '../integrations/tracing-channel/mysql'; +import { detectOrchestrionSetup } from './detect'; + +export interface ExperimentalSetupOrchestrionOptions { + /** + * Override the default set of channel-based integrations. + * If omitted, all orchestrion integrations shipped by @sentry/node are added. + */ + integrations?: Integration[]; +} + +/** + * EXPERIMENTAL — wires up orchestrion-driven channel integrations. + * + * Must be called after `Sentry.init({ _experimentalUseOrchestrion: true })`, with + * the client returned by `init()`: + * + * ```ts + * const client = Sentry.init({ dsn: '…', _experimentalUseOrchestrion: true }); + * _experimentalSetupOrchestrion(client); + * ``` + * + * This is the ONLY exported entry into `packages/node/src/orchestrion/*`. Bundlers + * can statically determine that apps which never import this drop the entire + * `orchestrion/` subtree from their output — that is the tree-shaking guarantee. + */ +export function _experimentalSetupOrchestrion( + client: NodeClient | undefined, + options: ExperimentalSetupOrchestrionOptions = {}, +): void { + DEBUG_BUILD && debug.log('[orchestrion] _experimentalSetupOrchestrion() called'); + + if (!client) { + DEBUG_BUILD && + debug.warn( + '[Sentry] _experimentalSetupOrchestrion() was called without a client. ' + + 'Pass the value returned by `Sentry.init()`.', + ); + return; + } + + // Verify the user remembered to set the flag on init() — without it, the default + // OTel integrations are still active and we'd produce duplicate spans. + const clientOptions = client.getOptions() as { _experimentalUseOrchestrion?: boolean }; + if (!clientOptions._experimentalUseOrchestrion) { + DEBUG_BUILD && + debug.warn( + '[Sentry] _experimentalSetupOrchestrion() called but Sentry.init() was not given ' + + '`_experimentalUseOrchestrion: true`. The default OTel integrations are still active — ' + + 'you will get duplicate spans. Add the flag to Sentry.init().', + ); + } + + detectOrchestrionSetup(); + + const integrations = options.integrations ?? [mysqlChannelIntegration()]; + DEBUG_BUILD && + debug.log( + '[orchestrion] registering channel integrations:', + integrations.map(i => i.name), + ); + for (const integration of integrations) { + client.addIntegration(integration); + } +} diff --git a/packages/node/src/sdk/index.ts b/packages/node/src/sdk/index.ts index 6942c6500f84..ff9e174d02be 100644 --- a/packages/node/src/sdk/index.ts +++ b/packages/node/src/sdk/index.ts @@ -24,9 +24,22 @@ export function getDefaultIntegrationsWithoutPerformance(): Integration[] { .concat(httpIntegration(), nativeNodeFetchIntegration()); } +/** + * Names of OTel-based default integrations that the orchestrion experiment + * replaces with channel-based equivalents. When + * `_experimentalUseOrchestrion: true` is set on `Sentry.init()`, these are + * filtered out of the default integration list so the two systems don't both + * instrument the same library and produce duplicate spans. + * + * Kept as a plain string set (instead of importing the orchestrion integrations + * themselves) so the orchestrion code path stays tree-shakable: `init()` never + * pulls in anything from `../orchestrion/*`. + */ +const ORCHESTRION_REPLACED_INTEGRATIONS = new Set(['Mysql']); + /** Get the default integrations for the Node SDK. */ export function getDefaultIntegrations(options: Options): Integration[] { - return [ + const integrations: Integration[] = [ ...getDefaultIntegrationsWithoutPerformance(), // We only add performance integrations if tracing is enabled // Note that this means that without tracing enabled, e.g. `expressIntegration()` will not be added @@ -34,6 +47,11 @@ export function getDefaultIntegrations(options: Options): Integration[] { // But `transactionName` will not be set automatically ...(hasSpansEnabled(options) ? getAutoPerformanceIntegrations() : []), ]; + + if ((options as NodeOptions)._experimentalUseOrchestrion) { + return integrations.filter(i => !ORCHESTRION_REPLACED_INTEGRATIONS.has(i.name)); + } + return integrations; } /** diff --git a/packages/node/src/types.ts b/packages/node/src/types.ts index 3a0cb1e7e5fc..869fd4098b78 100644 --- a/packages/node/src/types.ts +++ b/packages/node/src/types.ts @@ -65,6 +65,23 @@ export interface BaseNodeOptions extends OpenTelemetryServerRuntimeOptions { * Defaults to `true`. */ registerEsmLoaderHooks?: boolean; + + /** + * EXPERIMENTAL — opt into the orchestrion.js-based auto-instrumentation path. + * + * When `true`, `Sentry.init()` skips registering the default OTel + * auto-instrumentations for libraries that have a channel-based alternative + * (currently: `mysql`). It does NOT install any channel subscribers on its + * own — call `_experimentalSetupOrchestrion(client)` after `init()` for that. + * + * Splitting the opt-in across two calls keeps the orchestrion code path + * tree-shakable: bundlers can drop `orchestrion/*` from apps that don't + * import `_experimentalSetupOrchestrion`. + * + * Defaults to `false`. The flag name is intentionally underscore-prefixed and + * will be renamed or removed once the experiment graduates. + */ + _experimentalUseOrchestrion?: boolean; } /** diff --git a/packages/node/tsconfig.json b/packages/node/tsconfig.json index d5f034ad1048..35935cd48365 100644 --- a/packages/node/tsconfig.json +++ b/packages/node/tsconfig.json @@ -3,6 +3,15 @@ "include": ["src/**/*"], + // The orchestrion runtime hooks are hand-written `.mjs` / `.cjs` files that + // self-reference `@sentry/node/orchestrion/config`. If tsc picks them up, it + // follows that subpath export back to `build/types/orchestrion/config.d.ts`, + // treats the .d.ts as an input, and then collides with the .d.ts it wants to + // emit from `src/orchestrion/config.ts`. Excluding them keeps tsc focused on + // the .ts sources — rollup copies these files through to `build/orchestrion/` + // unchanged. + "exclude": ["src/orchestrion/runtime/**/*.mjs", "src/orchestrion/runtime/**/*.cjs"], + "compilerOptions": { "lib": ["es2020"], "module": "Node16", diff --git a/packages/node/tsconfig.types.json b/packages/node/tsconfig.types.json index 65455f66bd75..8c1228d18c1d 100644 --- a/packages/node/tsconfig.types.json +++ b/packages/node/tsconfig.types.json @@ -5,6 +5,10 @@ "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, - "outDir": "build/types" + "outDir": "build/types", + // Required so Node16 module resolution can disambiguate package self-references + // (`@sentry/node/orchestrion/config` from inside this package) against the + // package's `.` export. Without this tsc reports TS2209. + "rootDir": "src" } } diff --git a/yarn.lock b/yarn.lock index 06a319881032..aef6c1e658d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -379,6 +379,43 @@ dependencies: json-schema-to-ts "^3.1.1" +"@apm-js-collab/code-transformer-bundler-plugins@^0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.1.0.tgz#655ba83f88e156a0b1c0e501ac2f427a1c6a0741" + integrity sha512-pFSNp4Y0r+PSft9az5rFO8zgsIX8NuDMxMsaq6uAkPPuZBArq2Xykg2xlmuc884sI9lfZ4rJ0VSeVuTz8lfAcA== + dependencies: + "@apm-js-collab/code-transformer" "^0.3.0" + module-details-from-path "^1.0.4" + unplugin "^2.3.5" + +"@apm-js-collab/code-transformer@^0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.13.0.tgz#3bb80cf17f2a09bc19faafb7c6133a5d057488e7" + integrity sha512-JPUR9mNUJV3SP0l6XQ5xGG/3IMOELzNy86vCq/+GOkIUsxEWC6AMIviAQ5sxrfQQEbQofjIzU3kshx4RQnRq7A== + dependencies: + "@types/estree" "^1.0.8" + astring "^1.9.0" + esquery "^1.7.0" + meriyah "^6.1.4" + semifies "^1.0.0" + source-map "^0.6.0" + +"@apm-js-collab/code-transformer@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/code-transformer/-/code-transformer-0.3.0.tgz#bf6b46e1f8db932da457aeb568f13b38509dd2fb" + integrity sha512-6vZdhmS8sSR/FCzpqo43+rD8xL0wRmzdwt8h+xm0ytRK0BIAzAargRu6mqiP9k/wd/p1LQyPd4wTnaFw12t9HA== + dependencies: + wasm-pack "^0.13.1" + +"@apm-js-collab/tracing-hooks@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.7.0.tgz#4ecc4023f8874d86d1f004afcdee8e07297e739d" + integrity sha512-ETZbwnF3+nw6ORKW5gQnLyDgvQKg7gmshevAV34a87rQIIJoazZBRnLd8wkBaU4HUru3leAkFCwxGbeksvVKaQ== + dependencies: + "@apm-js-collab/code-transformer" "^0.13.0" + debug "^4.4.1" + module-details-from-path "^1.0.4" + "@apollo/cache-control-types@^1.0.3": version "1.0.3" resolved "https://registry.yarnpkg.com/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz#5da62cf64c3b4419dabfef4536b57a40c8ff0b47" @@ -11559,10 +11596,10 @@ ast-walker-scope@^0.8.1: "@babel/parser" "^7.28.4" ast-kit "^2.1.3" -astring@^1.8.6: - version "1.8.6" - resolved "https://registry.yarnpkg.com/astring/-/astring-1.8.6.tgz#2c9c157cf1739d67561c56ba896e6948f6b93731" - integrity sha512-ISvCdHdlTDlH5IpxQJIex7BWBywFWgjJSVdwst+/iQCoEYnyOaQ95+X1JGshuBjGp6nxKUy1jMgE3zPqN7fQdg== +astring@^1.8.6, astring@^1.9.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/astring/-/astring-1.9.0.tgz#cc73e6062a7eb03e7d19c22d8b0b3451fd9bfeef" + integrity sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg== astro@^3.5.0: version "3.5.0" @@ -11761,6 +11798,13 @@ axios@1.15.2: form-data "^4.0.5" proxy-from-env "^2.1.0" +axios@^0.26.1: + version "0.26.1" + resolved "https://registry.yarnpkg.com/axios/-/axios-0.26.1.tgz#1ede41c51fcf51bbbd6fd43669caaa4f0495aaa9" + integrity sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA== + dependencies: + follow-redirects "^1.14.8" + axobject-query@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-3.2.1.tgz#39c378a6e3b06ca679f29138151e45b2b32da62a" @@ -12154,6 +12198,15 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== +binary-install@^1.0.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/binary-install/-/binary-install-1.1.2.tgz#06f059e5475e2a208d65ead8cb523d7845a83038" + integrity sha512-ZS2cqFHPZOy4wLxvzqfQvDjCOifn+7uCPqNmYRIBM/03+yllON+4fNnsD0VJdW0p97y+E+dTRNPStWNqMBq+9g== + dependencies: + axios "^0.26.1" + rimraf "^3.0.2" + tar "^6.1.11" + binary@^0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/binary/-/binary-0.3.0.tgz#9f60553bc5ce8c3386f3b553cff47462adecaa79" @@ -16820,10 +16873,10 @@ esprima@~3.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-3.0.0.tgz#53cf247acda77313e551c3aa2e73342d3fb4f7d9" integrity sha1-U88kes2ncxPlUcOqLnM0LT+099k= -esquery@^1.4.2, esquery@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" - integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== +esquery@^1.4.2, esquery@^1.6.0, esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== dependencies: estraverse "^5.1.0" @@ -17658,7 +17711,7 @@ fn.name@1.x.x: resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc" integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== -follow-redirects@^1.0.0, follow-redirects@^1.15.11: +follow-redirects@^1.0.0, follow-redirects@^1.14.8, follow-redirects@^1.15.11: version "1.16.0" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== @@ -21029,11 +21082,6 @@ lodash.uniq@^4.2.0, lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@4.17.23: - version "4.17.23" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" - integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== - lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.18.1: version "4.18.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" @@ -21649,6 +21697,11 @@ merge2@^1.2.3, merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== +meriyah@^6.1.4: + version "6.1.4" + resolved "https://registry.yarnpkg.com/meriyah/-/meriyah-6.1.4.tgz#2d49a8934fbcd9205c20564579c3560d9b1e077b" + integrity sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ== + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -22112,7 +22165,7 @@ minimatch@5.1.0, minimatch@5.1.9, minimatch@^5.0.1, minimatch@^5.1.0: dependencies: brace-expansion "^2.0.1" -minimatch@^7.4.1, minimatch@~7.4.9: +minimatch@^7.4.1: version "7.4.9" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-7.4.9.tgz#ef35412b1b36261b78ef1b2f0db29b759bbcaf5d" integrity sha512-Brg/fp/iAVDOQoHxkuN5bEYhyQlZhxddI78yWsCbeEwTHXQjlNLtiJDUsp1GIptVqMI7/gkJMz4vVAc01mpoBw== @@ -24285,11 +24338,6 @@ path-scurry@^2.0.2: lru-cache "^11.0.0" minipass "^7.1.2" -path-to-regexp@0.1.12, path-to-regexp@~0.1.12: - version "0.1.12" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" - integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== - path-to-regexp@3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-3.3.0.tgz#f7f31d32e8518c2660862b644414b6d5c63a611b" @@ -24312,6 +24360,11 @@ path-to-regexp@^1.5.3, path-to-regexp@^1.7.0: dependencies: isarray "0.0.1" +path-to-regexp@~0.1.12: + version "0.1.12" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" + integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -27115,6 +27168,11 @@ selfsigned@^2.0.1: "@types/node-forge" "^1.3.0" node-forge "^1" +semifies@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/semifies/-/semifies-1.0.0.tgz#b69569f32c2ba2ac04f705ea82831364289b2ae2" + integrity sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw== + semver-compare@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" @@ -28180,7 +28238,7 @@ string-template@~0.2.1: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@4.2.3, "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -28313,13 +28371,6 @@ stringify-object@^3.2.1: dependencies: ansi-regex "^5.0.1" -strip-ansi@6.0.1, strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf" @@ -28341,6 +28392,13 @@ strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -30573,6 +30631,13 @@ warning@^3.0.0: dependencies: loose-envify "^1.0.0" +wasm-pack@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/wasm-pack/-/wasm-pack-0.13.1.tgz#345701522420ad74a5b584f1bdaf6db8c264cb54" + integrity sha512-P9exD4YkjpDbw68xUhF3MDm/CC/3eTmmthyG5bHJ56kalxOTewOunxTke4SyF8MTXV6jUtNjXggPgrGmMtczGg== + dependencies: + binary-install "^1.0.1" + watch-detector@^1.0.0, watch-detector@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/watch-detector/-/watch-detector-1.0.2.tgz#95deb9189f8c89c0a9f211739cef6d01cffcf452" @@ -31140,19 +31205,19 @@ wrangler@4.62.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@7.0.0, wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== +wrap-ansi@^6.0.1: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== dependencies: ansi-styles "^4.0.0" string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^6.0.1: - version "6.2.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" - integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== dependencies: ansi-styles "^4.0.0" string-width "^4.1.0"