From c7d462453a259a014b798cc9cad7b3526fd927df Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 21 Jul 2026 11:13:06 +0200 Subject: [PATCH] feat(cloudflare): Auto-instrument Workflow classes Extend the Vite auto-instrument plugin to wrap exported Cloudflare Workflow classes with `instrumentWorkflowWithSentry`, mirroring the existing Durable Object support. Workflow class names are read from the `workflows[]` section of the wrangler config (skipping bindings with a `script_name`, which live in another worker), and fed into the transform's class-wrapper map. Durable Object and Workflow bindings share the same shape and skip/dedup rules, so the extraction is a single `collectClassBindings` helper. Adds a `vite-autoinstrument/workflow` integration suite that builds an unwrapped Workflow class via the Vite plugin and asserts the workflow-step transaction. Co-Authored-By: Claude Opus 4.8 --- .../index.ts | 62 ++++++++ .../instrument.server.ts | 6 + .../test.ts | 71 ++++++++++ .../vite.config.mts | 17 +++ .../wrangler.jsonc | 20 +++ .../durableobject-workflow-specifier/index.ts | 57 ++++++++ .../instrument.server.ts | 6 + .../durableobject-workflow-specifier/test.ts | 70 +++++++++ .../vite.config.mts | 17 +++ .../wrangler.jsonc | 20 +++ .../durableobject-workflow/index.ts | 56 ++++++++ .../instrument.server.ts | 6 + .../durableobject-workflow/test.ts | 69 +++++++++ .../durableobject-workflow/vite.config.mts | 17 +++ .../durableobject-workflow/wrangler.jsonc | 20 +++ .../vite-autoinstrument/workflow/index.ts | 41 ++++++ .../workflow/instrument.server.ts | 6 + .../vite-autoinstrument/workflow/test.ts | 21 +++ .../workflow/vite.config.mts | 16 +++ .../workflow/wrangler.jsonc | 16 +++ .../cloudflare/src/vite/autoInstrument.ts | 3 + packages/cloudflare/src/vite/transform.ts | 3 +- .../cloudflare/src/vite/wranglerConfig.ts | 38 +++-- .../test/vite/autoInstrument.test.ts | 21 +++ .../cloudflare/test/vite/transform.test.ts | 94 ++++++++++++- .../test/vite/wranglerConfig.test.ts | 133 ++++++++++++++++++ 26 files changed, 892 insertions(+), 14 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/index.ts new file mode 100644 index 000000000000..895b61a4c8e2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/index.ts @@ -0,0 +1,62 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkflowEntrypoint } from 'cloudflare:workers'; +import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; + MY_WORKFLOW: Workflow; +} + +class CounterImpl extends DurableObject { + async fetch(): Promise { + const current = ((await this.ctx.storage.get('count')) ?? 0) + 1; + await this.ctx.storage.put('count', current); + return Response.json({ count: current }); + } +} + +// The Durable Object is wrapped by hand. The transform must recognize the +// existing `instrumentDurableObjectWithSentry` call — matched by the DO-kind +// wrapper method, not the workflow one — and leave it untouched (no double-wrap). +export const Counter = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }), + CounterImpl, +); + +// The Workflow is a plain inline export — the transform must auto-wrap it with +// `instrumentWorkflowWithSentry` even though its DO sibling is already wrapped. +export class MyWorkflow extends WorkflowEntrypoint { + async run(_event: WorkflowEvent, step: WorkflowStep): Promise { + await step.do('step-one', async () => 'Step one completed'); + } +} + +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 stub.fetch(new Request('https://do/increment')); + } + + if (url.pathname === '/workflow/trigger') { + const instance = await env.MY_WORKFLOW.create(); + for (let i = 0; i < 15; i++) { + try { + const s = await instance.status(); + if (s.status === 'complete' || s.status === 'errored') { + return Response.json({ id: instance.id, ...s }); + } + } catch { + // status() may not be available in local dev + } + await new Promise(r => setTimeout(r, 500)); + } + return Response.json({ id: instance.id, status: 'timeout' }); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts new file mode 100644 index 000000000000..62807342b2d0 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/test.ts @@ -0,0 +1,71 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// A fetch-invoked Durable Object emits an `http.server` transaction whose only +// children are the two `auto.db.cloudflare.durable_object` storage spans +// (`get` + `put`) — present here because the class was manually wrapped. +function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), + }), + }), + ); + expect(transactionEvent.spans).toHaveLength(2); + expect(transactionEvent.spans).toEqual([ + expect.objectContaining({ + op: 'db', + description: 'durable_object_storage_get', + origin: 'auto.db.cloudflare.durable_object', + }), + expect.objectContaining({ + op: 'db', + description: 'durable_object_storage_put', + origin: 'auto.db.cloudflare.durable_object', + }), + ]); +} + +// A workflow step runs in its own invocation and reports a `function.step.do` / +// `auto.faas.cloudflare.workflow` transaction named after the step — present +// only because the transform auto-wrapped the Workflow class. +function expectWorkflowStepTransaction(transactionEvent: TransactionEvent): void { + expect(transactionEvent.transaction).toBe('step-one'); + expect(transactionEvent.contexts?.trace?.op).toBe('function.step.do'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow'); +} + +// The main worker transaction for `/increment` just forwards to the DO, so it +// carries no child spans. The empty-spans assertion keeps it disjoint from the +// DO and workflow transactions regardless of arrival order. +function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), + }), + }), + ); + expect(transactionEvent.spans).toHaveLength(0); +} + +// The Durable Object is wrapped by hand with `instrumentDurableObjectWithSentry` +// while the Workflow sibling is a plain inline export. The transform must match +// the manual wrap by its DO-kind method and skip it (no double-wrap) while still +// auto-wrapping the Workflow with `instrumentWorkflowWithSentry`. We therefore +// expect a storage-bearing DO transaction (manual wrap) and a `step-one` +// transaction (auto wrap), plus the child-less main worker transaction. +it('leaves a manually wrapped Durable Object untouched and still auto-wraps a Workflow sibling', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectWorkflowStepTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/increment'); + await runner.makeRequest('get', '/workflow/trigger'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/vite.config.mts new file mode 100644 index 000000000000..5a47364942aa --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/vite.config.mts @@ -0,0 +1,17 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + // The Sentry plugin runs first so its build-time transform skips the manually + // wrapped `Counter` Durable Object and auto-wraps the `MyWorkflow` Workflow + // before the Cloudflare plugin bundles it. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/wrangler.jsonc new file mode 100644 index 000000000000..cf099a123f2f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-manual-mixed/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject-workflow-manual-mixed", + // `main` points at the source entry; the Sentry Vite plugin builds from it (so + // the auto-instrument transform runs) and the runner serves the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], + "workflows": [ + { + "name": "my-workflow", + "binding": "MY_WORKFLOW", + "class_name": "MyWorkflow", + }, + ], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/index.ts new file mode 100644 index 000000000000..b124c6065dcb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/index.ts @@ -0,0 +1,57 @@ +import { DurableObject, WorkflowEntrypoint } from 'cloudflare:workers'; +import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; + MY_WORKFLOW: Workflow; +} + +// Both classes are declared plain and exported through a single specifier list +// (`export { Counter, MyWorkflow }`) rather than inline. The transform must +// handle the specifier form for each kind: rename each local class and rebind +// the exported name to the kind-specific wrapper. +class Counter extends DurableObject { + async fetch(): Promise { + const current = ((await this.ctx.storage.get('count')) ?? 0) + 1; + await this.ctx.storage.put('count', current); + return Response.json({ count: current }); + } +} + +class MyWorkflow extends WorkflowEntrypoint { + async run(_event: WorkflowEvent, step: WorkflowStep): Promise { + await step.do('step-one', async () => 'Step one completed'); + } +} + +export { Counter, MyWorkflow }; + +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 stub.fetch(new Request('https://do/increment')); + } + + if (url.pathname === '/workflow/trigger') { + const instance = await env.MY_WORKFLOW.create(); + for (let i = 0; i < 15; i++) { + try { + const s = await instance.status(); + if (s.status === 'complete' || s.status === 'errored') { + return Response.json({ id: instance.id, ...s }); + } + } catch { + // status() may not be available in local dev + } + await new Promise(r => setTimeout(r, 500)); + } + return Response.json({ id: instance.id, status: 'timeout' }); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts new file mode 100644 index 000000000000..66bd3be29f15 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/test.ts @@ -0,0 +1,70 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// A fetch-invoked Durable Object emits an `http.server` transaction whose only +// children are the two `auto.db.cloudflare.durable_object` storage spans +// (`get` + `put`) — present only when the class was auto-instrumented. +function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), + }), + }), + ); + expect(transactionEvent.spans).toHaveLength(2); + expect(transactionEvent.spans).toEqual([ + expect.objectContaining({ + op: 'db', + description: 'durable_object_storage_get', + origin: 'auto.db.cloudflare.durable_object', + }), + expect.objectContaining({ + op: 'db', + description: 'durable_object_storage_put', + origin: 'auto.db.cloudflare.durable_object', + }), + ]); +} + +// A workflow step runs in its own invocation and reports a `function.step.do` / +// `auto.faas.cloudflare.workflow` transaction named after the step — present +// only when the Workflow class was wrapped with `instrumentWorkflowWithSentry`. +function expectWorkflowStepTransaction(transactionEvent: TransactionEvent): void { + expect(transactionEvent.transaction).toBe('step-one'); + expect(transactionEvent.contexts?.trace?.op).toBe('function.step.do'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow'); +} + +// The main worker transaction for `/increment` just forwards to the DO, so it +// carries no child spans. The empty-spans assertion keeps it disjoint from the +// DO and workflow transactions regardless of arrival order. +function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), + }), + }), + ); + expect(transactionEvent.spans).toHaveLength(0); +} + +// A Durable Object and a Workflow are both exported through a single specifier +// list (`export { Counter, MyWorkflow }`) instead of inline `export class`. The +// transform renames each local class and rebinds the exported name to the +// kind-specific wrapper, so the DO storage spans and the `step-one` workflow +// transaction only arrive if the specifier form was handled for both kinds. +it('auto-instruments a Durable Object and a Workflow exported via a specifier list', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectWorkflowStepTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/increment'); + await runner.makeRequest('get', '/workflow/trigger'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/vite.config.mts new file mode 100644 index 000000000000..47ee9a8178fb --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/vite.config.mts @@ -0,0 +1,17 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + // The Sentry plugin runs first so its build-time transform wraps the worker + // entry and both specifier-exported classes before the Cloudflare plugin + // bundles it. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/wrangler.jsonc new file mode 100644 index 000000000000..326b55d3eae2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow-specifier/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject-workflow-specifier", + // `main` points at the source entry; the Sentry Vite plugin builds from it (so + // the auto-instrument transform runs) and the runner serves the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], + "workflows": [ + { + "name": "my-workflow", + "binding": "MY_WORKFLOW", + "class_name": "MyWorkflow", + }, + ], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/index.ts new file mode 100644 index 000000000000..8bd15031b42d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/index.ts @@ -0,0 +1,56 @@ +import { DurableObject, WorkflowEntrypoint } from 'cloudflare:workers'; +import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; + MY_WORKFLOW: Workflow; +} + +// A Durable Object and a Workflow live in the same entry, both plain inline +// exports and neither manually wrapped. The `@sentry/cloudflare/vite` plugin's +// auto-instrumentation must wrap each with its kind-specific helper — the DO via +// `instrumentDurableObjectWithSentry`, the Workflow via +// `instrumentWorkflowWithSentry` — plus the default export via `withSentry`. +export class Counter extends DurableObject { + async fetch(): Promise { + const current = ((await this.ctx.storage.get('count')) ?? 0) + 1; + await this.ctx.storage.put('count', current); + return Response.json({ count: current }); + } +} + +export class MyWorkflow extends WorkflowEntrypoint { + async run(_event: WorkflowEvent, step: WorkflowStep): Promise { + await step.do('step-one', async () => 'Step one completed'); + } +} + +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 stub.fetch(new Request('https://do/increment')); + } + + if (url.pathname === '/workflow/trigger') { + const instance = await env.MY_WORKFLOW.create(); + for (let i = 0; i < 15; i++) { + try { + const s = await instance.status(); + if (s.status === 'complete' || s.status === 'errored') { + return Response.json({ id: instance.id, ...s }); + } + } catch { + // status() may not be available in local dev + } + await new Promise(r => setTimeout(r, 500)); + } + return Response.json({ id: instance.id, status: 'timeout' }); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts new file mode 100644 index 000000000000..f7122c460703 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/test.ts @@ -0,0 +1,69 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// A fetch-invoked Durable Object emits an `http.server` transaction whose only +// children are the two `auto.db.cloudflare.durable_object` storage spans +// (`get` + `put`) — present only when the class was auto-instrumented. +function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), + }), + }), + ); + expect(transactionEvent.spans).toHaveLength(2); + expect(transactionEvent.spans).toEqual([ + expect.objectContaining({ + op: 'db', + description: 'durable_object_storage_get', + origin: 'auto.db.cloudflare.durable_object', + }), + expect.objectContaining({ + op: 'db', + description: 'durable_object_storage_put', + origin: 'auto.db.cloudflare.durable_object', + }), + ]); +} + +// A workflow step runs in its own invocation and reports a `function.step.do` / +// `auto.faas.cloudflare.workflow` transaction named after the step — present +// only when the Workflow class was wrapped with `instrumentWorkflowWithSentry`. +function expectWorkflowStepTransaction(transactionEvent: TransactionEvent): void { + expect(transactionEvent.transaction).toBe('step-one'); + expect(transactionEvent.contexts?.trace?.op).toBe('function.step.do'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow'); +} + +// The main worker transaction for `/increment` just forwards to the DO, so it +// carries no child spans. The empty-spans assertion keeps it disjoint from the +// DO and workflow transactions regardless of arrival order. +function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void { + expect(transactionEvent).toEqual( + expect.objectContaining({ + contexts: expect.objectContaining({ + trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }), + }), + }), + ); + expect(transactionEvent.spans).toHaveLength(0); +} + +// A single entry exports both a Durable Object and a Workflow, neither wrapped by +// hand. The transform must wrap each with its kind-specific helper: hitting the +// DO yields a storage-bearing DO transaction and triggering the workflow yields a +// `step-one` transaction. Both only arrive if both classes were auto-wrapped. +it('auto-instruments a Durable Object and a Workflow in the same entry', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectWorkflowStepTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/increment'); + await runner.makeRequest('get', '/workflow/trigger'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/vite.config.mts new file mode 100644 index 000000000000..1d09acff5c6f --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/vite.config.mts @@ -0,0 +1,17 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + // The Sentry plugin runs first so its build-time transform wraps the worker + // entry, the `Counter` Durable Object, and the `MyWorkflow` Workflow before the + // Cloudflare plugin bundles it. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/wrangler.jsonc new file mode 100644 index 000000000000..87cd01f3cc44 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-workflow/wrangler.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject-workflow", + // `main` points at the source entry; the Sentry Vite plugin builds from it (so + // the auto-instrument transform runs) and the runner serves the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], + "workflows": [ + { + "name": "my-workflow", + "binding": "MY_WORKFLOW", + "class_name": "MyWorkflow", + }, + ], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/index.ts new file mode 100644 index 000000000000..1a6d037469ec --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/index.ts @@ -0,0 +1,41 @@ +import { WorkflowEntrypoint } from 'cloudflare:workers'; +import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_WORKFLOW: Workflow; +} + +// Neither the Workflow class nor the default handler is manually wrapped. The +// `@sentry/cloudflare/vite` plugin's auto-instrumentation wraps both at build +// time — `MyWorkflow` via `instrumentWorkflowWithSentry`, the default export +// via `withSentry`. +export class MyWorkflow extends WorkflowEntrypoint { + async run(_event: WorkflowEvent, step: WorkflowStep): Promise { + await step.do('step-one', async () => 'Step one completed'); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/workflow/trigger') { + const instance = await env.MY_WORKFLOW.create(); + for (let i = 0; i < 15; i++) { + try { + const s = await instance.status(); + if (s.status === 'complete' || s.status === 'errored') { + return Response.json({ id: instance.id, ...s }); + } + } catch { + // status() may not be available in local dev + } + await new Promise(r => setTimeout(r, 500)); + } + return Response.json({ id: instance.id, status: 'timeout' }); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/instrument.server.ts @@ -0,0 +1,6 @@ +import { defineCloudflareOptions } from '@sentry/cloudflare'; + +export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1.0, +})); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/test.ts new file mode 100644 index 000000000000..e4e517387760 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/test.ts @@ -0,0 +1,21 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// The worker is built by the Sentry Vite plugin (auto-instrumentation on). The +// runner detects `vite.config.mts`, runs `vite build`, and serves the generated +// output — so a workflow-step transaction only arrives if the build-time +// transform wrapped `MyWorkflow` with `instrumentWorkflowWithSentry`. +it('auto-instruments a Workflow class', async ({ signal }) => { + const runner = createRunner(__dirname) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + expect(transactionEvent.transaction).toBe('step-one'); + expect(transactionEvent.contexts?.trace?.op).toBe('function.step.do'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.faas.cloudflare.workflow'); + }) + .start(signal); + + await runner.makeRequest('get', '/workflow/trigger'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/vite.config.mts new file mode 100644 index 000000000000..49cd4b297b8e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/vite.config.mts @@ -0,0 +1,16 @@ +import { cloudflare } from '@cloudflare/vite-plugin'; +import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + // The Sentry plugin runs first so its build-time transform wraps the worker + // entry and the `MyWorkflow` class before the Cloudflare plugin bundles it. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/wrangler.jsonc new file mode 100644 index 000000000000..d21aa21a4659 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workflow/wrangler.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-workflow", + // `main` points at the source entry; the Sentry Vite plugin builds from it (so + // the auto-instrument transform runs) and the runner serves the built output. + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_als"], + "workflows": [ + { + "name": "my-workflow", + "binding": "MY_WORKFLOW", + "class_name": "MyWorkflow", + }, + ], +} diff --git a/packages/cloudflare/src/vite/autoInstrument.ts b/packages/cloudflare/src/vite/autoInstrument.ts index 331676ea1995..172741157b14 100644 --- a/packages/cloudflare/src/vite/autoInstrument.ts +++ b/packages/cloudflare/src/vite/autoInstrument.ts @@ -82,6 +82,9 @@ export function sentryCloudflareAutoInstrumentPlugin() { for (const { className } of wranglerConfig.durableObjects) { classWrappers.set(className, 'durableObject'); } + for (const { className } of wranglerConfig.workflows) { + classWrappers.set(className, 'workflow'); + } // No registration import is injected here: the orchestrion plugin's // subscribe-injection makes each bundled package self-register its channel diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 7050f594c722..89a859a5b73b 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -73,11 +73,12 @@ function isCallToMethod(node: BaseNode, methodName: string): boolean { * (`durable_objects.bindings`, …) — never by inspecting the class body — so the * transform stays a purely syntactic rewrite. */ -export type ClassWrapperKind = 'durableObject'; +export type ClassWrapperKind = 'durableObject' | 'workflow'; /** The `@sentry/cloudflare` helper each wrapper kind emits. */ const WRAPPER_METHODS: Record = { durableObject: 'instrumentDurableObjectWithSentry', + workflow: 'instrumentWorkflowWithSentry', }; export interface TransformContext { diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts index 267e0fb10f4c..dfaca12d1e0b 100644 --- a/packages/cloudflare/src/vite/wranglerConfig.ts +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -10,6 +10,7 @@ import { type Unstable_Config, unstable_readConfig } from 'wrangler'; export interface WranglerConfig { main?: string; durableObjects: Array<{ name: string; className: string }>; + workflows: Array<{ name: string; className: string }>; } /** @@ -20,8 +21,8 @@ export interface WranglerConfig { * `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. + * path. Durable Object and Workflow 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 @@ -48,20 +49,33 @@ export function resolveWranglerConfig( return undefined; } - const durableObjects: WranglerConfig['durableObjects'] = []; + return { + config: { + main: raw.main, + durableObjects: collectClassBindings(raw.durable_objects?.bindings), + workflows: collectClassBindings(raw.workflows), + }, + configDir: dirname(raw.configPath ?? configPath), + }; +} + +/** + * Map wrangler class bindings (Durable Objects, Workflows — same shape) to the + * `{ name, className }` the transform needs, skipping duplicates and bindings + * with a `script_name` (those reference a class exported by a *different* + * worker, so there is nothing to wrap in this worker's entry file). + */ +function collectClassBindings( + bindings: ReadonlyArray<{ name: string; class_name?: string; script_name?: string }> | undefined, +): Array<{ name: string; className: string }> { + const result: Array<{ name: string; className: string }> = []; 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. + for (const binding of bindings ?? []) { 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 }); + result.push({ name: binding.name, className: binding.class_name }); } - - return { - config: { main: raw.main, durableObjects }, - configDir: dirname(raw.configPath ?? configPath), - }; + return result; } diff --git a/packages/cloudflare/test/vite/autoInstrument.test.ts b/packages/cloudflare/test/vite/autoInstrument.test.ts index efa63695280e..94ac59924356 100644 --- a/packages/cloudflare/test/vite/autoInstrument.test.ts +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -109,6 +109,27 @@ describe('sentryCloudflareAutoInstrumentPlugin', () => { expect(result).toBeUndefined(); }); + it('wraps a configured workflow class in the entry', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "index.ts"', + '', + '[[workflows]]', + 'name = "my-workflow"', + 'binding = "MY_WF"', + 'class_name = "MyWorkflow"', + ].join('\n'), + }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const code = ['class WorkflowEntrypoint {}', 'export class MyWorkflow extends WorkflowEntrypoint {}'].join('\n'); + const result = plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, join(dir, 'index.ts')); + + expect(result).toBeDefined(); + expect(result.code).toContain('__SENTRY__.instrumentWorkflowWithSentry('); + }); + it('warns when a configured DO class cannot be wrapped', () => { const dir = writeTempDir({ 'wrangler.toml': [ diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 4abacfc429f2..f1c47881e2ac 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -11,6 +11,11 @@ function doWrappers(...names: string[]): Map { return new Map(names.map(name => [name, 'durableObject'])); } +/** Build a `classWrappers` map with every given class name marked as a Workflow. */ +function workflowWrappers(...names: string[]): Map { + return new Map(names.map(name => [name, 'workflow'])); +} + function transform(code: string, ctx: TransformContext) { return applyAutoInstrumentTransforms(code, parseJS(code), ctx); } @@ -231,7 +236,70 @@ describe('Durable Object class wrapping', () => { }); // --------------------------------------------------------------------------- -// Combined transforms (DO + default export) +// Workflow class wrapping +// --------------------------------------------------------------------------- + +describe('Workflow class wrapping', () => { + const ctx: TransformContext = { + classWrappers: workflowWrappers('MyWorkflow'), + optionsFn: '(env) => ({})', + }; + + it('wraps an exported workflow class with instrumentWorkflowWithSentry', () => { + const code = [ + 'class WorkflowEntrypoint {}', + 'export class MyWorkflow extends WorkflowEntrypoint {', + ' async run(event, step) {}', + '}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('class __SENTRY_ORIGINAL_MyWorkflow__'); + expect(result.code).not.toContain('export class MyWorkflow'); + expect(result.code).toContain('export const MyWorkflow = __SENTRY__.instrumentWorkflowWithSentry('); + // A workflow must never be wrapped with the DO helper. + expect(result.code).not.toContain('instrumentDurableObjectWithSentry'); + expect(result.wrappedClasses).toEqual(new Set(['MyWorkflow'])); + }); + + it('wraps a workflow class exported via a specifier', () => { + const code = [ + 'class WorkflowEntrypoint {}', + 'class MyWorkflow extends WorkflowEntrypoint {}', + 'export { MyWorkflow };', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain( + 'const MyWorkflow = __SENTRY__.instrumentWorkflowWithSentry((env) => ({}), __SENTRY_ORIGINAL_MyWorkflow__);', + ); + expect(result.code).toContain('export { MyWorkflow };'); + expect(result.wrappedClasses).toEqual(new Set(['MyWorkflow'])); + }); + + it('counts a manually wrapped workflow export as wrapped without touching it', () => { + const code = [ + "import { instrumentWorkflowWithSentry } from '@sentry/cloudflare';", + 'class Impl {}', + 'export const MyWorkflow = instrumentWorkflowWithSentry((env) => ({}), Impl);', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.wrappedClasses).toEqual(new Set(['MyWorkflow'])); + expect(result.code).toBe(code); + }); + + it('ignores workflow classes not listed in wrangler config', () => { + const code = ['class WorkflowEntrypoint {}', 'export class SomeOtherWorkflow extends WorkflowEntrypoint {}'].join( + '\n', + ); + expect(transform(code, ctx)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Combined transforms (DO + Workflow + default export) // --------------------------------------------------------------------------- describe('combined transforms', () => { @@ -240,6 +308,30 @@ describe('combined transforms', () => { optionsFn: '(env) => ({ dsn: env.SENTRY_DSN })', }; + it('wraps a DO and a Workflow with their respective helpers', () => { + const mixed: TransformContext = { + classWrappers: new Map([ + ['MyDO', 'durableObject'], + ['MyWorkflow', 'workflow'], + ]), + optionsFn: '(env) => ({})', + }; + + const code = [ + 'class DurableObject {}', + 'class WorkflowEntrypoint {}', + 'export class MyDO extends DurableObject {}', + 'export class MyWorkflow extends WorkflowEntrypoint {}', + ].join('\n'); + + const result = transform(code, mixed)!; + expect(result.code).toContain('export const MyDO = __SENTRY__.instrumentDurableObjectWithSentry('); + expect(result.code).toContain('export const MyWorkflow = __SENTRY__.instrumentWorkflowWithSentry('); + expect(result.wrappedClasses).toEqual(new Set(['MyDO', 'MyWorkflow'])); + const importCount = (result.code.match(/import \* as __SENTRY__/g) ?? []).length; + expect(importCount).toBe(1); + }); + it('wraps both DO class and default export', () => { const code = [ 'class DurableObject {}', diff --git a/packages/cloudflare/test/vite/wranglerConfig.test.ts b/packages/cloudflare/test/vite/wranglerConfig.test.ts index 04b22cbffb67..0279fd240333 100644 --- a/packages/cloudflare/test/vite/wranglerConfig.test.ts +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; +import { unstable_readConfig } from 'wrangler'; import { resolveWranglerConfig } from '../../src/vite/wranglerConfig'; function writeTempDir(files: Record): string { @@ -247,4 +248,136 @@ describe('resolveWranglerConfig', () => { else process.env.CLOUDFLARE_ENV = previous; } }); + + it('parses workflow bindings', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + workflows: [ + { name: 'my-workflow', binding: 'MY_WF', class_name: 'MyWorkflow' }, + { name: 'other', binding: 'OTHER_WF', class_name: 'OtherWorkflow' }, + ], + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workflows).toEqual([ + { name: 'my-workflow', className: 'MyWorkflow' }, + { name: 'other', className: 'OtherWorkflow' }, + ]); + }); + + it('parses workflow bindings from TOML', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "src/index.ts"', + '', + '[[workflows]]', + 'name = "my-workflow"', + 'binding = "MY_WF"', + 'class_name = "MyWorkflow"', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workflows).toEqual([{ name: 'my-workflow', className: 'MyWorkflow' }]); + }); + + it('skips workflow bindings with a script_name (class lives in another worker)', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + workflows: [ + { name: 'local', binding: 'LOCAL_WF', class_name: 'LocalWorkflow' }, + { name: 'external', binding: 'EXT_WF', class_name: 'ExternalWorkflow', script_name: 'other-worker' }, + ], + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workflows).toEqual([{ name: 'local', className: 'LocalWorkflow' }]); + }); + + it('defaults workflows to an empty array when none are configured', () => { + const dir = writeTempDir({ 'wrangler.json': JSON.stringify({ main: 'src/index.ts' }) }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workflows).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// What `unstable_readConfig` exposes about service-binding `entrypoint`. +// +// These characterize the wrangler API directly (not our wrapper) to justify a +// design decision: a service binding's `entrypoint` names a *named export on +// the target worker being bound to*, not an entrypoint this worker exposes. +// So it cannot, in general, tell auto-wrap which of *this* worker's exports is +// a handler — with one exception: a self-binding (`service === own name`). +// --------------------------------------------------------------------------- + +describe('unstable_readConfig: service-binding entrypoint semantics', () => { + function readConfig(files: Record) { + const dir = writeTempDir(files); + return unstable_readConfig({ config: join(dir, Object.keys(files)[0]!) }, { hideWarnings: true }); + } + + it('resolves `main` to an absolute path', () => { + const raw = readConfig({ + 'wrangler.json': JSON.stringify({ main: 'src/index.ts', compatibility_date: '2024-01-01' }), + }); + // Not the literal `src/index.ts` from the file — wrangler resolves it. + expect(raw.main).not.toBe('src/index.ts'); + expect(raw.main?.endsWith(join('src', 'index.ts'))).toBe(true); + }); + + it("an outward service binding names the *target* worker's export, not ours", () => { + const raw = readConfig({ + 'wrangler.json': JSON.stringify({ + name: 'worker-a', + main: 'src/index.ts', + compatibility_date: '2024-01-01', + services: [{ binding: 'MY_SVC', service: 'worker-b', entrypoint: 'SomeEntry' }], + }), + }); + + expect(raw.name).toBe('worker-a'); + // `entrypoint` belongs to `worker-b`, a different worker this build isn't + // compiling — nothing in *our* entry file to wrap from this. + expect(raw.services).toEqual([{ binding: 'MY_SVC', service: 'worker-b', entrypoint: 'SomeEntry' }]); + expect(raw.services?.[0]?.service).not.toBe(raw.name); + }); + + it('a self-binding (service === own name) does name one of *our* exports', () => { + const raw = readConfig({ + 'wrangler.json': JSON.stringify({ + name: 'worker-self', + main: 'src/index.ts', + compatibility_date: '2024-01-01', + services: [ + { binding: 'SELF', service: 'worker-self', entrypoint: 'InternalEntry' }, + { binding: 'OTHER', service: 'worker-x', entrypoint: 'RemoteEntry' }, + ], + }), + }); + + // Only the self-bound entrypoint is ours; the other points at `worker-x`. + const ownEntrypoints = (raw.services ?? []).filter(s => s.service === raw.name).map(s => s.entrypoint); + expect(ownEntrypoints).toEqual(['InternalEntry']); + }); + + it('leaves `name` undefined when the config omits it (no self-binding is derivable)', () => { + const raw = readConfig({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + compatibility_date: '2024-01-01', + services: [{ binding: 'S', service: 'x', entrypoint: 'E' }], + }), + }); + + // Without a worker name there is no `service === name` to match against, so + // even self-bindings can't be identified. + expect(raw.name).toBeUndefined(); + expect(raw.topLevelName).toBeUndefined(); + }); });