From adf471dadeca032f89dbb4b10549ef25ddfae057 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 21 Aug 2026 18:50:24 +0300 Subject: [PATCH 1/3] feat(cloudflare): Derive rpcTracePropagationBindings from the wrangler config The Vite plugin already knows which bindings resolve to receivers it instruments itself: Durable Object bindings without a `script_name`, and service bindings naming this worker. Those are exactly the bindings whose trailing trace argument is guaranteed to be stripped again, so the plugin adds them to `rpcTracePropagationBindings` and same-deployment RPC traces connect without any configuration. Bindings to other workers stay opt-in. Only bindings whose receiver class the transform wrapped itself are added. A class the user wrapped by hand, or re-exported from another module, runs on its own options and cannot be assumed to strip the argument. The options object only exists once the callback runs with `env`, so the plugin cannot merge at build time. The transform emits an inline callback that wraps the user's one and merges the binding names, so no runtime helper export is needed. Note this default only applies to Vite builds. At runtime a DurableObjectNamespace exposes no origin and a Fetcher does not say which service it points at, so a plain wrangler build still has to list its bindings. Co-Authored-By: Claude Opus 5 Co-Authored-By: Claude Fable 5 --- .../durableobject-rpc/index.ts | 31 +++++ .../durableobject-rpc/instrument.server.ts | 8 ++ .../durableobject-rpc/test.ts | 31 +++++ .../durableobject-rpc/vite.config.mts | 8 ++ .../durableobject-rpc/wrangler.jsonc | 12 ++ .../cloudflare/src/vite/autoInstrument.ts | 1 + packages/cloudflare/src/vite/index.ts | 4 + packages/cloudflare/src/vite/transform.ts | 55 ++++++++- .../cloudflare/src/vite/wranglerConfig.ts | 38 ++++++ .../test/vite/autoInstrument.test.ts | 5 +- .../cloudflare/test/vite/transform.test.ts | 93 +++++++++++++++ .../test/vite/wranglerConfig.test.ts | 111 ++++++++++++++++++ 12 files changed, 393 insertions(+), 4 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/index.ts new file mode 100644 index 000000000000..c5b7776111ee --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/index.ts @@ -0,0 +1,31 @@ +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; +} + +// Nothing is wrapped manually, the Vite plugin wraps both exports and enables RPC trace +// propagation for `COUNTER` on its own. +export class Counter extends DurableObject { + // An uninstrumented receiver would see Sentry's RPC metadata in the trailing optional parameter, + // see https://github.com/getsentry/sentry-javascript/issues/23233. + async increment(by: number, _unused?: number): Promise<{ count: number; argumentCount: number }> { + const count = ((await this.ctx.storage.get('count')) ?? 0) + by; + await this.ctx.storage.put('count', count); + return { count, argumentCount: arguments.length }; + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/increment') { + const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e')); + return Response.json(await stub.increment(1)); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts new file mode 100644 index 000000000000..d2e70b59d426 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/instrument.server.ts @@ -0,0 +1,8 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +// `rpcTracePropagationBindings` is deliberately absent, the Vite plugin derives `COUNTER` on its own. +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts new file mode 100644 index 000000000000..20483653eeec --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/test.ts @@ -0,0 +1,31 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +it('propagates the trace over a Durable Object RPC call without configuring the binding', async ({ signal }) => { + let workerTraceId: string | undefined; + let doTraceId: string | undefined; + + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent.contexts?.trace?.op).toBe('rpc'); + doTraceId = transactionEvent.contexts?.trace?.trace_id; + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + workerTraceId = transactionEvent.contexts?.trace?.trace_id; + }) + .start(signal); + + // `argumentCount` proves the receiver stripped the metadata argument again. + const response = await runner.makeRequest<{ count: number; argumentCount: number }>('get', '/increment'); + expect(response).toEqual({ count: 1, argumentCount: 1 }); + + await runner.completed(); + + expect(workerTraceId).toBeDefined(); + expect(doTraceId).toBe(workerTraceId); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/vite.config.mts new file mode 100644 index 000000000000..4c3b3cd054f6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/vite.config.mts @@ -0,0 +1,8 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + // The Sentry transform wraps the entry before the Cloudflare plugin bundles it. + plugins: [cloudflare(), sentryCloudflareVitePlugin()], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/wrangler.jsonc new file mode 100644 index 000000000000..8f38ad9c6172 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-rpc/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject-rpc", + // `main` points at the source entry so the auto-instrument transform runs during the build. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], +} diff --git a/packages/cloudflare/src/vite/autoInstrument.ts b/packages/cloudflare/src/vite/autoInstrument.ts index aa410ef98fa4..707eed54375e 100644 --- a/packages/cloudflare/src/vite/autoInstrument.ts +++ b/packages/cloudflare/src/vite/autoInstrument.ts @@ -128,6 +128,7 @@ export function sentryCloudflareAutoInstrumentPlugin(options: { wranglerConfigPa agentClasses, optionsFn, optionsImport, + sameWorkerBindings: wranglerConfig.sameWorkerBindings, }); const wrappedClasses = result?.wrappedClasses ?? new Set(); diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index f1fcb6b739b3..3b4d6a2dbd75 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -45,6 +45,10 @@ export interface SentryCloudflareVitePluginOptions { * left alone, so this is safe alongside manual instrumentation. Set to * `false` to opt out. * + * The plugin also adds the bindings that resolve to the wrapped classes (this worker's own + * Durable Objects and self service bindings) to `rpcTracePropagationBindings`. Bindings to + * other workers stay opt-in, their receivers may not run Sentry. + * * @default true */ autoInstrumentation?: boolean; diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 65183f8aea98..13912feb1a30 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -1,6 +1,9 @@ import MagicString from 'magic-string'; +import type { SameWorkerBinding } from './wranglerConfig'; import { detectWorkerEntrypointClasses } from './workerEntrypoint'; +const MERGED_OPTIONS_IDENTIFIER = '__SENTRY_OPTIONS__'; + // --------------------------------------------------------------------------- // Minimal ESTree node types for the AST nodes we inspect. // --------------------------------------------------------------------------- @@ -108,6 +111,8 @@ export interface TransformContext { optionsFn: string; /** Import statement prepended when `optionsFn` references a separate module. */ optionsImport?: string; + /** @see {@link import('./wranglerConfig').WranglerConfig.sameWorkerBindings} */ + sameWorkerBindings?: readonly SameWorkerBinding[]; } export interface TransformResult { @@ -144,6 +149,7 @@ export function applyAutoInstrumentTransforms( ): TransformResult | undefined { const ms = new MagicString(code); const topLevelClasses = collectTopLevelClasses(ast); + const sameWorkerBindings = ctx.sameWorkerBindings ?? []; const state: TransformState = { ms, needsImport: false, @@ -153,6 +159,9 @@ export function applyAutoInstrumentTransforms( classWrappers: ctx.classWrappers, agentClasses: ctx.agentClasses ?? new Set(), workerEntrypointClasses: detectWorkerEntrypointClasses(ast), + // The identifier must be chosen before wrapping, which bindings survive is only known after. + optionsFn: sameWorkerBindings.length > 0 ? MERGED_OPTIONS_IDENTIFIER : ctx.optionsFn, + autoWrapped: new Set(), }; const { wrappedClasses } = state; @@ -179,6 +188,10 @@ export function applyAutoInstrumentTransforms( return { code, map: ms.generateMap({ hires: true }), wrappedClasses }; } + // `prepend` inserts before earlier prepends, yielding: Sentry import, options import, declaration. + if (sameWorkerBindings.length > 0) { + ms.prepend(buildMergedOptionsDeclaration(sameWorkerBindings, ctx.optionsFn, state)); + } if (ctx.optionsImport) ms.prepend(ctx.optionsImport); ms.prepend("import * as __SENTRY__ from '@sentry/cloudflare';\n"); @@ -212,6 +225,39 @@ interface TransformState { * so they can be wrapped without a config entry. */ workerEntrypointClasses: Set; + optionsFn: string; + /** + * Exported names this transform wrapped itself, unlike `wrappedClasses` which also counts + * hand-wrapped classes. `undefined` marks the default export, mirroring + * {@link SameWorkerBinding.className}. + */ + autoWrapped: Set; +} + +/** + * Builds the callback that merges same-worker binding names into `rpcTracePropagationBindings` at + * runtime, the options object only exists once the callback runs with `env`. Only bindings whose + * class this transform wrapped survive, a hand-wrapped class runs on its own options. + */ +function buildMergedOptionsDeclaration( + sameWorkerBindings: readonly SameWorkerBinding[], + optionsFn: string, + state: TransformState, +): string { + const bindingNames = sameWorkerBindings + .filter(({ className }) => state.autoWrapped.has(className)) + .map(({ bindingName }) => bindingName); + + if (!bindingNames.length) { + return `const ${MERGED_OPTIONS_IDENTIFIER} = ${optionsFn};\n`; + } + + const names = bindingNames.map(name => JSON.stringify(name)).join(', '); + return ( + `const ${MERGED_OPTIONS_IDENTIFIER} = (env) => { ` + + `const opts = (${optionsFn})(env); ` + + `return { ...opts, rpcTracePropagationBindings: [${names}, ...(opts?.rpcTracePropagationBindings ?? [])] }; };\n` + ); } /** @@ -263,8 +309,9 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state // `export default ` → `const __SENTRY_DEFAULT_EXPORT__ = ` // MagicString positions are always relative to the original source. state.ms.overwrite(node.start, decl.start, 'const __SENTRY_DEFAULT_EXPORT__ = '); - state.ms.append(`\nexport default __SENTRY__.withSentry(${ctx.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`); + state.ms.append(`\nexport default __SENTRY__.withSentry(${state.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`); state.needsImport = true; + state.autoWrapped.add(undefined); } function handleNamedExport(node: ExportNamedNode, ctx: TransformContext, state: TransformState): void { @@ -336,10 +383,11 @@ function wrapInlineClassExport( // Insert the wrapped re-export after the class body state.ms.appendLeft( exportNode.end, - `\nexport const ${className} = __SENTRY__.${WRAPPER_METHODS[kind]}(${ctx.optionsFn}, ${renamedClass});\n`, + `\nexport const ${className} = __SENTRY__.${WRAPPER_METHODS[kind]}(${state.optionsFn}, ${renamedClass});\n`, ); state.wrappedClasses.add(className); + state.autoWrapped.add(className); state.renamedLocals.add(className); state.needsImport = true; } @@ -357,6 +405,7 @@ function wrapSpecifierExport(specifier: ExportSpecifierNode, ctx: TransformConte if (!localName || !localClass?.id) return; state.wrappedClasses.add(exportedName); + state.autoWrapped.add(exportedName); state.needsImport = true; if (state.renamedLocals.has(localName)) return; state.renamedLocals.add(localName); @@ -367,6 +416,6 @@ function wrapSpecifierExport(specifier: ExportSpecifierNode, ctx: TransformConte // wrapped) `localName` binding, so the wrapper is NOT exported here. state.ms.appendLeft( localClass.end, - `\nconst ${localName} = __SENTRY__.${WRAPPER_METHODS[kind]}(${ctx.optionsFn}, ${renamedClass});\n`, + `\nconst ${localName} = __SENTRY__.${WRAPPER_METHODS[kind]}(${state.optionsFn}, ${renamedClass});\n`, ); } diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts index f95ca7264302..c24ee69658ee 100644 --- a/packages/cloudflare/src/vite/wranglerConfig.ts +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -18,6 +18,17 @@ export interface WranglerConfig { * an export on a *different* worker, which this build can't wrap. */ workerEntrypoints: string[]; + /** + * Bindings whose RPC receiver lives in this worker, so this build instruments it. An `undefined` + * `className` means the default export. Not deduped by class, two bindings may point at the same + * class and both names have to be listed. + */ + sameWorkerBindings: SameWorkerBinding[]; +} + +export interface SameWorkerBinding { + bindingName: string; + className?: string; } /** @@ -62,6 +73,7 @@ export function resolveWranglerConfig( durableObjects: collectClassBindings(raw.durable_objects?.bindings), workflows: collectClassBindings(raw.workflows), workerEntrypoints: collectSelfBoundEntrypoints(raw), + sameWorkerBindings: collectSameWorkerBindings(raw), }, configDir: dirname(raw.configPath ?? configPath), }; @@ -86,6 +98,32 @@ function collectSelfBoundEntrypoints(raw: Unstable_Config): string[] { return [...entrypoints]; } +/** + * Bindings with a `script_name` or naming another worker target a class this build does not wrap, + * so they are excluded and stay opt-in. + */ +function collectSameWorkerBindings(raw: Unstable_Config): SameWorkerBinding[] { + const bindings: SameWorkerBinding[] = []; + + for (const binding of raw.durable_objects?.bindings ?? []) { + if (typeof binding?.name === 'string' && typeof binding.class_name === 'string' && !binding.script_name) { + bindings.push({ bindingName: binding.name, className: binding.class_name }); + } + } + + for (const binding of raw.services ?? []) { + // A service binding is only ours when `service` names this worker; without a `name` none can be. + if (raw.name && binding?.service === raw.name && typeof binding.binding === 'string') { + bindings.push({ + bindingName: binding.binding, + className: typeof binding.entrypoint === 'string' ? binding.entrypoint : undefined, + }); + } + } + + return bindings; +} + /** * Map wrangler class bindings (Durable Objects, Workflows — same shape) to the * `{ name, className }` the transform needs, skipping duplicates and bindings diff --git a/packages/cloudflare/test/vite/autoInstrument.test.ts b/packages/cloudflare/test/vite/autoInstrument.test.ts index 703120daaf7e..75ffece9b6d9 100644 --- a/packages/cloudflare/test/vite/autoInstrument.test.ts +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -491,6 +491,9 @@ describe('instrument file auto-detection', () => { const code = ['class DurableObject {}', 'export class MyDO extends DurableObject {}'].join('\n'); const result = await tx(code, entryPath)!; - expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry(__SENTRY_OPTIONS_CALLBACK__,'); + expect(result.code).toContain( + 'const __SENTRY_OPTIONS__ = (env) => { const opts = (__SENTRY_OPTIONS_CALLBACK__)(env); return { ...opts, rpcTracePropagationBindings: ["MY_DO", ...(opts?.rpcTracePropagationBindings ?? [])] }; };', + ); + expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry(__SENTRY_OPTIONS__,'); }); }); diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index e12754aaf3ea..1d5d9b719777 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -669,3 +669,96 @@ describe('combined transforms', () => { expect(result.code).not.toContain('__SENTRY_DEFAULT_EXPORT__'); }); }); + +describe('same-worker RPC binding floor', () => { + it('declares the merged options callback after both imports and uses it at every wrapper site', () => { + const code = [ + 'export class MyDO {}', + 'export class MyWorkflow {}', + 'export default { fetch() { return new Response("ok"); } };', + ].join('\n'); + + const result = transform(code, { + classWrappers: new Map([ + ['MyDO', 'durableObject'], + ['MyWorkflow', 'workflow'], + ]), + optionsFn: '__SENTRY_OPTIONS_CALLBACK__', + optionsImport: "import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.ts';\n", + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).toContain( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + "import __SENTRY_OPTIONS_CALLBACK__ from './instrument.server.ts';", + 'const __SENTRY_OPTIONS__ = (env) => { const opts = (__SENTRY_OPTIONS_CALLBACK__)(env); return { ...opts, rpcTracePropagationBindings: ["MY_DO", ...(opts?.rpcTracePropagationBindings ?? [])] }; };', + ].join('\n'), + ); + expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry(__SENTRY_OPTIONS__,'); + expect(result.code).toContain('__SENTRY__.instrumentWorkflowWithSentry(__SENTRY_OPTIONS__,'); + expect(result.code).toContain('__SENTRY__.withSentry(__SENTRY_OPTIONS__, __SENTRY_DEFAULT_EXPORT__)'); + }); + + it('passes the env fallback callback through when there is no instrument file', () => { + const code = 'export class MyDO {}'; + + const result = transform(code, { + classWrappers: doWrappers('MyDO'), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).toContain( + 'const __SENTRY_OPTIONS__ = (env) => { const opts = (() => undefined)(env); return { ...opts, rpcTracePropagationBindings: ["MY_DO", ...(opts?.rpcTracePropagationBindings ?? [])] }; };', + ); + }); + + it('enables a self service binding without an entrypoint only when it wrapped the default export', () => { + const code = 'export default { fetch() { return new Response("ok"); } };'; + + const result = transform(code, { + classWrappers: doWrappers(), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'SELF' }], + })!; + + expect(result.code).toContain('rpcTracePropagationBindings: ["SELF",'); + }); + + it('drops a binding whose class was wrapped by hand', () => { + // A hand-wrapped receiver runs on its own options and would see the trailing argument. + const code = [ + 'export const MyDO = Sentry.instrumentDurableObjectWithSentry(options, class {});', + 'export default { fetch() { return new Response("ok"); } };', + ].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('MyDO'), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).not.toContain('rpcTracePropagationBindings'); + }); + + it('drops a binding whose class is re-exported from another module', () => { + const code = ['export { MyDO } from "./myDo";', 'export default { fetch() {} };'].join('\n'); + + const result = transform(code, { + classWrappers: doWrappers('MyDO'), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'MY_DO', className: 'MyDO' }], + })!; + + expect(result.code).toContain('const __SENTRY_OPTIONS__ = () => undefined;'); + expect(result.code).not.toContain('rpcTracePropagationBindings'); + }); + + it('leaves the output untouched when there are no same-worker bindings', () => { + const code = 'export class MyDO {}'; + const ctx: TransformContext = { classWrappers: doWrappers('MyDO'), optionsFn: '() => undefined' }; + + expect(transform(code, { ...ctx, sameWorkerBindings: [] })!.code).toBe(transform(code, ctx)!.code); + }); +}); diff --git a/packages/cloudflare/test/vite/wranglerConfig.test.ts b/packages/cloudflare/test/vite/wranglerConfig.test.ts index 7589d69da1c5..274e8bebd160 100644 --- a/packages/cloudflare/test/vite/wranglerConfig.test.ts +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -460,3 +460,114 @@ describe('unstable_readConfig: service-binding entrypoint semantics', () => { expect(raw.topLevelName).toBeUndefined(); }); }); + +describe('sameWorkerBindings', () => { + function sameWorkerBindings(files: Record) { + return resolveWranglerConfig(writeTempDir(files))!.config.sameWorkerBindings; + } + + it('includes Durable Object bindings declared by this worker', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { bindings: [{ name: 'COUNTER', class_name: 'Counter' }] }, + }), + }), + ).toEqual([{ bindingName: 'COUNTER', className: 'Counter' }]); + }); + + it('keeps every binding name pointing at the same Durable Object class', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { + bindings: [ + { name: 'COUNTER', class_name: 'Counter' }, + { name: 'COUNTER_ALIAS', class_name: 'Counter' }, + ], + }, + }), + }), + ).toEqual([ + { bindingName: 'COUNTER', className: 'Counter' }, + { bindingName: 'COUNTER_ALIAS', className: 'Counter' }, + ]); + }); + + it('excludes Durable Object bindings owned by another worker', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { + bindings: [{ name: 'REMOTE_DO', class_name: 'Other', script_name: 'other-worker' }], + }, + }), + }), + ).toEqual([]); + }); + + it('excludes a Durable Object binding carrying this worker as `script_name`', () => { + // A `script_name` binding is never wrapped by this build, propagation would target an uninstrumented receiver. + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + durable_objects: { + bindings: [{ name: 'SELF_DO', class_name: 'Counter', script_name: 'my-worker' }], + }, + }), + }), + ).toEqual([]); + }); + + it('includes self service bindings and excludes bindings to other workers', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + services: [ + { binding: 'SELF', service: 'my-worker', entrypoint: 'AdminEntry' }, + { binding: 'DEFAULT_SELF', service: 'my-worker' }, + { binding: 'EXTERNAL', service: 'other-worker' }, + ], + }), + }), + ).toEqual([ + { bindingName: 'SELF', className: 'AdminEntry' }, + { bindingName: 'DEFAULT_SELF', className: undefined }, + ]); + }); + + it('derives nothing from service bindings when the config omits `name`', () => { + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + services: [{ binding: 'SELF', service: 'my-worker', entrypoint: 'AdminEntry' }], + }), + }), + ).toEqual([]); + }); + + it('never includes workflow bindings or tail consumers', () => { + // Workflow bindings never reach the RPC instrumentation, tail consumers are not `env` bindings. + expect( + sameWorkerBindings({ + 'wrangler.json': JSON.stringify({ + name: 'my-worker', + main: 'src/index.ts', + workflows: [{ name: 'MY_WF', binding: 'MY_WF', class_name: 'MyWorkflow' }], + tail_consumers: [{ service: 'my-worker' }], + }), + }), + ).toEqual([]); + }); +}); From c6f90653c296837d810759b0c7fb44296d062091 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 26 Aug 2026 08:57:57 +0300 Subject: [PATCH 2/3] fix(cloudflare): Count a default export re-exporting a wrapped class as auto-wrapped `export default Foo`, where a named export already wrapped `Foo`, skips wrapping to avoid `withSentry(withSentry(...))`, but the binding still points at the wrapped class. Mark the default export as auto-wrapped so an entrypoint-less self service binding survives into `rpcTracePropagationBindings`. Co-Authored-By: Claude Opus 5 --- packages/cloudflare/src/vite/transform.ts | 8 ++++++-- packages/cloudflare/test/vite/transform.test.ts | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 13912feb1a30..77060de7d646 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -303,8 +303,12 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state // `export default Foo` where `Foo` is a local class already wrapped by a named // export (e.g. a self-bound WorkerEntrypoint also used as the default handler). - // Wrapping again would produce `withSentry(withSentry(...))`. - if (decl.type === 'Identifier' && state.renamedLocals.has((decl as IdentifierNode).name)) return; + // Wrapping again would produce `withSentry(withSentry(...))`. The binding still + // points at the wrapped class, so the default export counts as auto-wrapped. + if (decl.type === 'Identifier' && state.renamedLocals.has((decl as IdentifierNode).name)) { + state.autoWrapped.add(undefined); + return; + } // `export default ` → `const __SENTRY_DEFAULT_EXPORT__ = ` // MagicString positions are always relative to the original source. diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 1d5d9b719777..9f4731b8fc12 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -726,6 +726,23 @@ describe('same-worker RPC binding floor', () => { expect(result.code).toContain('rpcTracePropagationBindings: ["SELF",'); }); + it('enables a self service binding when the default export re-exports an already wrapped class', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class AdminEntry extends WorkerEntrypoint {}', + 'export { AdminEntry };', + 'export default AdminEntry;', + ].join('\n'); + + const result = transform(code, { + classWrappers: new Map(), + optionsFn: '() => undefined', + sameWorkerBindings: [{ bindingName: 'SELF' }], + })!; + + expect(result.code).toContain('rpcTracePropagationBindings: ["SELF",'); + }); + it('drops a binding whose class was wrapped by hand', () => { // A hand-wrapped receiver runs on its own options and would see the trailing argument. const code = [ From 05986819af9d631108a9c4b80cd954bbd3326007 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Fri, 28 Aug 2026 13:36:50 +0200 Subject: [PATCH 3/3] feat: Use symbol for default exports | clariy comment --- packages/cloudflare/src/vite/transform.ts | 16 ++++++++-------- packages/cloudflare/src/vite/wranglerConfig.ts | 15 ++++++++++----- packages/cloudflare/test/vite/transform.test.ts | 5 +++-- .../cloudflare/test/vite/wranglerConfig.test.ts | 4 ++-- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 77060de7d646..b4b897eb56b1 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -1,5 +1,5 @@ import MagicString from 'magic-string'; -import type { SameWorkerBinding } from './wranglerConfig'; +import { DEFAULT_EXPORT, type ExportName, type SameWorkerBinding } from './wranglerConfig'; import { detectWorkerEntrypointClasses } from './workerEntrypoint'; const MERGED_OPTIONS_IDENTIFIER = '__SENTRY_OPTIONS__'; @@ -161,7 +161,7 @@ export function applyAutoInstrumentTransforms( workerEntrypointClasses: detectWorkerEntrypointClasses(ast), // The identifier must be chosen before wrapping, which bindings survive is only known after. optionsFn: sameWorkerBindings.length > 0 ? MERGED_OPTIONS_IDENTIFIER : ctx.optionsFn, - autoWrapped: new Set(), + autoWrapped: new Set(), }; const { wrappedClasses } = state; @@ -227,11 +227,11 @@ interface TransformState { workerEntrypointClasses: Set; optionsFn: string; /** - * Exported names this transform wrapped itself, unlike `wrappedClasses` which also counts - * hand-wrapped classes. `undefined` marks the default export, mirroring - * {@link SameWorkerBinding.className}. + * Export names wrapped by this transform, so their options can be extended with + * `rpcTracePropagationBindings`. Hand-wrapped exports stay out, they keep the options they were + * wrapped with. `wrappedClasses` counts both. */ - autoWrapped: Set; + autoWrapped: Set; } /** @@ -306,7 +306,7 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state // Wrapping again would produce `withSentry(withSentry(...))`. The binding still // points at the wrapped class, so the default export counts as auto-wrapped. if (decl.type === 'Identifier' && state.renamedLocals.has((decl as IdentifierNode).name)) { - state.autoWrapped.add(undefined); + state.autoWrapped.add(DEFAULT_EXPORT); return; } @@ -315,7 +315,7 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state state.ms.overwrite(node.start, decl.start, 'const __SENTRY_DEFAULT_EXPORT__ = '); state.ms.append(`\nexport default __SENTRY__.withSentry(${state.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`); state.needsImport = true; - state.autoWrapped.add(undefined); + state.autoWrapped.add(DEFAULT_EXPORT); } function handleNamedExport(node: ExportNamedNode, ctx: TransformContext, state: TransformState): void { diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts index c24ee69658ee..9838f0609e46 100644 --- a/packages/cloudflare/src/vite/wranglerConfig.ts +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -19,16 +19,21 @@ export interface WranglerConfig { */ workerEntrypoints: string[]; /** - * Bindings whose RPC receiver lives in this worker, so this build instruments it. An `undefined` - * `className` means the default export. Not deduped by class, two bindings may point at the same - * class and both names have to be listed. + * Bindings whose RPC receiver lives in this worker, so this build instruments it. Not deduped by + * class, two bindings may point at the same class and both names have to be listed. */ sameWorkerBindings: SameWorkerBinding[]; } +/** Stands in for a class name where a binding targets the module's default export. */ +export const DEFAULT_EXPORT = Symbol('defaultExport'); + +/** The name a binding or an export is known by, either an exported class name or the default export. */ +export type ExportName = string | typeof DEFAULT_EXPORT; + export interface SameWorkerBinding { bindingName: string; - className?: string; + className: ExportName; } /** @@ -116,7 +121,7 @@ function collectSameWorkerBindings(raw: Unstable_Config): SameWorkerBinding[] { if (raw.name && binding?.service === raw.name && typeof binding.binding === 'string') { bindings.push({ bindingName: binding.binding, - className: typeof binding.entrypoint === 'string' ? binding.entrypoint : undefined, + className: typeof binding.entrypoint === 'string' ? binding.entrypoint : DEFAULT_EXPORT, }); } } diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 9f4731b8fc12..4130f170cd88 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -1,6 +1,7 @@ import { parse } from 'acorn'; import { describe, expect, it } from 'vitest'; import { applyAutoInstrumentTransforms, type ClassWrapperKind, type TransformContext } from '../../src/vite/transform'; +import { DEFAULT_EXPORT } from '../../src/vite/wranglerConfig'; function parseJS(code: string) { return parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as { body: any[] }; @@ -720,7 +721,7 @@ describe('same-worker RPC binding floor', () => { const result = transform(code, { classWrappers: doWrappers(), optionsFn: '() => undefined', - sameWorkerBindings: [{ bindingName: 'SELF' }], + sameWorkerBindings: [{ bindingName: 'SELF', className: DEFAULT_EXPORT }], })!; expect(result.code).toContain('rpcTracePropagationBindings: ["SELF",'); @@ -737,7 +738,7 @@ describe('same-worker RPC binding floor', () => { const result = transform(code, { classWrappers: new Map(), optionsFn: '() => undefined', - sameWorkerBindings: [{ bindingName: 'SELF' }], + sameWorkerBindings: [{ bindingName: 'SELF', className: DEFAULT_EXPORT }], })!; expect(result.code).toContain('rpcTracePropagationBindings: ["SELF",'); diff --git a/packages/cloudflare/test/vite/wranglerConfig.test.ts b/packages/cloudflare/test/vite/wranglerConfig.test.ts index 274e8bebd160..9d8612146b60 100644 --- a/packages/cloudflare/test/vite/wranglerConfig.test.ts +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { unstable_readConfig } from 'wrangler'; -import { resolveWranglerConfig } from '../../src/vite/wranglerConfig'; +import { DEFAULT_EXPORT, resolveWranglerConfig } from '../../src/vite/wranglerConfig'; const tempDirs: string[] = []; @@ -542,7 +542,7 @@ describe('sameWorkerBindings', () => { }), ).toEqual([ { bindingName: 'SELF', className: 'AdminEntry' }, - { bindingName: 'DEFAULT_SELF', className: undefined }, + { bindingName: 'DEFAULT_SELF', className: DEFAULT_EXPORT }, ]); });