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..c45c1fcd3b02 --- /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'; + +// `rpcTracePropagationTargets` 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..0a40077b552a 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 `rpcTracePropagationTargets`. 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..79329b76d760 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 `rpcTracePropagationTargets` 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, rpcTracePropagationTargets: [${names}, ...(opts?.rpcTracePropagationTargets ?? [])] }; };\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..5877ef480883 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, rpcTracePropagationTargets: ["MY_DO", ...(opts?.rpcTracePropagationTargets ?? [])] }; };', + ); + 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..810da030e9b8 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, rpcTracePropagationTargets: ["MY_DO", ...(opts?.rpcTracePropagationTargets ?? [])] }; };', + ].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, rpcTracePropagationTargets: ["MY_DO", ...(opts?.rpcTracePropagationTargets ?? [])] }; };', + ); + }); + + 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('rpcTracePropagationTargets: ["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('rpcTracePropagationTargets'); + }); + + 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('rpcTracePropagationTargets'); + }); + + 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([]); + }); +});