From 67556277697f7f4c51f46526e520ffc6de97c544 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 17 Jul 2026 08:29:15 +0200 Subject: [PATCH 1/4] feat(server-utils): Allow integrations to be part of marker --- packages/core/src/utils/worldwide.ts | 9 ++ packages/server-utils/package.json | 3 +- .../src/orchestrion/bundler/options.ts | 31 ++++- .../orchestrion/bundler/subscribeInjection.ts | 110 ++++++++++++++++ .../src/orchestrion/config/amqplib.ts | 3 + .../src/orchestrion/config/anthropic-ai.ts | 3 + .../src/orchestrion/config/aws-sdk.ts | 3 + .../config/channel-integration-definitions.ts | 48 +++++++ .../src/orchestrion/config/dataloader.ts | 3 + .../src/orchestrion/config/express.ts | 3 + .../src/orchestrion/config/generic-pool.ts | 3 + .../src/orchestrion/config/google-genai.ts | 3 + .../src/orchestrion/config/graphql.ts | 3 + .../src/orchestrion/config/hapi.ts | 3 + .../src/orchestrion/config/index.ts | 71 ++++++++--- .../src/orchestrion/config/ioredis.ts | 3 + .../src/orchestrion/config/kafkajs.ts | 3 + .../src/orchestrion/config/lru-memoizer.ts | 3 + .../src/orchestrion/config/mysql.ts | 3 + .../src/orchestrion/config/mysql2.ts | 3 + .../src/orchestrion/config/openai.ts | 3 + .../server-utils/src/orchestrion/config/pg.ts | 3 + .../src/orchestrion/config/postgres.ts | 3 + .../src/orchestrion/config/redis.ts | 3 + .../orchestrion/config/subscribe-injection.ts | 54 ++++++++ .../src/orchestrion/config/vercel-ai.ts | 3 + .../orchestrion/subscribeInjection.test.ts | 120 ++++++++++++++++++ 27 files changed, 481 insertions(+), 22 deletions(-) create mode 100644 packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts create mode 100644 packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts create mode 100644 packages/server-utils/src/orchestrion/config/subscribe-injection.ts create mode 100644 packages/server-utils/test/orchestrion/subscribeInjection.test.ts diff --git a/packages/core/src/utils/worldwide.ts b/packages/core/src/utils/worldwide.ts index 42a7ffdfaec4..396c98d952a2 100644 --- a/packages/core/src/utils/worldwide.ts +++ b/packages/core/src/utils/worldwide.ts @@ -12,6 +12,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Integration } from '../types/integration'; import type { Carrier } from '../carrier'; import type { SdkSource } from './env'; @@ -63,6 +64,14 @@ export type InternalGlobal = { runtime?: string[]; /** Empty array signifies bundler plugin ran */ bundler?: string[]; + /** + * Channel-subscriber integration factories a bundler plugin's + * subscribe-injection stored here, keyed by export name (one per instrumented + * package actually bundled; the key dedupes packages split across several + * files). A bundler-only SDK (e.g. `@sentry/cloudflare`) reads these at + * `init()` and instantiates them. + */ + integrations?: Map Integration>; }; } & Carrier; diff --git a/packages/server-utils/package.json b/packages/server-utils/package.json index 278b74fa803e..6523e588ce1a 100644 --- a/packages/server-utils/package.json +++ b/packages/server-utils/package.json @@ -119,7 +119,8 @@ "@apm-js-collab/code-transformer-bundler-plugins": "^0.7.1", "@apm-js-collab/tracing-hooks": "^0.13.0", "@sentry/conventions": "^0.16.0", - "@sentry/core": "10.66.0" + "@sentry/core": "10.66.0", + "meriyah": "^6.1.4" }, "devDependencies": { "@types/node": "^18.19.1", diff --git a/packages/server-utils/src/orchestrion/bundler/options.ts b/packages/server-utils/src/orchestrion/bundler/options.ts index c536482df346..d6d2829d792a 100644 --- a/packages/server-utils/src/orchestrion/bundler/options.ts +++ b/packages/server-utils/src/orchestrion/bundler/options.ts @@ -1,5 +1,6 @@ import type { InstrumentationConfig, CustomTransform } from '..'; import { SENTRY_INSTRUMENTATIONS } from '../config'; +import { subscribeInjectionOptions } from './subscribeInjection'; import type { CodeTransformerPluginOptions } from '@apm-js-collab/code-transformer-bundler-plugins/core'; export type PluginOptions = { @@ -17,6 +18,26 @@ export type PluginOptions = { * Defaults to `true`. */ shouldInjectDiagnostics?: boolean; + /** + * Inject a small marker-push into each instrumented module that imports only + * that package's channel-subscriber factory and pushes it onto + * `globalThis.__SENTRY_ORCHESTRION__.integrations`. A bundler-only SDK reads + * the marker at `init()` and instantiates the collected factories, so every + * transformed package's subscriber is wired up with no runtime module hook. + * + * Because each site imports a single named factory, it tree-shakes: a bundle + * carries subscriber code only for the packages actually transformed into it. + * + * This is what lets a bundler-only SDK (e.g. `@sentry/cloudflare`, which runs + * in workerd where requires can't be monkey-patched) record channel spans, + * but it is bundler-agnostic: any orchestrion bundler plugin can enable it. + * Leave it off for SDKs that wire the integrations up through a static import + * instead (e.g. `@sentry/node`'s `experimentalUseDiagnosticsChannelInjection()`), + * so the subscribers aren't registered twice. + * + * Defaults to `false`. + */ + injectChannelSubscribers?: boolean; }; /** @@ -29,8 +50,14 @@ export type PluginOptions = { * visible to the runtime). */ export function orchestrionTransformOptions(options: PluginOptions): CodeTransformerPluginOptions { - const instrumentations = [...SENTRY_INSTRUMENTATIONS, ...(options.instrumentations || [])]; - const customTransforms = options.customTransforms; + const subscribeInjection = options.injectChannelSubscribers ? subscribeInjectionOptions() : undefined; + + const instrumentations = [ + ...SENTRY_INSTRUMENTATIONS, + ...(options.instrumentations || []), + ...(subscribeInjection?.instrumentations || []), + ]; + const customTransforms = { ...options.customTransforms, ...subscribeInjection?.customTransforms }; if (options.shouldInjectDiagnostics === false) { return { diff --git a/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts b/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts new file mode 100644 index 000000000000..d4cb16c9fab7 --- /dev/null +++ b/packages/server-utils/src/orchestrion/bundler/subscribeInjection.ts @@ -0,0 +1,110 @@ +import type { CustomTransform } from '@apm-js-collab/code-transformer'; +import { parse } from 'meriyah'; +import { SUBSCRIBE_INJECTIONS } from '../config'; +import { subscriberExportForModule } from '../config/channel-integration-definitions'; +import { SUBSCRIBE_TRANSFORM_NAME } from '../config/subscribe-injection'; +import type { PluginOptions } from './options'; + +// Tracks Program nodes we already injected into, so a package with several +// instrumented files (or several configs pointing at one file) is injected only +// once per file. A `WeakSet` keyed by the node avoids mutating the emitted AST. +const injectedPrograms = new WeakSet(); + +interface ProgramNode { + type: string; + body: Array<{ type: string; directive?: string }>; +} + +/** + * Snippet injected into each instrumented module. It imports ONLY that package's + * channel-subscriber factory from `@sentry/server-utils/orchestrion` plus the two + * `@sentry/core` helpers it needs, then does two things when the module first + * evaluates: + * + * 1. Stores the factory on the global orchestrion marker under its export name, + * so a later `init()` (a fresh isolate, or a client created after this module + * loads) picks it up via `getDefaultIntegrations()`. + * 2. If a client already exists, registers the integration live on it right away. + * + * Step 2 is what makes this robust against module load order. Bundler-only SDKs + * (e.g. `@sentry/cloudflare`) call `init()` per request, but a package like + * `mysql` loads its instrumented file lazily on first use — i.e. AFTER that + * request's `init()` already snapshotted the marker. Without the live add, the + * first request that touches such a package would publish to a channel nobody + * subscribed to yet. `addIntegration` dedupes by integration name and only runs + * `setupOnce` once, so storing AND live-adding never double-subscribes. + * + * The marker is a `Map` keyed by export name so a package split across several + * instrumented files (e.g. `pg`'s JS and native clients, or openai's per-resource + * `.js`/`.mjs` files) registers its one subscriber once, no matter how many of + * its files land in the bundle — `.set` on the shared key is idempotent. + * + * Importing the single named factory (rather than a central dispatch that pulls + * in every subscriber) is what makes this tree-shake: a bundle carries only the + * subscriber code for packages actually transformed into it — the same + * "only-active-when-bundled" property the runtime module hook gives unbundled + * Node, but without a hook (workerd can't monkey-patch requires). + */ +function subscribeSnippet(exportName: string, esm: boolean): string { + const importStmt = esm + ? `import { ${exportName} } from '@sentry/server-utils/orchestrion';\nimport { getClient as __sentryGetClient } from '@sentry/core';` + : `const { ${exportName} } = require('@sentry/server-utils/orchestrion');\nconst { getClient: __sentryGetClient } = require('@sentry/core');`; + + // `??=` keeps the marker init a no-op after the first instrumented file + // creates the Map; keying by export name dedupes packages split across files. + return ( + `${importStmt}\n` + + '(globalThis.__SENTRY_ORCHESTRION__ ??= {}).integrations ??= new Map();\n' + + `globalThis.__SENTRY_ORCHESTRION__.integrations.set(${JSON.stringify(exportName)}, ${exportName});\n` + + `__sentryGetClient()?.addIntegration(${exportName}());` + ); +} + +/** + * The custom transform registered under {@link SUBSCRIBE_TRANSFORM_NAME}. It is + * invoked with the matched `Program` node and mutates it in place, splicing the + * marker-push snippet in after any `'use strict'` directive. + * + * `state` carries the matched config spread with `{ moduleType }`; the config's + * `channelName` carries the package name (see `toSubscribeInjections`), which + * maps to the subscriber's export name. + */ +const injectSubscribe: CustomTransform = (state, program) => { + const node = program as unknown as ProgramNode; + if (injectedPrograms.has(node)) { + return; + } + + const { moduleType, channelName } = state as { moduleType?: string; channelName?: string }; + const exportName = channelName ? subscriberExportForModule(channelName) : undefined; + if (!exportName) { + return; + } + + injectedPrograms.add(node); + + const statements = parse(subscribeSnippet(exportName, moduleType === 'esm'), { + module: moduleType === 'esm', + next: true, + }).body as ProgramNode['body']; + + const directiveIndex = node.body.findIndex(n => n.type === 'ExpressionStatement' && n.directive === 'use strict'); + node.body.splice(directiveIndex + 1, 0, ...statements); +}; + +/** + * The `instrumentations` + `customTransforms` a bundler plugin passes to + * {@link orchestrionTransformOptions} to enable the marker-push subscribe + * injection used by bundler-only SDKs (e.g. `@sentry/cloudflare`). + * + * The `SUBSCRIBE_INJECTIONS` configs ride alongside the real channel-publishing + * configs, and `injectSubscribe` runs on each matched module, so every + * transformed package self-registers its subscriber on the global marker + * without a runtime module hook. + */ +export function subscribeInjectionOptions(): Pick { + return { + instrumentations: SUBSCRIBE_INJECTIONS, + customTransforms: { [SUBSCRIBE_TRANSFORM_NAME]: injectSubscribe }, + }; +} diff --git a/packages/server-utils/src/orchestrion/config/amqplib.ts b/packages/server-utils/src/orchestrion/config/amqplib.ts index 5f2082859347..dea498f607c9 100644 --- a/packages/server-utils/src/orchestrion/config/amqplib.ts +++ b/packages/server-utils/src/orchestrion/config/amqplib.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // `amqplib` splits its API across three files: // - `lib/channel_model.js` holds `class Channel` (publish/consume/ack/nack/reject/…) and @@ -87,3 +88,5 @@ export const amqplibChannels = { AMQPLIB_NACK_ALL: 'orchestrion:amqplib:nackAll', AMQPLIB_CONNECT: 'orchestrion:amqplib:connect', } as const; + +export const amqplibSubscribeInjection = toSubscribeInjections(amqplibConfig); diff --git a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts index 0b5f0e0ecf10..202fd538eabc 100644 --- a/packages/server-utils/src/orchestrion/config/anthropic-ai.ts +++ b/packages/server-utils/src/orchestrion/config/anthropic-ai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const anthropicAiConfig = [ // One entry each for CJS/ESM @@ -38,3 +39,5 @@ export const anthropicAiChannels = { ANTHROPIC_MODELS: 'orchestrion:@anthropic-ai/sdk:models', ANTHROPIC_MESSAGES_STREAM: 'orchestrion:@anthropic-ai/sdk:messages-stream', } as const; + +export const anthropicAiSubscribeInjection = toSubscribeInjections(anthropicAiConfig); diff --git a/packages/server-utils/src/orchestrion/config/aws-sdk.ts b/packages/server-utils/src/orchestrion/config/aws-sdk.ts index fdcc4d9e5682..d9e6bf35726d 100644 --- a/packages/server-utils/src/orchestrion/config/aws-sdk.ts +++ b/packages/server-utils/src/orchestrion/config/aws-sdk.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; +import { toSubscribeInjections } from './subscribe-injection'; // The AWS SDK (v3) routes every command through the smithy `Client.prototype.send` method. Which // package hosts that `Client` class changed across versions, so we target all of them; only the one @@ -32,3 +33,5 @@ export const awsSdkChannels = { AWS_SMITHY_CLIENT_SEND: 'orchestrion:@smithy/smithy-client:send', AWS_SDK_SMITHY_CLIENT_SEND: 'orchestrion:@aws-sdk/smithy-client:send', } as const; + +export const awsSdkSubscribeInjection = toSubscribeInjections(awsSdkConfig); diff --git a/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts new file mode 100644 index 000000000000..649cb816e837 --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/channel-integration-definitions.ts @@ -0,0 +1,48 @@ +/** + * Build-time metadata mapping each instrumented package (orchestrion + * `module.name`) to the channel-subscriber integration that consumes the + * channels injected into it — by the `exportName` it is published under from + * `@sentry/server-utils/orchestrion`. + * + * Kept in a separate, factory-free module on purpose: the subscribe-injection + * transform (reachable from every orchestrion bundler plugin) reads this to + * generate the tiny snippet it injects into each instrumented file, and must + * not drag any subscriber code — or its `@sentry/core` span machinery — into + * the plugin's own build to do so. + * + * `exportName` must be a named export of `@sentry/server-utils/orchestrion`. + * `modules` must match `module.name` values in `SENTRY_INSTRUMENTATIONS` — e.g. + * `postgresChannelIntegration` covers both `pg` and `pg-pool`, and + * `redisChannelIntegration` both `redis` and `@redis/client`. + * + * `redis`, `ioredis` and `dataloader` are included even though they're not in + * the node SDK's `channelIntegrations` (they only partially replace an OTel + * integration there): in a bundler-only runtime like Cloudflare Workers there + * is no OTel integration to coordinate with, so subscribing whenever the + * package is bundled is unconditionally correct. + */ +export const CHANNEL_INTEGRATION_DEFINITIONS = [ + { exportName: 'postgresChannelIntegration', modules: ['pg', 'pg-pool'] }, + { exportName: 'postgresJsChannelIntegration', modules: ['postgres'] }, + { exportName: 'mysqlChannelIntegration', modules: ['mysql'] }, + { exportName: 'mysql2ChannelIntegration', modules: ['mysql2'] }, + { exportName: 'genericPoolChannelIntegration', modules: ['generic-pool'] }, + { exportName: 'lruMemoizerChannelIntegration', modules: ['lru-memoizer'] }, + { exportName: 'openaiChannelIntegration', modules: ['openai'] }, + { exportName: 'anthropicChannelIntegration', modules: ['@anthropic-ai/sdk'] }, + { exportName: 'googleGenAIChannelIntegration', modules: ['@google/genai'] }, + { exportName: 'vercelAiChannelIntegration', modules: ['ai'] }, + { exportName: 'amqplibChannelIntegration', modules: ['amqplib'] }, + { exportName: 'hapiChannelIntegration', modules: ['@hapi/hapi'] }, + { exportName: 'expressChannelIntegration', modules: ['express', 'router'] }, + { exportName: 'graphqlChannelIntegration', modules: ['graphql'] }, + { exportName: 'kafkajsChannelIntegration', modules: ['kafkajs'] }, + { exportName: 'redisChannelIntegration', modules: ['redis', '@redis/client'] }, + { exportName: 'ioredisChannelIntegration', modules: ['ioredis'] }, + { exportName: 'dataloaderChannelIntegration', modules: ['dataloader'] }, +] as const satisfies ReadonlyArray<{ exportName: string; modules: readonly string[] }>; + +/** Look up the subscriber export name for an instrumented package, if any. */ +export function subscriberExportForModule(moduleName: string): string | undefined { + return CHANNEL_INTEGRATION_DEFINITIONS.find(d => (d.modules as readonly string[]).includes(moduleName))?.exportName; +} diff --git a/packages/server-utils/src/orchestrion/config/dataloader.ts b/packages/server-utils/src/orchestrion/config/dataloader.ts index cd1f0879bdc3..58cc3b7ba621 100644 --- a/packages/server-utils/src/orchestrion/config/dataloader.ts +++ b/packages/server-utils/src/orchestrion/config/dataloader.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // `dataloader` ships a single transpiled CommonJS `index.js`. Its class methods are emitted as // `_proto. = function () {}` (named function *expressions*), so they match on @@ -53,3 +54,5 @@ export const dataloaderChannels = { DATALOADER_CLEAR: 'orchestrion:dataloader:clear', DATALOADER_CLEAR_ALL: 'orchestrion:dataloader:clearAll', } as const; + +export const dataloaderSubscribeInjection = toSubscribeInjections(dataloaderConfig); diff --git a/packages/server-utils/src/orchestrion/config/express.ts b/packages/server-utils/src/orchestrion/config/express.ts index 81fe89947e3d..0c349454184f 100644 --- a/packages/server-utils/src/orchestrion/config/express.ts +++ b/packages/server-utils/src/orchestrion/config/express.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const expressConfig = [ // Express funnels every middleware/route handler through a single method on @@ -72,3 +73,5 @@ export const expressChannels = { EXPRESS_REGISTER: 'orchestrion:express:register', ROUTER_REGISTER: 'orchestrion:router:register', } as const; + +export const expressSubscribeInjection = toSubscribeInjections(expressConfig); diff --git a/packages/server-utils/src/orchestrion/config/generic-pool.ts b/packages/server-utils/src/orchestrion/config/generic-pool.ts index 724451207ea2..cb3f2f64d524 100644 --- a/packages/server-utils/src/orchestrion/config/generic-pool.ts +++ b/packages/server-utils/src/orchestrion/config/generic-pool.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // Two shapes of `acquire`, both publishing to the same `orchestrion:generic-pool:acquire` channel: // - v3+: `class Pool { acquire(priority) }` returns a promise, so `kind: 'Auto'` resolves to `wrapPromise`. @@ -21,3 +22,5 @@ export const genericPoolConfig = [ export const genericPoolChannels = { GENERIC_POOL_ACQUIRE: 'orchestrion:generic-pool:acquire', } as const; + +export const genericPoolSubscribeInjection = toSubscribeInjections(genericPoolConfig); diff --git a/packages/server-utils/src/orchestrion/config/google-genai.ts b/packages/server-utils/src/orchestrion/config/google-genai.ts index 693e8ba039bc..8426f2306e0d 100644 --- a/packages/server-utils/src/orchestrion/config/google-genai.ts +++ b/packages/server-utils/src/orchestrion/config/google-genai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // `@google/genai` ships one bundled file per module format and the matcher compares `filePath` exactly, // so we list every file the `node` export condition resolves to across the supported range: `index.js` @@ -38,3 +39,5 @@ export const googleGenAiChannels = { GOOGLE_GENAI_EMBED_CONTENT: 'orchestrion:@google/genai:embed-content', GOOGLE_GENAI_CHAT: 'orchestrion:@google/genai:chat', } as const; + +export const googleGenAiSubscribeInjection = toSubscribeInjections(googleGenAiConfig); diff --git a/packages/server-utils/src/orchestrion/config/graphql.ts b/packages/server-utils/src/orchestrion/config/graphql.ts index 13257755934c..3517ea137626 100644 --- a/packages/server-utils/src/orchestrion/config/graphql.ts +++ b/packages/server-utils/src/orchestrion/config/graphql.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // `parse`/`validate`/`execute` are top-level named `function` declarations in graphql's compiled // files, stable across the supported majors, so `functionName` matches. `execute` returns @@ -26,3 +27,5 @@ export const graphqlChannels = { GRAPHQL_VALIDATE: 'orchestrion:graphql:validate', GRAPHQL_EXECUTE: 'orchestrion:graphql:execute', } as const; + +export const graphqlSubscribeInjection = toSubscribeInjections(graphqlConfig); diff --git a/packages/server-utils/src/orchestrion/config/hapi.ts b/packages/server-utils/src/orchestrion/config/hapi.ts index 7c11aac6911a..94752b564666 100644 --- a/packages/server-utils/src/orchestrion/config/hapi.ts +++ b/packages/server-utils/src/orchestrion/config/hapi.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const hapiConfig = [ // hapi's `route`/`ext` live on an anonymous class (`internals.Server = class {}`), @@ -21,3 +22,5 @@ export const hapiChannels = { HAPI_ROUTE: 'orchestrion:@hapi/hapi:route', HAPI_EXT: 'orchestrion:@hapi/hapi:ext', } as const; + +export const hapiSubscribeInjection = toSubscribeInjections(hapiConfig); diff --git a/packages/server-utils/src/orchestrion/config/index.ts b/packages/server-utils/src/orchestrion/config/index.ts index ccc361c12755..fc59bea44643 100644 --- a/packages/server-utils/src/orchestrion/config/index.ts +++ b/packages/server-utils/src/orchestrion/config/index.ts @@ -1,37 +1,37 @@ import type { InstrumentationConfig } from '..'; import { uniq } from '@sentry/core'; -import { amqplibConfig } from './amqplib'; -import { anthropicAiConfig } from './anthropic-ai'; -import { awsSdkConfig } from './aws-sdk'; -import { dataloaderConfig } from './dataloader'; -import { expressConfig } from './express'; +import { awsSdkConfig, awsSdkSubscribeInjection } from './aws-sdk'; +import { amqplibConfig, amqplibSubscribeInjection } from './amqplib'; +import { anthropicAiConfig, anthropicAiSubscribeInjection } from './anthropic-ai'; +import { dataloaderConfig, dataloaderSubscribeInjection } from './dataloader'; +import { expressConfig, expressSubscribeInjection } from './express'; import { firebaseConfig } from './firebase'; -import { genericPoolConfig } from './generic-pool'; -import { googleGenAiConfig } from './google-genai'; -import { graphqlConfig } from './graphql'; -import { hapiConfig } from './hapi'; -import { ioredisConfig } from './ioredis'; -import { kafkajsConfig } from './kafkajs'; +import { genericPoolConfig, genericPoolSubscribeInjection } from './generic-pool'; +import { googleGenAiConfig, googleGenAiSubscribeInjection } from './google-genai'; +import { graphqlConfig, graphqlSubscribeInjection } from './graphql'; +import { hapiConfig, hapiSubscribeInjection } from './hapi'; +import { ioredisConfig, ioredisSubscribeInjection } from './ioredis'; +import { kafkajsConfig, kafkajsSubscribeInjection } from './kafkajs'; import { knexConfig } from './knex'; import { koaConfig } from './koa'; import { langchainConfig } from './langchain'; import { langgraphConfig } from './langgraph'; -import { lruMemoizerConfig } from './lru-memoizer'; +import { lruMemoizerConfig, lruMemoizerSubscribeInjection } from './lru-memoizer'; import { mongodbConfig } from './mongodb'; import { mongooseConfig } from './mongoose'; -import { mysql2Config } from './mysql2'; -import { mysqlConfig } from './mysql'; +import { mysql2Config, mysql2SubscribeInjection } from './mysql2'; +import { mysqlConfig, mysqlSubscribeInjection } from './mysql'; import { nestjsConfig } from './nestjs'; -import { openaiConfig } from './openai'; -import { pgConfig } from './pg'; -import { postgresJsConfig } from './postgres'; +import { openaiConfig, openaiSubscribeInjection } from './openai'; +import { pgConfig, pgSubscribeInjection } from './pg'; +import { postgresJsConfig, postgresJsSubscribeInjection } from './postgres'; import { prismaConfig } from './prisma'; import { reactRouterConfig } from './react-router'; -import { redisConfig } from './redis'; +import { redisConfig, redisSubscribeInjection } from './redis'; import { remixConfig } from './remix'; import { tediousConfig } from './tedious'; -import { vercelAiConfig } from './vercel-ai'; +import { vercelAiConfig, vercelAiSubscribeInjection } from './vercel-ai'; // Kept sorted alphabetically by module so concurrent additions insert at different // points rather than all appending to the end (fewer merge conflicts). @@ -76,6 +76,39 @@ export const SENTRY_INSTRUMENTATIONS: InstrumentationConfig[] = [ ...vercelAiConfig, ]; +/** + * The `Program`-matching injection configs that make each instrumented file + * self-register its channel subscriber at load (used by bundler-only SDKs like + * `@sentry/cloudflare`). + * + * Deliberately separate from `SENTRY_INSTRUMENTATIONS`: these reference a custom + * transform that only the opted-in bundler plugin registers, so feeding them to + * the runtime `--import` hook (which can't register it) would make the + * code-transformer drop the whole file. Each library owns its own + * `*SubscribeInjection` (derived from its channel configs), collected here. + */ +export const SUBSCRIBE_INJECTIONS: InstrumentationConfig[] = [ + ...amqplibSubscribeInjection, + ...anthropicAiSubscribeInjection, + ...awsSdkSubscribeInjection, + ...dataloaderSubscribeInjection, + ...expressSubscribeInjection, + ...genericPoolSubscribeInjection, + ...googleGenAiSubscribeInjection, + ...graphqlSubscribeInjection, + ...hapiSubscribeInjection, + ...ioredisSubscribeInjection, + ...kafkajsSubscribeInjection, + ...lruMemoizerSubscribeInjection, + ...mysql2SubscribeInjection, + ...mysqlSubscribeInjection, + ...openaiSubscribeInjection, + ...pgSubscribeInjection, + ...postgresJsSubscribeInjection, + ...redisSubscribeInjection, + ...vercelAiSubscribeInjection, +]; + /** * The unique set of package names instrumented by `SENTRY_INSTRUMENTATIONS` * merged with any caller-provided `instrumentations` (e.g. `['mysql']`). diff --git a/packages/server-utils/src/orchestrion/config/ioredis.ts b/packages/server-utils/src/orchestrion/config/ioredis.ts index 35d2d44cd79b..5755759c549b 100644 --- a/packages/server-utils/src/orchestrion/config/ioredis.ts +++ b/packages/server-utils/src/orchestrion/config/ioredis.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const ioredisConfig = [ // ioredis `<5.11.0` (>=5.11.0 publishes its own `ioredis:*` diagnostics_channel) @@ -30,3 +31,5 @@ export const ioredisChannels = { IOREDIS_COMMAND: 'orchestrion:ioredis:command', IOREDIS_CONNECT: 'orchestrion:ioredis:connect', } as const; + +export const ioredisSubscribeInjection = toSubscribeInjections(ioredisConfig); diff --git a/packages/server-utils/src/orchestrion/config/kafkajs.ts b/packages/server-utils/src/orchestrion/config/kafkajs.ts index 0d653e0db0eb..0e42054b20b6 100644 --- a/packages/server-utils/src/orchestrion/config/kafkajs.ts +++ b/packages/server-utils/src/orchestrion/config/kafkajs.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const kafkajsConfig = [ { @@ -25,3 +26,5 @@ export const kafkajsChannels = { KAFKAJS_SEND_BATCH: 'orchestrion:kafkajs:send_batch', KAFKAJS_CONSUMER_RUN: 'orchestrion:kafkajs:consumer_run', } as const; + +export const kafkajsSubscribeInjection = toSubscribeInjections(kafkajsConfig); diff --git a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts index e186136d05d1..5193072808b9 100644 --- a/packages/server-utils/src/orchestrion/config/lru-memoizer.ts +++ b/packages/server-utils/src/orchestrion/config/lru-memoizer.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const lruMemoizerConfig = [ { @@ -12,3 +13,5 @@ export const lruMemoizerConfig = [ export const lruMemoizerChannels = { LRU_MEMOIZER_LOAD: 'orchestrion:lru-memoizer:load', } as const; + +export const lruMemoizerSubscribeInjection = toSubscribeInjections(lruMemoizerConfig); diff --git a/packages/server-utils/src/orchestrion/config/mysql.ts b/packages/server-utils/src/orchestrion/config/mysql.ts index c686f435a9f7..2a720b1fa189 100644 --- a/packages/server-utils/src/orchestrion/config/mysql.ts +++ b/packages/server-utils/src/orchestrion/config/mysql.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const mysqlConfig = [ { @@ -11,3 +12,5 @@ export const mysqlConfig = [ export const mysqlChannels = { MYSQL_QUERY: 'orchestrion:mysql:query', } as const; + +export const mysqlSubscribeInjection = toSubscribeInjections(mysqlConfig); diff --git a/packages/server-utils/src/orchestrion/config/mysql2.ts b/packages/server-utils/src/orchestrion/config/mysql2.ts index bac26e323053..f0d49bf530d4 100644 --- a/packages/server-utils/src/orchestrion/config/mysql2.ts +++ b/packages/server-utils/src/orchestrion/config/mysql2.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // Ports `@opentelemetry/instrumentation-mysql2` (which patches `query`/`execute` on the connection // prototype) to orchestrion channel injection. @@ -46,3 +47,5 @@ export const mysql2Channels = { MYSQL2_QUERY: 'orchestrion:mysql2:query', MYSQL2_EXECUTE: 'orchestrion:mysql2:execute', } as const; + +export const mysql2SubscribeInjection = toSubscribeInjections(mysql2Config); diff --git a/packages/server-utils/src/orchestrion/config/openai.ts b/packages/server-utils/src/orchestrion/config/openai.ts index d29c12623e24..9055cf0bb1ab 100644 --- a/packages/server-utils/src/orchestrion/config/openai.ts +++ b/packages/server-utils/src/orchestrion/config/openai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const openaiConfig = [ // OpenAI chat completions. `Completions.create` returns a thenable `APIPromise` with no callback arg, @@ -35,3 +36,5 @@ export const openaiChannels = { OPENAI_CHAT: 'orchestrion:openai:chat', OPENAI_EMBEDDINGS: 'orchestrion:openai:embeddings', } as const; + +export const openaiSubscribeInjection = toSubscribeInjections(openaiConfig); diff --git a/packages/server-utils/src/orchestrion/config/pg.ts b/packages/server-utils/src/orchestrion/config/pg.ts index d000e423068a..9be137a02653 100644 --- a/packages/server-utils/src/orchestrion/config/pg.ts +++ b/packages/server-utils/src/orchestrion/config/pg.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const pgConfig = [ // `pg` (node-postgres). @@ -46,3 +47,5 @@ export const pgChannels = { PG_CONNECT: 'orchestrion:pg:connect', PGPOOL_CONNECT: 'orchestrion:pg-pool:connect', } as const; + +export const pgSubscribeInjection = toSubscribeInjections(pgConfig); diff --git a/packages/server-utils/src/orchestrion/config/postgres.ts b/packages/server-utils/src/orchestrion/config/postgres.ts index 245a939cd7e7..2af4ff5472f0 100644 --- a/packages/server-utils/src/orchestrion/config/postgres.ts +++ b/packages/server-utils/src/orchestrion/config/postgres.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; // postgres.js (`postgres` npm package, v3.x). Named after the npm package; // `postgres` doesn't collide with `pg.ts` (that file instruments `pg`/`pg-pool`). @@ -53,3 +54,5 @@ export const postgresJsChannels = { POSTGRESJS_EXECUTE: 'orchestrion:postgres:execute', POSTGRESJS_CONNECT: 'orchestrion:postgres:connect', } as const; + +export const postgresJsSubscribeInjection = toSubscribeInjections(postgresJsConfig); diff --git a/packages/server-utils/src/orchestrion/config/redis.ts b/packages/server-utils/src/orchestrion/config/redis.ts index 55db5e45c4e8..d156bb448002 100644 --- a/packages/server-utils/src/orchestrion/config/redis.ts +++ b/packages/server-utils/src/orchestrion/config/redis.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const redisConfig = [ // redis `>=2.6.0 <4` (standalone `redis`). `internal_send_command` is an @@ -71,3 +72,5 @@ export const redisChannels = { NODE_REDIS_PIPELINE: 'orchestrion:@redis/client:pipeline', NODE_REDIS_BATCH: 'orchestrion:@redis/client:batch', } as const; + +export const redisSubscribeInjection = toSubscribeInjections(redisConfig); diff --git a/packages/server-utils/src/orchestrion/config/subscribe-injection.ts b/packages/server-utils/src/orchestrion/config/subscribe-injection.ts new file mode 100644 index 000000000000..129fc637feaf --- /dev/null +++ b/packages/server-utils/src/orchestrion/config/subscribe-injection.ts @@ -0,0 +1,54 @@ +import type { InstrumentationConfig } from '@apm-js-collab/code-transformer'; + +/** + * Name shared by the `Program` injection configs (their `transform` field) and + * the custom transform registered on the bundler plugin (its `customTransforms` + * key). Any unique string works — it only has to match on both sides. + * + * Lives in this dependency-free leaf so the per-library config files can build + * their injection configs without importing the transform implementation (which + * pulls in `meriyah` and must never reach the runtime `--import` path). + */ +export const SUBSCRIBE_TRANSFORM_NAME = 'sentrySubscribeOrchestrionChannel'; + +/** + * Turn a library's channel-publishing configs into the `Program`-matching + * injection configs that make each instrumented file self-register its channel + * subscriber (via the {@link SUBSCRIBE_TRANSFORM_NAME} custom transform). + * + * Emits one injection per distinct instrumented file (deduped by module + * matcher), so the subscribe snippet lands in exactly the files that receive + * channels and inherits their precise version ranges. `channelName` carries the + * package name (not a real channel — nothing is wrapped here) so the transform + * can look up which subscriber to import. + * + * Co-located with each library's config (e.g. `mysqlSubscribeInjection`) but + * kept OUT of `SENTRY_INSTRUMENTATIONS`: the runtime `--import` hook consumes + * that list and can't register the custom transform, and an unregistered + * `transform` makes the code-transformer drop the whole file. They are + * aggregated separately into `SUBSCRIBE_INJECTIONS` and only handed to a bundler + * plugin that opts in (and registers the transform). + */ +export function toSubscribeInjections(configs: InstrumentationConfig[]): InstrumentationConfig[] { + const seen = new Set(); + const injections: InstrumentationConfig[] = []; + + for (const { module } of configs) { + const key = `${module.name}\0${module.versionRange}\0${String(module.filePath)}`; + + if (seen.has(key)) { + continue; + } + + seen.add(key); + + injections.push({ + channelName: module.name, + module, + astQuery: 'Program', + transform: SUBSCRIBE_TRANSFORM_NAME, + }); + } + + return injections; +} diff --git a/packages/server-utils/src/orchestrion/config/vercel-ai.ts b/packages/server-utils/src/orchestrion/config/vercel-ai.ts index 3c7d15fa0be7..b1874f7bac75 100644 --- a/packages/server-utils/src/orchestrion/config/vercel-ai.ts +++ b/packages/server-utils/src/orchestrion/config/vercel-ai.ts @@ -1,4 +1,5 @@ import type { InstrumentationConfig } from '..'; +import { toSubscribeInjections } from './subscribe-injection'; export const vercelAiConfig = [ // Vercel AI v6: mirror the v7 native `ai:telemetry` channel by injecting @@ -68,3 +69,5 @@ function vercelAiEntries( functionQuery: { functionName, kind }, })); } + +export const vercelAiSubscribeInjection = toSubscribeInjections(vercelAiConfig); diff --git a/packages/server-utils/test/orchestrion/subscribeInjection.test.ts b/packages/server-utils/test/orchestrion/subscribeInjection.test.ts new file mode 100644 index 000000000000..bbc8c7eaa5ab --- /dev/null +++ b/packages/server-utils/test/orchestrion/subscribeInjection.test.ts @@ -0,0 +1,120 @@ +import { createCodeTransformer } from '@apm-js-collab/code-transformer-bundler-plugins/core'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + CHANNEL_INTEGRATION_DEFINITIONS, + subscriberExportForModule, +} from '../../src/orchestrion/config/channel-integration-definitions'; +import { orchestrionTransformOptions } from '../../src/orchestrion/bundler/options'; + +// The code transformer reads the instrumented package's version from its +// on-disk `package.json`, so each test package needs a real directory. +function makePackage(root: string, name: string, version: string, type?: 'module' | 'commonjs'): void { + const dir = join(root, 'node_modules', name); + mkdirSync(join(dir, 'lib'), { recursive: true }); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version, ...(type ? { type } : {}) })); +} + +describe('channel integration definitions', () => { + it('maps every module to a defined subscriber export', () => { + expect(subscriberExportForModule('mysql')).toBe('mysqlChannelIntegration'); + expect(subscriberExportForModule('pg')).toBe('postgresChannelIntegration'); + expect(subscriberExportForModule('pg-pool')).toBe('postgresChannelIntegration'); + expect(subscriberExportForModule('@redis/client')).toBe('redisChannelIntegration'); + expect(subscriberExportForModule('not-a-package')).toBeUndefined(); + }); + + it('references only real named exports of @sentry/server-utils/orchestrion', async () => { + const barrel = await import('../../src/orchestrion/index'); + for (const { exportName } of CHANNEL_INTEGRATION_DEFINITIONS) { + expect(typeof (barrel as Record)[exportName]).toBe('function'); + } + }); +}); + +describe('subscribe-injection transform option', () => { + let root: string; + + beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'orch-subscribe-')); + makePackage(root, 'mysql', '2.18.1', 'commonjs'); + makePackage(root, 'pg', '8.11.0', 'module'); + }); + + afterAll(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('adds Program injection configs and the custom transform only when opted in', () => { + const off = orchestrionTransformOptions({}); + expect(off.customTransforms).toEqual({}); + expect(off.instrumentations.some(i => i.astQuery === 'Program' && i.transform)).toBe(false); + + const on = orchestrionTransformOptions({ injectChannelSubscribers: true }); + expect(Object.keys(on.customTransforms || {})).toContain('sentrySubscribeOrchestrionChannel'); + expect(on.instrumentations.some(i => i.astQuery === 'Program' && i.transform)).toBe(true); + }); + + it('injects a CJS marker-push importing only that package factory, after "use strict"', () => { + const t = createCodeTransformer(orchestrionTransformOptions({ injectChannelSubscribers: true })); + const code = + "'use strict';\nfunction Connection(){}\nConnection.prototype.query = function query(sql, cb){ return cb(); };\n"; + const result = t.transform(code, join(root, 'node_modules/mysql/lib/Connection.js')); + t.dispose?.(); + + expect(result).not.toBeNull(); + expect(result!.code.split('\n')[0]).toContain("'use strict'"); + expect(result!.code).toMatch( + /const\s*\{\s*mysqlChannelIntegration\s*\}\s*=\s*require\(["']@sentry\/server-utils\/orchestrion["']\)/, + ); + expect(result!.code).toContain( + 'globalThis.__SENTRY_ORCHESTRION__.integrations.set("mysqlChannelIntegration", mysqlChannelIntegration)', + ); + // Also registers live on an existing client, so a module that loads AFTER + // `init()` (mysql loads its instrumented file lazily) still subscribes for + // the in-flight request instead of only the next `init()`. + expect(result!.code).toMatch( + /const\s*\{\s*getClient:\s*__sentryGetClient\s*\}\s*=\s*require\(["']@sentry\/core["']\)/, + ); + expect(result!.code).toContain('__sentryGetClient()?.addIntegration(mysqlChannelIntegration())'); + // It imports ONLY the mysql factory — no central dispatch pulling in others. + expect(result!.code).not.toContain('pgChannelIntegration'); + expect(result!.code).not.toContain('subscribeOrchestrionChannel'); + // The real channel-publishing transform still ran alongside the injection. + expect(result!.code).toContain('orchestrion:mysql:query'); + }); + + it('injects an ESM marker-push for an instrumented ESM module', () => { + const t = createCodeTransformer(orchestrionTransformOptions({ injectChannelSubscribers: true })); + const result = t.transform( + 'export class Client { query(){} connect(){} }\n', + join(root, 'node_modules/pg/lib/client.js'), + ); + t.dispose?.(); + + expect(result).not.toBeNull(); + expect(result!.code).toMatch( + /import\s*\{\s*postgresChannelIntegration\s*\}\s*from\s*["']@sentry\/server-utils\/orchestrion["']/, + ); + expect(result!.code).toMatch(/import\s*\{\s*getClient as __sentryGetClient\s*\}\s*from\s*["']@sentry\/core["']/); + expect(result!.code).toContain( + 'globalThis.__SENTRY_ORCHESTRION__.integrations.set("postgresChannelIntegration", postgresChannelIntegration)', + ); + expect(result!.code).toContain('__sentryGetClient()?.addIntegration(postgresChannelIntegration())'); + }); + + it('registers the factory at most once per file', () => { + const t = createCodeTransformer(orchestrionTransformOptions({ injectChannelSubscribers: true })); + // `pg`'s `lib/client.js` is matched by both the `query` and `connect` configs. + const result = t.transform( + 'export class Client { query(){} connect(){} }\n', + join(root, 'node_modules/pg/lib/client.js'), + ); + t.dispose?.(); + + const registrations = result!.code.match(/integrations\.set\("postgresChannelIntegration"/g) ?? []; + expect(registrations).toHaveLength(1); + }); +}); From 85d017042ca54d9c6bb36408573fd458864efe48 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 14 Jul 2026 15:31:34 +0200 Subject: [PATCH 2/4] feat(cloudflare): Instrument bundled packages via orchestrion diagnostics channels Integrate the server-utils orchestrion channel-integration mechanism into the Cloudflare SDK, so bundled npm packages (e.g. `mysql`) are traced without monkey-patching, which workerd doesn't support anyway. - Add the `@sentry/cloudflare/vite` plugin, which runs the orchestrion transform (injecting `diagnostics_channel.tracingChannel` calls) and injects a registration module that puts the channel-subscriber integrations on the global marker. Workers built without the plugin don't ship that code. - `getDefaultIntegrations()` reads the registered integrations from the marker at `init()`, activating only those whose module was actually transformed, and warns (debug only, once per isolate) about modules whose transform failed. - Add a Cloudflare + MySQL e2e test app exercising real `db` spans in workerd. Co-Authored-By: Claude Opus 4.8 --- .../cloudflare-orchestrion-mysql/.gitignore | 2 + .../docker-compose.yml | 18 +++++ .../global-setup.mjs | 14 ++++ .../global-teardown.mjs | 12 +++ .../cloudflare-orchestrion-mysql/package.json | 34 +++++++++ .../playwright.config.ts | 22 ++++++ .../cloudflare-orchestrion-mysql/src/env.d.ts | 3 + .../cloudflare-orchestrion-mysql/src/index.ts | 73 +++++++++++++++++++ .../start-event-proxy.mjs | 6 ++ .../tests/mysql.test.ts | 57 +++++++++++++++ .../tsconfig.json | 16 ++++ .../vite.config.ts | 14 ++++ .../wrangler.jsonc | 7 ++ packages/cloudflare/package.json | 4 + packages/cloudflare/rollup.npm.config.mjs | 2 +- packages/cloudflare/src/sdk.ts | 19 +++++ packages/cloudflare/src/vite/index.ts | 70 ++++++++++++++++++ packages/cloudflare/test/sdk.test.ts | 42 ++++++++++- 18 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/.gitignore create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/docker-compose.yml create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-setup.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-teardown.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/playwright.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/env.d.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/index.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/start-event-proxy.mjs create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tests/mysql.test.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tsconfig.json create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/vite.config.ts create mode 100644 dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/wrangler.jsonc create mode 100644 packages/cloudflare/src/vite/index.ts diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/.gitignore b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/.gitignore new file mode 100644 index 000000000000..37cbd6339404 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/.gitignore @@ -0,0 +1,2 @@ +dist +.wrangler diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/docker-compose.yml b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/docker-compose.yml new file mode 100644 index 000000000000..e07e3e50ccd6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/docker-compose.yml @@ -0,0 +1,18 @@ +services: + db: + image: mysql:8.0 + restart: always + container_name: e2e-tests-cloudflare-orchestrion-mysql + # The `mysql` 2.x driver doesn't speak MySQL 8's default + # `caching_sha2_password` auth, so force the legacy plugin. + command: ['--default-authentication-plugin=mysql_native_password'] + ports: + - '3306:3306' + environment: + MYSQL_ROOT_PASSWORD: password + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 -uroot -ppassword'] + interval: 2s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-setup.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-setup.mjs new file mode 100644 index 000000000000..9ba25cd71638 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-setup.mjs @@ -0,0 +1,14 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalSetup() { + // Start MySQL via Docker Compose. `--wait` blocks until the healthcheck in + // docker-compose.yml passes, so the worker can connect on the first request. + execSync('docker compose up -d --wait', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-teardown.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-teardown.mjs new file mode 100644 index 000000000000..2742279431ad --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/global-teardown.mjs @@ -0,0 +1,12 @@ +import { execSync } from 'child_process'; +import { dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export default async function globalTeardown() { + execSync('docker compose down --volumes', { + cwd: __dirname, + stdio: 'inherit', + }); +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json new file mode 100644 index 000000000000..7b8f571c423d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/package.json @@ -0,0 +1,34 @@ +{ + "name": "cloudflare-orchestrion-mysql", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "wrangler dev --var \"E2E_TEST_DSN:$E2E_TEST_DSN\" --log-level=$(test $CI && echo 'none' || echo 'log')", + "test": "playwright test", + "typecheck": "tsc --noEmit", + "test:build": "pnpm install && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" + }, + "dependencies": { + "@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz", + "mysql": "2.18.1" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.35.0", + "@playwright/test": "~1.56.0", + "@cloudflare/workers-types": "^4.20260629.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "@types/node": "^24.12.4", + "typescript": "^5.5.2", + "vite": "7.3.2", + "wrangler": "^4.61.0", + "ws": "^8.18.3" + }, + "volta": { + "node": "24.15.0", + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/playwright.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/playwright.config.ts new file mode 100644 index 000000000000..d6e6fa435f6c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/playwright.config.ts @@ -0,0 +1,22 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +// `vite build` (where the Sentry plugin's orchestrion transform runs) produces +// the worker; `pnpm preview` (`wrangler dev`, following the vite plugin's +// `.wrangler/deploy` redirect to the built output) serves it. `globalSetup` +// spins up the MySQL container the worker connects to. +const config = getPlaywrightConfig( + { + startCommand: 'pnpm preview', + port: 8787, + }, + { + workers: '100%', + retries: 0, + }, +); + +export default { + ...config, + globalSetup: './global-setup.mjs', + globalTeardown: './global-teardown.mjs', +}; diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/env.d.ts b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/env.d.ts new file mode 100644 index 000000000000..eb80bafb4834 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/env.d.ts @@ -0,0 +1,3 @@ +interface Env { + E2E_TEST_DSN: string; +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/index.ts b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/index.ts new file mode 100644 index 000000000000..2cccb3c26c64 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/src/index.ts @@ -0,0 +1,73 @@ +import * as Sentry from '@sentry/cloudflare'; +// @ts-ignore -- `mysql` ships no type declarations; only needed at runtime. +import mysql from 'mysql'; + +// The `@sentry/cloudflare/vite` plugin's orchestrion transform injects the +// `orchestrion:mysql:query` diagnostics channel into the bundled `mysql` +// package at build time. The SDK detects the injection and subscribes to the +// channel, so the queries below produce `db` spans with no OTel require-hook — +// which wouldn't work in workerd anyway. + +interface Connection { + query(sql: string, cb: (err: unknown, results?: unknown) => void): void; + end(cb?: (err: unknown) => void): void; + on(event: string, cb: (err: unknown) => void): void; +} + +interface MysqlModule { + createConnection(opts: { host: string; port: number; user: string; password: string }): Connection; +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.E2E_TEST_DSN, + environment: 'qa', + tunnel: 'http://localhost:3031/', + tracesSampleRate: 1.0, + transportOptions: { + bufferSize: 1000, + }, + }), + { + async fetch(request: Request): Promise { + const url = new URL(request.url); + + // Runs two queries, the second NESTED inside the first's callback. mysql + // dispatches that callback from its socket data handler (a fresh async + // context), so the nested query's span only lands on this request's + // http.server transaction if the channel subscriber restored the parent + // span across that async boundary. + if (url.pathname === '/test-mysql') { + // The connection is created inside the handler: workerd forbids I/O in + // global scope, and mysql opens its socket lazily on the first query. + const connection = (mysql as MysqlModule).createConnection({ + host: '127.0.0.1', + port: 3306, + user: 'root', + password: 'password', + }); + + // Swallow connection-level errors so a socket hiccup doesn't become an + // uncaught exception that fails the request unrelated to the spans. + connection.on('error', () => { + // no-op + }); + + await new Promise((resolve, reject) => { + connection.query('SELECT 1 + 1 AS solution', (err: unknown) => { + if (err) return reject(err); + connection.query('SELECT NOW()', (err2: unknown) => { + connection.end(); + if (err2) return reject(err2); + resolve(); + }); + }); + }); + + return Response.json({ status: 'ok' }); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/start-event-proxy.mjs new file mode 100644 index 000000000000..ebb560fb9f3c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'cloudflare-orchestrion-mysql', +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tests/mysql.test.ts b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tests/mysql.test.ts new file mode 100644 index 000000000000..e59c3ab11ae2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tests/mysql.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +test('a real mysql query emits a db span with orchestrion-channel attributes', async ({ baseURL }) => { + // Each incoming request gets a Sentry http.server transaction; the mysql + // queries run inside it, so their db spans attach to it. The + // `orchestrion:mysql:query` channel was injected into the bundled `mysql` + // package at build time by `@sentry/cloudflare/vite`, and the Cloudflare SDK + // subscribes to it once it detects the injection. + const transactionPromise = waitForTransaction('cloudflare-orchestrion-mysql', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/test-mysql') && + (event.spans?.some(span => span.op === 'db') ?? false) + ); + }); + + const res = await fetch(`${baseURL}/test-mysql`); + expect(res.status).toBe(200); + await res.json(); + + const transaction = await transactionPromise; + const dbSpans = transaction.spans!.filter(span => span.op === 'db'); + + const firstQuery = dbSpans.find(span => span.description === 'SELECT 1 + 1 AS solution'); + expect(firstQuery).toBeDefined(); + expect(firstQuery!.data?.['sentry.origin']).toBe('auto.db.orchestrion.mysql'); + expect(firstQuery!.data?.['db.system']).toBe('mysql'); + expect(firstQuery!.data?.['db.statement']).toBe('SELECT 1 + 1 AS solution'); + expect(firstQuery!.data?.['net.peer.name']).toBe('127.0.0.1'); + expect(firstQuery!.data?.['net.peer.port']).toBe(3306); + expect(firstQuery!.data?.['db.user']).toBe('root'); +}); + +test('a nested query lands on the same transaction (async context restored)', async ({ baseURL }) => { + // The second query runs inside the first query's callback — i.e. across + // mysql's async socket-callback dispatch. Both spans appearing on the SAME + // http.server transaction proves the channel subscriber restored the parent + // span across that async boundary (otherwise the nested query would start its + // own trace and never join this transaction). + const transactionPromise = waitForTransaction('cloudflare-orchestrion-mysql', event => { + return ( + event?.contexts?.trace?.op === 'http.server' && + (event.request?.url ?? '').includes('/test-mysql') && + (event.spans?.filter(span => span.op === 'db').length ?? 0) >= 2 + ); + }); + + const res = await fetch(`${baseURL}/test-mysql`); + expect(res.status).toBe(200); + await res.json(); + + const transaction = await transactionPromise; + const descriptions = transaction.spans!.filter(span => span.op === 'db').map(span => span.description); + expect(descriptions).toContain('SELECT 1 + 1 AS solution'); + expect(descriptions).toContain('SELECT NOW()'); +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tsconfig.json b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tsconfig.json new file mode 100644 index 000000000000..0bd378d7c8f8 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2021", + "lib": ["es2021"], + "module": "es2022", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types", "node"], + "skipLibCheck": true, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true + }, + "include": ["src/**/*"] +} diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/vite.config.ts b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/vite.config.ts new file mode 100644 index 000000000000..541d36ac0a61 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/vite.config.ts @@ -0,0 +1,14 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + useDiagnosticsChannelInjection: true, + }, + }), + ], +}); diff --git a/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/wrangler.jsonc b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/wrangler.jsonc new file mode 100644 index 000000000000..dd811d6c177c --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/cloudflare-orchestrion-mysql/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cloudflare-orchestrion-mysql", + "main": "src/index.ts", + "compatibility_date": "2026-06-29", + "compatibility_flags": ["nodejs_compat"], +} diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 5e965bf529f1..33a0228e9157 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -46,6 +46,10 @@ "types": "./build/types/nodejs_compat/index.d.ts", "default": "./build/cjs/nodejs_compat/index.js" } + }, + "./vite": { + "types": "./build/types/vite/index.d.ts", + "import": "./build/esm/vite/index.js" } }, "typesVersions": { diff --git a/packages/cloudflare/rollup.npm.config.mjs b/packages/cloudflare/rollup.npm.config.mjs index 63407d8629dd..9b674514ca4f 100644 --- a/packages/cloudflare/rollup.npm.config.mjs +++ b/packages/cloudflare/rollup.npm.config.mjs @@ -2,6 +2,6 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu export default makeNPMConfigVariants( makeBaseNPMConfig({ - entrypoints: ['src/index.ts', 'src/nodejs_compat/index.ts'], + entrypoints: ['src/index.ts', 'src/nodejs_compat/index.ts', 'src/vite/index.ts'], }), ); diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index d74ab861bb74..7f752e736808 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -21,6 +21,18 @@ import { setupOpenTelemetryTracer } from './opentelemetry/tracer'; import { makeCloudflareTransport } from './transport'; import { defaultStackParser } from './vendor/stacktrace'; +/** + * Exact copy of the function from `@sentry/server-utils/orchestrion`. + * This is to avoid importing from server-utils directly into the Cloudflare SDK. + * TODO(v11): Use `@sentry/server-utils/orchestrion` once we move to `nodejs_compat` by default + */ +function getRegisteredChannelIntegrations(): Integration[] { + const marker = globalThis.__SENTRY_ORCHESTRION__; + const registered = marker?.integrations || []; + + return registered.map(factory => factory()); +} + /** Get the default integrations for the Cloudflare SDK. */ export function getDefaultIntegrations(options: CloudflareOptions): Integration[] { // TODO(v11): Drop this transitional gating and let `requestDataIntegration` rely on the resolved @@ -44,6 +56,13 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[ httpServerIntegration(), requestDataIntegration(cookiesEnabled ? undefined : { include: { cookies: false } }), consoleIntegration(), + // The orchestrion diagnostics-channel subscribers (mysql, pg, …). The + // `@sentry/cloudflare/vite` plugin injects the channels at build time and + // adds a generated registration module to the bundle, which puts the + // subscriber factories on the global marker. Read from there instead of + // importing them so bundles built without the plugin — where the channels + // would never fire — don't ship the code. + ...getRegisteredChannelIntegrations(), ]; } diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts new file mode 100644 index 000000000000..ee92dec763cf --- /dev/null +++ b/packages/cloudflare/src/vite/index.ts @@ -0,0 +1,70 @@ +// Published ESM-only via the `@sentry/cloudflare/vite` subpath export: +// `@sentry/server-utils/orchestrion/vite` exposes no `require` condition, so a +// CJS entry here would fail at resolution time (ERR_PACKAGE_PATH_NOT_EXPORTED). +// The CJS rollup variant still emits this file, but `package.json` doesn't +// expose it — same setup as `@sentry/server-utils/orchestrion/vite` itself. +import { sentryOrchestrionPlugin } from '@sentry/server-utils/orchestrion/vite'; + +/** + * Options for {@link sentryCloudflareVitePlugin}. + */ +export interface SentryCloudflareVitePluginOptions { + /** + * Experimental options that may change or be removed without notice. + */ + _experimental?: { + /** + * Enables build-time automatic instrumentation of supported dependencies + * (e.g. database clients like `mysql`) so the Sentry Cloudflare SDK can + * trace them without monkey-patching, which wouldn't work in workerd anyway. + * + * When enabled, the plugin injects `diagnostics_channel.tracingChannel` + * calls into the bundled packages and adds a generated registration module + * to the bundle, which the SDK picks up in `Sentry.withSentry()`. Both + * `vite build` and `vite dev` are instrumented. + * + * @default false + * @experimental May change or be removed in any release. + */ + useDiagnosticsChannelInjection?: boolean; + }; +} + +/** + * Sentry Vite plugin for Cloudflare Workers. + * + * Add this plugin to your Vite configuration to enable additional Sentry + * instrumentation for Cloudflare Workers built with Vite. Configure the Sentry + * SDK in your Worker as usual with `Sentry.withSentry()`. + * + * Currently, the only functionality is the experimental + * `_experimental.useDiagnosticsChannelInjection` option, which traces supported + * dependencies (such as database clients) without changing your application + * code. Without it, the plugin is a no-op. + * + * @example + * ```ts + * // vite.config.ts + * import { cloudflare } from '@cloudflare/vite-plugin'; + * import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; + * import { defineConfig } from 'vite'; + * + * export default defineConfig({ + * plugins: [ + * cloudflare(), + * sentryCloudflareVitePlugin({ + * _experimental: { + * useDiagnosticsChannelInjection: true, + * }, + * }), + * ], + * }); + * ``` + */ +export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOptions = {}) { + if (!options._experimental?.useDiagnosticsChannelInjection) { + return []; + } + + return sentryOrchestrionPlugin({ registerIntegrations: true }); +} diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index 54b8ee609cda..f7f2228e5e1a 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -1,9 +1,9 @@ import * as SentryCore from '@sentry/core'; import type { Integration } from '@sentry/core'; import { getClient } from '@sentry/core'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { CloudflareClient } from '../src/client'; -import { init } from '../src/sdk'; +import { getDefaultIntegrations, init } from '../src/sdk'; import { resetSdk } from './testUtils'; import { spanStreamingIntegration } from '../src/'; @@ -60,3 +60,41 @@ describe('init', () => { expect((integrations?.[0] as MarkedIntegration)?._custom).toBe(true); }); }); + +describe('getDefaultIntegrations', () => { + afterEach(() => { + delete globalThis.__SENTRY_ORCHESTRION__; + }); + + test('does not add orchestrion channel integrations when none were registered', () => { + delete globalThis.__SENTRY_ORCHESTRION__; + + const names = getDefaultIntegrations({}).map(i => i.name); + + expect(names).not.toContain('Mysql'); + expect(names).not.toContain('Postgres'); + expect(names).not.toContain('LruMemoizer'); + }); + + test('does not add orchestrion channel integrations when only the bundler marker is set', () => { + globalThis.__SENTRY_ORCHESTRION__ = { bundler: true }; + + const names = getDefaultIntegrations({}).map(i => i.name); + + expect(names).not.toContain('Mysql'); + expect(names).not.toContain('Postgres'); + expect(names).not.toContain('LruMemoizer'); + }); + + test('adds orchestrion channel integrations registered by the injected registration module', async () => { + // Mirror what the module the vite plugin injects into bundles does at runtime. + const { registerChannelIntegrations } = await import('@sentry/server-utils/orchestrion'); + registerChannelIntegrations(); + + const names = getDefaultIntegrations({}).map(i => i.name); + + expect(names).toContain('Mysql'); + expect(names).toContain('Postgres'); + expect(names).toContain('LruMemoizer'); + }); +}); From dbd579539f8d3d325dfc06d3341a6458060bd24d Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 14 Jul 2026 15:31:34 +0200 Subject: [PATCH 3/4] feat(cloudflare): Instrument bundled packages via orchestrion diagnostics channels Integrate the server-utils orchestrion channel-integration mechanism into the Cloudflare SDK, so bundled npm packages (e.g. `mysql`) are traced without monkey-patching, which workerd doesn't support anyway. - Add the `@sentry/cloudflare/vite` plugin, which runs the orchestrion transform (injecting `diagnostics_channel.tracingChannel` calls) and injects a registration module that puts the channel-subscriber integrations on the global marker. Workers built without the plugin don't ship that code. - `getDefaultIntegrations()` reads the registered integrations from the marker at `init()`, activating only those whose module was actually transformed, and warns (debug only, once per isolate) about modules whose transform failed. - Add a Cloudflare + MySQL e2e test app exercising real `db` spans in workerd. Co-Authored-By: Claude Opus 4.8 --- packages/cloudflare/src/sdk.ts | 28 ++++++++++++++++----------- packages/cloudflare/src/vite/index.ts | 9 +++++---- packages/cloudflare/test/sdk.test.ts | 18 +++++++++++++---- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/cloudflare/src/sdk.ts b/packages/cloudflare/src/sdk.ts index 7f752e736808..2bbd704e6004 100644 --- a/packages/cloudflare/src/sdk.ts +++ b/packages/cloudflare/src/sdk.ts @@ -5,6 +5,7 @@ import { dedupeIntegration, functionToStringIntegration, getIntegrationsToSetup, + GLOBAL_OBJ, inboundFiltersIntegration, initAndBind, linkedErrorsIntegration, @@ -22,15 +23,20 @@ import { makeCloudflareTransport } from './transport'; import { defaultStackParser } from './vendor/stacktrace'; /** - * Exact copy of the function from `@sentry/server-utils/orchestrion`. - * This is to avoid importing from server-utils directly into the Cloudflare SDK. - * TODO(v11): Use `@sentry/server-utils/orchestrion` once we move to `nodejs_compat` by default + * Instantiate the channel-subscriber factories the `@sentry/cloudflare/vite` + * plugin registered on the global marker. The plugin splices a small snippet + * into each instrumented module that `.set`s its factory here (keyed by export + * name), so the marker holds one factory per package actually bundled. + * + * The marker is read directly instead of importing the factories, so a worker + * built without the plugin — where the channels never fire — ships none of this + * code. + * TODO(v11): Use `@sentry/server-utils/orchestrion` once we move to `nodejs_compat` by default. */ function getRegisteredChannelIntegrations(): Integration[] { - const marker = globalThis.__SENTRY_ORCHESTRION__; - const registered = marker?.integrations || []; + const registered = GLOBAL_OBJ.__SENTRY_ORCHESTRION__?.integrations; - return registered.map(factory => factory()); + return registered ? [...registered.values()].map(factory => factory()) : []; } /** Get the default integrations for the Cloudflare SDK. */ @@ -57,11 +63,11 @@ export function getDefaultIntegrations(options: CloudflareOptions): Integration[ requestDataIntegration(cookiesEnabled ? undefined : { include: { cookies: false } }), consoleIntegration(), // The orchestrion diagnostics-channel subscribers (mysql, pg, …). The - // `@sentry/cloudflare/vite` plugin injects the channels at build time and - // adds a generated registration module to the bundle, which puts the - // subscriber factories on the global marker. Read from there instead of - // importing them so bundles built without the plugin — where the channels - // would never fire — don't ship the code. + // `@sentry/cloudflare/vite` plugin injects the channels at build time and, + // next to each, a snippet that registers the matching subscriber factory on + // the global marker. Read from there instead of importing them so bundles + // built without the plugin — where the channels would never fire — don't + // ship the code. ...getRegisteredChannelIntegrations(), ]; } diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index ee92dec763cf..113d18193257 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -19,9 +19,10 @@ export interface SentryCloudflareVitePluginOptions { * trace them without monkey-patching, which wouldn't work in workerd anyway. * * When enabled, the plugin injects `diagnostics_channel.tracingChannel` - * calls into the bundled packages and adds a generated registration module - * to the bundle, which the SDK picks up in `Sentry.withSentry()`. Both - * `vite build` and `vite dev` are instrumented. + * calls into the bundled packages and, next to each, a snippet that + * registers the matching Sentry channel-subscriber factory on the global + * marker, which the SDK picks up in `Sentry.withSentry()`. Both `vite build` + * and `vite dev` are instrumented. * * @default false * @experimental May change or be removed in any release. @@ -66,5 +67,5 @@ export function sentryCloudflareVitePlugin(options: SentryCloudflareVitePluginOp return []; } - return sentryOrchestrionPlugin({ registerIntegrations: true }); + return sentryOrchestrionPlugin({ injectChannelSubscribers: true }); } diff --git a/packages/cloudflare/test/sdk.test.ts b/packages/cloudflare/test/sdk.test.ts index f7f2228e5e1a..09efdd96f3d6 100644 --- a/packages/cloudflare/test/sdk.test.ts +++ b/packages/cloudflare/test/sdk.test.ts @@ -86,10 +86,20 @@ describe('getDefaultIntegrations', () => { expect(names).not.toContain('LruMemoizer'); }); - test('adds orchestrion channel integrations registered by the injected registration module', async () => { - // Mirror what the module the vite plugin injects into bundles does at runtime. - const { registerChannelIntegrations } = await import('@sentry/server-utils/orchestrion'); - registerChannelIntegrations(); + test('adds orchestrion channel integrations registered on the marker by injected modules', async () => { + // Mirror what the snippet the vite plugin injects into each instrumented + // module does at runtime: import its factory and `.set` it on the marker map, + // keyed by export name (so a package split across files registers once). + const { mysqlChannelIntegration, postgresChannelIntegration, lruMemoizerChannelIntegration } = + await import('@sentry/server-utils/orchestrion'); + globalThis.__SENTRY_ORCHESTRION__ = { + bundler: true, + integrations: new Map([ + ['mysqlChannelIntegration', mysqlChannelIntegration], + ['postgresChannelIntegration', postgresChannelIntegration], + ['lruMemoizerChannelIntegration', lruMemoizerChannelIntegration], + ]), + }; const names = getDefaultIntegrations({}).map(i => i.name); From 859e5fe2828712c0ec8e27d6eaf93bf6f85255a3 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 14 Jul 2026 16:31:08 +0200 Subject: [PATCH 4/4] feat(cloudflare): Read wrangler config and resolve the Sentry options module Add the building blocks the auto-instrument Vite plugin will use, with no wiring into a plugin yet: - `wranglerConfig`: locate and parse `wrangler.{json,jsonc,toml}`, returning the worker entry (`main`) and the configured Durable Object class names. - `instrumentFile`: find a conventional `instrument.server.{ts,js,mjs,cjs}` next to the entry and build the options import, falling back to an env-based callback when absent. - `defineCloudflareOptions`: identity helper that gives the options callback its type in that module. Adds `jsonc-parser`, `smol-toml`, and `magic-string` as dependencies. Co-Authored-By: Claude Opus 4.8 --- packages/cloudflare/package.json | 9 +- .../cloudflare/src/defineCloudflareOptions.ts | 46 ++++ packages/cloudflare/src/index.ts | 1 + .../cloudflare/src/vite/instrumentFile.ts | 53 ++++ .../cloudflare/src/vite/wranglerConfig.ts | 67 +++++ .../test/defineCloudflareOptions.test.ts | 28 ++ .../test/vite/wranglerConfig.test.ts | 250 ++++++++++++++++++ yarn.lock | 12 +- 8 files changed, 463 insertions(+), 3 deletions(-) create mode 100644 packages/cloudflare/src/defineCloudflareOptions.ts create mode 100644 packages/cloudflare/src/vite/instrumentFile.ts create mode 100644 packages/cloudflare/src/vite/wranglerConfig.ts create mode 100644 packages/cloudflare/test/defineCloudflareOptions.test.ts create mode 100644 packages/cloudflare/test/vite/wranglerConfig.test.ts diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 33a0228e9157..ddce243bb58b 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -66,14 +66,19 @@ "@opentelemetry/api": "^1.9.1", "@sentry/core": "10.66.0", "@sentry/node": "10.66.0", - "@sentry/server-utils": "10.66.0" + "@sentry/server-utils": "10.66.0", + "magic-string": "~0.30.21" }, "peerDependencies": { - "@cloudflare/workers-types": "^4.x || ^5.x" + "@cloudflare/workers-types": "^4.x || ^5.x", + "wrangler": "^4.x" }, "peerDependenciesMeta": { "@cloudflare/workers-types": { "optional": true + }, + "wrangler": { + "optional": true } }, "devDependencies": { diff --git a/packages/cloudflare/src/defineCloudflareOptions.ts b/packages/cloudflare/src/defineCloudflareOptions.ts new file mode 100644 index 000000000000..6e905dad7fa1 --- /dev/null +++ b/packages/cloudflare/src/defineCloudflareOptions.ts @@ -0,0 +1,46 @@ +import type { env as cloudflareEnv } from 'cloudflare:workers'; +import type { CloudflareOptions } from './client'; + +/** + * Define the Sentry options for a Cloudflare Worker in a dedicated module. + * + * This is the recommended way to configure the SDK when using the Vite plugin's + * auto-instrumentation: place an `instrument.server.{ts,js,mjs}` file next to + * the worker entry whose **default export** is the result of this function. The + * plugin picks it up automatically and hands it to `withSentry`. + * + * Unlike Node's `Sentry.init(...)`, the options cannot be applied at module + * load time on Cloudflare: the DSN and other settings typically come from the + * per-request `env`, which only exists inside the handler. Pass a callback to + * read from `env`, or a static object when no `env` access is needed — either + * way you get full type-checking and autocomplete on {@link CloudflareOptions}. + * + * At runtime this is a thin pass-through; it only normalizes a static object + * into a callback so the plugin always imports a `(env) => options` function. + * + * @example + * ```ts + * // src/instrument.server.ts + * import { defineCloudflareOptions } from '@sentry/cloudflare'; + * + * export default defineCloudflareOptions((env) => ({ + * dsn: env.SENTRY_DSN, + * tracesSampleRate: 1.0, + * })); + * ``` + * + * @example + * ```ts + * // Static options — no `env` access needed + * export default defineCloudflareOptions({ tracesSampleRate: 1.0 }); + * ``` + */ +export function defineCloudflareOptions( + optionsOrCallback: CloudflareOptions | ((env: Env) => CloudflareOptions | undefined), +): (env: Env) => CloudflareOptions | undefined { + if (typeof optionsOrCallback === 'function') { + return optionsOrCallback as (env: Env) => CloudflareOptions | undefined; + } + + return () => optionsOrCallback; +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts index 20a537c5b307..b93d9626bde9 100644 --- a/packages/cloudflare/src/index.ts +++ b/packages/cloudflare/src/index.ts @@ -117,6 +117,7 @@ export { } from '@sentry/core'; export { withSentry } from './withSentry'; +export { defineCloudflareOptions } from './defineCloudflareOptions'; export { instrumentDurableObjectWithSentry } from './durableobject'; export { sentryPagesPlugin } from './pages-plugin'; diff --git a/packages/cloudflare/src/vite/instrumentFile.ts b/packages/cloudflare/src/vite/instrumentFile.ts new file mode 100644 index 000000000000..ffd79ca0104c --- /dev/null +++ b/packages/cloudflare/src/vite/instrumentFile.ts @@ -0,0 +1,53 @@ +import { existsSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; + +// Fallback options callback used when no instrument file is present. Returning +// `undefined` makes the SDK read all configuration (DSN, release, environment, +// sample rate, …) from the worker's `env` at runtime. +export const ENV_FALLBACK_OPTIONS_FN = '() => undefined'; + +// Identifier the generated import binds the user's options module to. +const OPTIONS_IMPORT_IDENTIFIER = '__SENTRY_OPTIONS_CALLBACK__'; + +// Conventional, non-configurable name of the Sentry options module. It is +// looked up next to the worker entry file; its default export is the options +// callback `(env) => CloudflareOptions`. +const INSTRUMENT_FILE_BASENAME = 'instrument.server'; +const INSTRUMENT_FILE_EXTENSIONS = ['ts', 'mts', 'js', 'mjs', 'cjs']; + +/** + * Locate the conventional `instrument.server.*` module sitting next to the + * worker entry file. Returns its absolute path, or `undefined` when absent. + */ +export function resolveInstrumentFile(entryFilePath: string): string | undefined { + const dir = dirname(entryFilePath); + for (const ext of INSTRUMENT_FILE_EXTENSIONS) { + const candidate = resolve(dir, `${INSTRUMENT_FILE_BASENAME}.${ext}`); + if (existsSync(candidate)) return candidate; + } + return undefined; +} + +/** + * Build the `optionsFn` reference and `import` statement for the instrument + * module whose **default export** is the options callback + * `(env) => CloudflareOptions`. + * + * The import is emitted relative to `entryFilePath` because it is injected into + * the entry file's source. The file extension is kept: extensionless specifiers + * only resolve for extensions in Vite's default `resolve.extensions` (which + * excludes `.cjs`), and keeping it makes our probe order authoritative when + * several `instrument.server.*` files coexist. + */ +export function buildOptionsImport( + entryFilePath: string, + instrumentFilePath: string, +): { optionsFn: string; importStmt: string } { + let relativePath = relative(dirname(entryFilePath), instrumentFilePath).replace(/\\/g, '/'); + if (!relativePath.startsWith('.')) relativePath = `./${relativePath}`; + + return { + optionsFn: OPTIONS_IMPORT_IDENTIFIER, + importStmt: `import ${OPTIONS_IMPORT_IDENTIFIER} from '${relativePath}';\n`, + }; +} diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts new file mode 100644 index 000000000000..267e0fb10f4c --- /dev/null +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -0,0 +1,67 @@ +import { existsSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { type Unstable_Config, unstable_readConfig } from 'wrangler'; + +/** + * The slice of the wrangler configuration the auto-instrument plugin cares + * about. `main` is an absolute path (wrangler resolves it against the config + * file's directory). + */ +export interface WranglerConfig { + main?: string; + durableObjects: Array<{ name: string; className: string }>; +} + +/** + * Locate and resolve the wrangler configuration via wrangler's own + * `unstable_readConfig` — the API `@cloudflare/vite-plugin` uses. + * + * We only locate the file (probing `wrangler.json`, `.jsonc`, `.toml` inside + * `root` with wrangler's own precedence, since it discovers from `cwd` rather + * than an arbitrary root); wrangler then parses it, flattens the active + * environment (honoring `CLOUDFLARE_ENV`), and resolves `main` to an absolute + * path. Durable Object bindings are the active environment's, matching what the + * deployed Worker actually binds. + * + * Returns `undefined` when no config file is found or it can't be read/parsed + * (the caller warns and disables auto-instrumentation rather than failing the + * whole build). + */ +export function resolveWranglerConfig( + root: string, + explicitPath?: string, +): { config: WranglerConfig; configDir: string } | undefined { + const configPath = explicitPath + ? resolve(root, explicitPath) + : ['wrangler.json', 'wrangler.jsonc', 'wrangler.toml'].map(name => resolve(root, name)).find(existsSync); + + if (!configPath || !existsSync(configPath)) { + return undefined; + } + + let raw: Unstable_Config; + try { + // `hideWarnings` keeps wrangler's config diagnostics (e.g. missing DO + // migrations) out of the Vite build output. + raw = unstable_readConfig({ config: configPath }, { hideWarnings: true }); + } catch { + return undefined; + } + + const durableObjects: WranglerConfig['durableObjects'] = []; + const seenClassNames = new Set(); + for (const binding of raw.durable_objects?.bindings ?? []) { + // `script_name` bindings reference a class exported by a *different* worker + // — there is nothing to wrap in this worker's entry file. + if (typeof binding?.class_name !== 'string' || binding.script_name || seenClassNames.has(binding.class_name)) { + continue; + } + seenClassNames.add(binding.class_name); + durableObjects.push({ name: binding.name, className: binding.class_name }); + } + + return { + config: { main: raw.main, durableObjects }, + configDir: dirname(raw.configPath ?? configPath), + }; +} diff --git a/packages/cloudflare/test/defineCloudflareOptions.test.ts b/packages/cloudflare/test/defineCloudflareOptions.test.ts new file mode 100644 index 000000000000..08a6a8233024 --- /dev/null +++ b/packages/cloudflare/test/defineCloudflareOptions.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { defineCloudflareOptions } from '../src/defineCloudflareOptions'; + +describe('defineCloudflareOptions', () => { + it('returns the callback unchanged', () => { + const callback = (env: { SENTRY_DSN: string }) => ({ dsn: env.SENTRY_DSN }); + expect(defineCloudflareOptions(callback)).toBe(callback); + }); + + it('passes env through to the callback', () => { + const callback = defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, + })); + + expect(callback({ SENTRY_DSN: 'https://example' })).toEqual({ + dsn: 'https://example', + tracesSampleRate: 1.0, + }); + }); + + it('normalizes a static options object into a callback', () => { + const callback = defineCloudflareOptions({ tracesSampleRate: 0.5 }); + + expect(typeof callback).toBe('function'); + expect(callback({} as never)).toEqual({ tracesSampleRate: 0.5 }); + }); +}); diff --git a/packages/cloudflare/test/vite/wranglerConfig.test.ts b/packages/cloudflare/test/vite/wranglerConfig.test.ts new file mode 100644 index 000000000000..04b22cbffb67 --- /dev/null +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -0,0 +1,250 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { resolveWranglerConfig } from '../../src/vite/wranglerConfig'; + +function writeTempDir(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-')); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +describe('resolveWranglerConfig', () => { + it('parses wrangler.toml', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "MY_DO"', + 'class_name = "MyDurableObject"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + // wrangler resolves `main` to an absolute path against the config dir. + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'MY_DO', className: 'MyDurableObject' }]); + }); + + it('parses wrangler.json', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/worker.ts', + durable_objects: { + bindings: [{ name: 'DO_A', class_name: 'A' }], + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + expect(result!.config.main).toBe(join(dir, 'src/worker.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'DO_A', className: 'A' }]); + }); + + it('parses wrangler.jsonc (strips comments)', () => { + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' // Entry point', + ' "main": "src/index.ts",', + ' /* DO bindings */', + ' "durable_objects": {', + ' "bindings": [', + ' { "name": "DO", "class_name": "MyDO" }', + ' ]', + ' }', + '}', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result).toBeDefined(); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'DO', className: 'MyDO' }]); + }); + + it('parses JSONC with trailing commas', () => { + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' "main": "src/index.ts",', + ' "durable_objects": {', + ' "bindings": [', + ' { "name": "DO", "class_name": "MyDO" },', + ' ],', + ' },', + '}', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.config.durableObjects).toEqual([{ name: 'DO', className: 'MyDO' }]); + }); + + it('parses TOML single-quoted (literal) strings', () => { + const dir = writeTempDir({ 'wrangler.toml': "main = 'src/index.ts'" }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + }); + + it('prefers wrangler.json over wrangler.toml (matching wrangler itself)', () => { + const dir = writeTempDir({ + 'wrangler.toml': 'main = "from-toml.ts"', + 'wrangler.json': '{ "main": "from-json.ts" }', + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'from-json.ts')); + }); + + it('prefers wrangler.jsonc over wrangler.toml (matching wrangler itself)', () => { + const dir = writeTempDir({ + 'wrangler.toml': 'main = "from-toml.ts"', + 'wrangler.jsonc': '{ "main": "from-jsonc.ts" }', + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.main).toBe(join(dir, 'from-jsonc.ts')); + }); + + it('handles TOML with commented-out bindings', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '# [[durable_objects.bindings]]', + '# name = "IGNORED"', + '# class_name = "IgnoredDO"', + '', + '[[durable_objects.bindings]]', + 'name = "REAL"', + 'class_name = "RealDO"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([{ name: 'REAL', className: 'RealDO' }]); + }); + + it('handles multiple DO bindings', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "DO_A"', + 'class_name = "A"', + '', + '[[durable_objects.bindings]]', + 'name = "DO_B"', + 'class_name = "B"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toHaveLength(2); + expect(result!.config.durableObjects[0]).toEqual({ name: 'DO_A', className: 'A' }); + expect(result!.config.durableObjects[1]).toEqual({ name: 'DO_B', className: 'B' }); + }); + + it('returns undefined when no config exists', () => { + const dir = writeTempDir({}); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('returns undefined for explicit non-existent path', () => { + expect(resolveWranglerConfig('/tmp', '/tmp/nonexistent.toml')).toBeUndefined(); + }); + + it('resolves a relative explicit path against the root', () => { + const dir = writeTempDir({ 'custom.toml': 'main = "src/index.ts"' }); + + const result = resolveWranglerConfig(dir, 'custom.toml'); + expect(result).toBeDefined(); + expect(result!.config.main).toBe(join(dir, 'src/index.ts')); + expect(result!.configDir).toBe(dir); + }); + + it('returns undefined for an empty config file instead of crashing', () => { + const dir = writeTempDir({ 'wrangler.json': '' }); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('returns undefined for invalid TOML instead of crashing', () => { + const dir = writeTempDir({ 'wrangler.toml': 'main = [' }); + expect(resolveWranglerConfig(dir)).toBeUndefined(); + }); + + it('skips DO bindings with a script_name (class lives in another worker)', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + durable_objects: { + bindings: [ + { name: 'LOCAL', class_name: 'LocalDO' }, + { name: 'EXTERNAL', class_name: 'ExternalDO', script_name: 'other-worker' }, + ], + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([{ name: 'LOCAL', className: 'LocalDO' }]); + }); + + it('uses only the active environment DO bindings (does not union across envs)', () => { + // wrangler flattens to the active environment (top level here, since no + // CLOUDFLARE_ENV), matching what the deployed Worker actually binds. A + // class bound only in a non-active env is intentionally not included. + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + durable_objects: { bindings: [{ name: 'TOP', class_name: 'TopDO' }] }, + env: { + production: { + durable_objects: { + bindings: [{ name: 'PROD_ONLY', class_name: 'ProdDO' }], + }, + }, + }, + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.durableObjects).toEqual([{ name: 'TOP', className: 'TopDO' }]); + }); + + it('honors CLOUDFLARE_ENV for both main and DO bindings', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + durable_objects: { bindings: [{ name: 'TOP', class_name: 'TopDO' }] }, + env: { + staging: { + main: 'src/staging.ts', + durable_objects: { bindings: [{ name: 'STAGING_DO', class_name: 'StagingDO' }] }, + }, + }, + }), + }); + + const previous = process.env.CLOUDFLARE_ENV; + process.env.CLOUDFLARE_ENV = 'staging'; + try { + const result = resolveWranglerConfig(dir)!; + expect(result.config.main).toBe(join(dir, 'src/staging.ts')); + expect(result.config.durableObjects).toEqual([{ name: 'STAGING_DO', className: 'StagingDO' }]); + } finally { + if (previous === undefined) delete process.env.CLOUDFLARE_ENV; + else process.env.CLOUDFLARE_ENV = previous; + } + }); +}); diff --git a/yarn.lock b/yarn.lock index 381e90c80f85..5755e951e6ae 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19920,6 +19920,11 @@ jsonc-parser@3.2.0, jsonc-parser@^3.0.0: resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.2.0.tgz#31ff3f4c2b9793f89c67212627c51c6394f88e76" integrity sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w== +jsonc-parser@^3.3.1: + version "3.3.1" + resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" + integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== + jsondiffpatch@0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz#daa6a25bedf0830974c81545568d5f671c82551f" @@ -20795,7 +20800,7 @@ magic-string@^0.26.0, magic-string@^0.26.7: dependencies: sourcemap-codec "^1.4.8" -magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.8: +magic-string@^0.30.0, magic-string@^0.30.10, magic-string@^0.30.17, magic-string@^0.30.19, magic-string@^0.30.21, magic-string@^0.30.3, magic-string@^0.30.4, magic-string@^0.30.5, magic-string@~0.30.0, magic-string@~0.30.21, magic-string@~0.30.8: version "0.30.21" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== @@ -27294,6 +27299,11 @@ smol-toml@1.6.1: resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.6.1.tgz#4fceb5f7c4b86c2544024ef686e12ff0983465be" integrity sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg== +smol-toml@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.7.0.tgz#ed1b259ce7e05907df1abe758971bd0a0ef2c0dd" + integrity sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ== + snake-case@^3.0.3: version "3.0.4" resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c"