diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/index.ts new file mode 100644 index 000000000000..c88daa21fa21 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/index.ts @@ -0,0 +1,44 @@ +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + SELF: Fetcher; + COUNTER: DurableObjectNamespace; +} + +// The entrypoint itself reaches into the Durable Object. This exercises a nested +// chain — default handler → auto-wrapped `WorkerEntrypoint` → auto-wrapped +// `DurableObject` — proving a DO invoked from *inside* an auto-instrumented +// entrypoint is still instrumented. Nothing here is manually wrapped. +export class CounterEntrypoint extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === '/work') { + const stub = this.env.COUNTER.get(this.env.COUNTER.idFromName('e2e')); + return stub.fetch(new Request('https://do/increment')); + } + return new Response('Not found', { status: 404 }); + } +} + +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 default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/chain') { + // Hops into the entrypoint (which in turn hits the DO) via the self service + // binding, so the whole auto-wrapped chain runs on one request. + return env.SELF.fetch(new Request('https://self/work')); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/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/combination-entrypoint-do-chained/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/test.ts new file mode 100644 index 000000000000..42e7a536a9a9 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/test.ts @@ -0,0 +1,50 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// The Durable Object, reached from inside the entrypoint, 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.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); + 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', + }), + ]); +} + +function expectPlainTransaction(name: string) { + return (transactionEvent: TransactionEvent): void => { + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); + expect(transactionEvent.transaction).toBe(name); + expect(transactionEvent.spans).toHaveLength(0); + }; +} + +// A single request fans out through the whole auto-wrapped chain: default +// handler (`/chain`) → self-bound `CounterEntrypoint` (`/work`) → `Counter` +// Durable Object. All three transactions arrive only if the build-time transform +// wrapped the default export, the entrypoint, and the DO — and it proves a DO +// invoked from *within* an auto-instrumented entrypoint is itself instrumented. +it('auto-instruments a Durable Object invoked from within a WorkerEntrypoint', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => expectPlainTransaction('GET /chain')(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectPlainTransaction('GET /work')(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/chain'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/vite.config.mts new file mode 100644 index 000000000000..34eb08bdcdcf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/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 self-bound `CounterEntrypoint`, and the `Counter` Durable Object + // before the Cloudflare plugin bundles it. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/wrangler.jsonc new file mode 100644 index 000000000000..64b8c143d01b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-chained/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-combination-entrypoint-do-chained", + // `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"], + // Self-service-binding: the worker binds to its own named `WorkerEntrypoint` + // export, so the auto-instrument transform can identify and wrap it. + "services": [ + { + "binding": "SELF", + "service": "cloudflare-vite-autoinstrument-combination-entrypoint-do-chained", + "entrypoint": "CounterEntrypoint", + }, + ], + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/index.ts new file mode 100644 index 000000000000..075aa006aa21 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/index.ts @@ -0,0 +1,54 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + SELF: Fetcher; + COUNTER: DurableObjectNamespace; +} + +// The WorkerEntrypoint is left plain — the auto-instrument transform must wrap +// it (and the default export) at build time. +export class GreeterEntrypoint extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === '/greet') { + return new Response('Hello from the entrypoint'); + } + return new Response('Not found', { status: 404 }); + } +} + +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 already wrapped manually. The transform must detect the +// existing `Sentry.instrumentDurableObjectWithSentry` call and leave it +// untouched (no double-wrap) while still auto-wrapping the plain entrypoint and +// default export in the same file. +export const Counter = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }), + CounterImpl, +); + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/call-entrypoint') { + return env.SELF.fetch(new Request('https://self/greet')); + } + + if (url.pathname === '/increment') { + const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e')); + return stub.fetch(new Request('https://do/increment')); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-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/combination-entrypoint-do-manual-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts new file mode 100644 index 000000000000..70879a90ca71 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/test.ts @@ -0,0 +1,56 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// A Durable Object invoked via `fetch` emits an `http.server` transaction whose +// only children are the two `auto.db.cloudflare.durable_object` storage spans +// (`get` + `put`). Here they come from the manual wrap — the assertion also +// proves the transform did NOT double-wrap (a double-wrap would nest proxies or +// break the build). +function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void { + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); + 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', + }), + ]); +} + +function expectPlainTransaction(name: string) { + return (transactionEvent: TransactionEvent): void => { + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); + expect(transactionEvent.transaction).toBe(name); + expect(transactionEvent.spans).toHaveLength(0); + }; +} + +// The Durable Object is manually wrapped with +// `Sentry.instrumentDurableObjectWithSentry`; the `GreeterEntrypoint` and the +// default export are plain. The transform must skip the manual DO (no +// double-wrap) yet still auto-wrap the entrypoint and default handler — so the +// manual DO transaction (with storage spans) and both auto-wrapped transactions +// all arrive exactly once. +it('leaves a manually wrapped Durable Object untouched while auto-wrapping a sibling WorkerEntrypoint', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => expectPlainTransaction('GET /call-entrypoint')(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectPlainTransaction('GET /greet')(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectPlainTransaction('GET /increment')(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/call-entrypoint'); + await runner.makeRequest('get', '/increment'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/vite.config.mts new file mode 100644 index 000000000000..a669042495e9 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-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 runs over the + // worker entry — it must skip the manually wrapped `Counter` Durable Object + // and only auto-wrap the plain `GreeterEntrypoint` and default export. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/wrangler.jsonc new file mode 100644 index 000000000000..1f20fdfa5e0d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do-manual-mixed/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-combination-entrypoint-do-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"], + // Self-service-binding: the worker binds to its own named `WorkerEntrypoint` + // export, so the auto-instrument transform can identify and wrap it. + "services": [ + { + "binding": "SELF", + "service": "cloudflare-vite-autoinstrument-combination-entrypoint-do-manual-mixed", + "entrypoint": "GreeterEntrypoint", + }, + ], + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/index.ts new file mode 100644 index 000000000000..d18f8a79c8ce --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/index.ts @@ -0,0 +1,49 @@ +import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + SELF: Fetcher; + COUNTER: DurableObjectNamespace; +} + +// A single worker exporting both a plain `WorkerEntrypoint` and a plain +// `DurableObject` alongside a plain default handler — none manually wrapped. The +// `@sentry/cloudflare/vite` plugin's auto-instrumentation must wrap all three at +// build time: `GreeterEntrypoint` (self-bound in wrangler.jsonc), `Counter` via +// `instrumentDurableObjectWithSentry`, and the default export via `withSentry`. +export class GreeterEntrypoint extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === '/greet') { + return new Response('Hello from the entrypoint'); + } + return new Response('Not found', { status: 404 }); + } +} + +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 default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/call-entrypoint') { + // Loops back into this worker's own `GreeterEntrypoint` via the self + // service binding, so the auto-wrapped entrypoint actually runs. + return env.SELF.fetch(new Request('https://self/greet')); + } + + if (url.pathname === '/increment') { + const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e')); + return stub.fetch(new Request('https://do/increment')); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/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/combination-entrypoint-do/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts new file mode 100644 index 000000000000..e50bceea5be2 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/test.ts @@ -0,0 +1,55 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// A Durable Object invoked via `fetch` 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.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); + 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 plain `http.server` transaction with no child spans, identified by its +// transaction name. Used for the two main-worker entries and the entrypoint — +// asserting the name keeps each expectation disjoint under unordered matching. +function expectPlainTransaction(name: string) { + return (transactionEvent: TransactionEvent): void => { + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare'); + expect(transactionEvent.transaction).toBe(name); + expect(transactionEvent.spans).toHaveLength(0); + }; +} + +// A single worker exports a plain `WorkerEntrypoint`, a plain `DurableObject`, +// and a plain default handler. The runner builds it with the Sentry Vite plugin +// (auto-instrumentation on) and serves the output — so every transaction below +// only arrives if the build-time transform wrapped all three: `withSentry` for +// the default export, the self-bound `GreeterEntrypoint`, and `Counter` via +// `instrumentDurableObjectWithSentry`. +it('auto-instruments a WorkerEntrypoint and a Durable Object exported from the same worker', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => expectPlainTransaction('GET /call-entrypoint')(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectPlainTransaction('GET /greet')(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectPlainTransaction('GET /increment')(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/call-entrypoint'); + await runner.makeRequest('get', '/increment'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/vite.config.mts new file mode 100644 index 000000000000..4a71fda44344 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/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 self-bound `GreeterEntrypoint`, and the `Counter` Durable Object + // before the Cloudflare plugin bundles it. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/wrangler.jsonc new file mode 100644 index 000000000000..8771cbe8153d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/combination-entrypoint-do/wrangler.jsonc @@ -0,0 +1,22 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-combination-entrypoint-do", + // `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"], + // Self-service-binding: the worker binds to its own named `WorkerEntrypoint` + // export, so the auto-instrument transform can identify and wrap it. + "services": [ + { + "binding": "SELF", + "service": "cloudflare-vite-autoinstrument-combination-entrypoint-do", + "entrypoint": "GreeterEntrypoint", + }, + ], + "durable_objects": { + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/index.ts new file mode 100644 index 000000000000..581a6bdc96df --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/index.ts @@ -0,0 +1,34 @@ +import { WorkerEntrypoint } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + SELF: Fetcher; +} + +// Neither the named entrypoint nor the default handler is manually wrapped. The +// `@sentry/cloudflare/vite` plugin's auto-instrumentation wraps both at build +// time — `GreeterEntrypoint` because it extends `WorkerEntrypoint` (and is +// self-bound in wrangler.jsonc), the default export via `withSentry`. +export class GreeterEntrypoint extends WorkerEntrypoint { + async fetch(request: Request): Promise { + const url = new URL(request.url); + if (url.pathname === '/greet') { + return new Response('Hello from the entrypoint'); + } + return new Response('Not found', { status: 404 }); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/call-entrypoint') { + // Loops back into this worker's own `GreeterEntrypoint` via the self + // service binding, so the auto-wrapped entrypoint actually runs. + return env.SELF.fetch(new Request('https://self/greet')); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/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/workerentrypoint/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/test.ts new file mode 100644 index 000000000000..de3e96f9850d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/test.ts @@ -0,0 +1,30 @@ +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 these transactions only arrive if the build-time transform wrapped +// both the default handler and the self-bound `GreeterEntrypoint`. +it('auto-instruments the default handler and a self-bound WorkerEntrypoint', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + // Main worker's http.server transaction — proves `withSentry` wrapped the + // unwrapped default export. + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.transaction).toBe('GET /call-entrypoint'); + }) + .expect(envelope => { + const transactionEvent = envelope[1]?.[0]?.[1] as TransactionEvent; + // The entrypoint's own http.server transaction — proves the auto-wrap + // identified and wrapped the named `WorkerEntrypoint`. + expect(transactionEvent.contexts?.trace?.op).toBe('http.server'); + expect(transactionEvent.transaction).toBe('GET /greet'); + }) + .start(signal); + + await runner.makeRequest('get', '/call-entrypoint'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/vite.config.mts new file mode 100644 index 000000000000..f68e50f0019d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/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 the self-bound `GreeterEntrypoint` before the Cloudflare plugin + // bundles it. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/wrangler.jsonc new file mode 100644 index 000000000000..011471c55e9e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/workerentrypoint/wrangler.jsonc @@ -0,0 +1,18 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-workerentrypoint", + // `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"], + // Self-service-binding: the worker binds to its own named `WorkerEntrypoint` + // export, so the auto-instrument transform can identify and wrap it. + "services": [ + { + "binding": "SELF", + "service": "cloudflare-vite-autoinstrument-workerentrypoint", + "entrypoint": "GreeterEntrypoint", + }, + ], +} diff --git a/packages/cloudflare/src/vite/autoInstrument.ts b/packages/cloudflare/src/vite/autoInstrument.ts index 172741157b14..d69e879b3cd1 100644 --- a/packages/cloudflare/src/vite/autoInstrument.ts +++ b/packages/cloudflare/src/vite/autoInstrument.ts @@ -85,6 +85,9 @@ export function sentryCloudflareAutoInstrumentPlugin() { for (const { className } of wranglerConfig.workflows) { classWrappers.set(className, 'workflow'); } + for (const className of wranglerConfig.workerEntrypoints) { + classWrappers.set(className, 'workerEntrypoint'); + } // 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 89a859a5b73b..05af1cce0cea 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -1,4 +1,5 @@ import MagicString from 'magic-string'; +import { detectWorkerEntrypointClasses } from './workerEntrypoint'; // --------------------------------------------------------------------------- // Minimal ESTree node types for the AST nodes we inspect. @@ -68,17 +69,27 @@ function isCallToMethod(node: BaseNode, methodName: string): boolean { } /** - * The kind of Sentry wrapper to apply to a configured class export. Which kind - * a class gets is decided by the wrangler config section its name was read from - * (`durable_objects.bindings`, …) — never by inspecting the class body — so the - * transform stays a purely syntactic rewrite. + * The kind of Sentry wrapper to apply to an exported class. + * + * `durableObject` and `workflow` are keyed by name from the wrangler config + * (`durable_objects.bindings`, `workflows`), since those class names are + * authoritative there. `workerEntrypoint` is different: a worker's own + * entrypoints aren't enumerated in its config, so they're detected structurally + * (a class extending `WorkerEntrypoint` from `cloudflare:workers`), with the + * config providing only a fallback for self-bound entrypoints whose base class + * lives in another module. */ -export type ClassWrapperKind = 'durableObject' | 'workflow'; +export type ClassWrapperKind = 'durableObject' | 'workflow' | 'workerEntrypoint'; -/** The `@sentry/cloudflare` helper each wrapper kind emits. */ +/** + * The `@sentry/cloudflare` helper each wrapper kind emits. All share the same + * `(optionsCallback, Class)` signature. `WorkerEntrypoint` classes use + * `withSentry`, which runtime-detects the class type and routes accordingly. + */ const WRAPPER_METHODS: Record = { durableObject: 'instrumentDurableObjectWithSentry', workflow: 'instrumentWorkflowWithSentry', + workerEntrypoint: 'withSentry', }; export interface TransformContext { @@ -126,20 +137,30 @@ export function applyAutoInstrumentTransforms( ctx: TransformContext, ): TransformResult | undefined { const ms = new MagicString(code); + const topLevelClasses = collectTopLevelClasses(ast); const state: TransformState = { ms, needsImport: false, wrappedClasses: new Set(), - topLevelClasses: collectTopLevelClasses(ast), + topLevelClasses, renamedLocals: new Set(), + classWrappers: ctx.classWrappers, + workerEntrypointClasses: detectWorkerEntrypointClasses(ast), }; const { wrappedClasses } = state; + // Named exports first, regardless of source order: the default-export handler + // needs to know which local bindings a named export already wrapped, so it can + // skip a class that is both exported by name and re-exported as default (which + // would otherwise wrap it twice). + for (const node of ast.body) { + if (node.type === 'ExportNamedDeclaration') { + handleNamedExport(node as ExportNamedNode, ctx, state); + } + } for (const node of ast.body) { if (node.type === 'ExportDefaultDeclaration') { wrapDefaultExport(node as ExportDefaultNode, ctx, state); - } else if (node.type === 'ExportNamedDeclaration') { - handleNamedExport(node as ExportNamedNode, ctx, state); } } @@ -175,6 +196,33 @@ interface TransformState { * the same class don't produce duplicate bindings. */ renamedLocals: Set; + /** Class name → wrapper kind, keyed by the *exported* name (from config). */ + classWrappers: Map; + /** + * Local class names detected as `WorkerEntrypoint` subclasses in this module, + * so they can be wrapped without a config entry. + */ + workerEntrypointClasses: Set; +} + +/** + * Resolve the wrapper kind for a class export. + * + * Config (`classWrappers`) wins — it's authoritative for Durable Objects and + * Workflows, and provides the self-binding fallback for WorkerEntrypoints whose + * base class this module can't see. Otherwise a structurally-detected + * `WorkerEntrypoint` subclass (matched by its *local* name) gets wrapped with + * `withSentry`. + */ +function resolveWrapperKind( + exportedName: string, + localName: string | undefined, + state: TransformState, +): ClassWrapperKind | undefined { + const configured = state.classWrappers.get(exportedName); + if (configured) return configured; + if (localName && state.workerEntrypointClasses.has(localName)) return 'workerEntrypoint'; + return undefined; } function collectTopLevelClasses(ast: ProgramBody): Map { @@ -193,6 +241,11 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state // Already wrapped — leave it alone if (isCallToMethod(decl, 'withSentry')) return; + // `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; + // `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__ = '); @@ -246,7 +299,8 @@ function wrapInlineClassExport( state: TransformState, ): void { const classId = classDecl.id; - const kind = classId ? ctx.classWrappers.get(classId.name) : undefined; + // Inline export: the exported name and the local class name are the same. + const kind = classId ? resolveWrapperKind(classId.name, classId.name, state) : undefined; if (!classId || !kind) return; const className = classId.name; @@ -272,10 +326,12 @@ function wrapInlineClassExport( function wrapSpecifierExport(specifier: ExportSpecifierNode, ctx: TransformContext, state: TransformState): void { if (specifier.type !== 'ExportSpecifier' || specifier.exported?.type !== 'Identifier') return; const exportedName = specifier.exported.name; - const kind = exportedName ? ctx.classWrappers.get(exportedName) : undefined; - if (!exportedName || !kind) return; + if (!exportedName) return; const localName = specifier.local?.type === 'Identifier' ? specifier.local.name : undefined; + const kind = resolveWrapperKind(exportedName, localName, state); + if (!kind) return; + const localClass = localName ? state.topLevelClasses.get(localName) : undefined; if (!localName || !localClass?.id) return; diff --git a/packages/cloudflare/src/vite/workerEntrypoint.ts b/packages/cloudflare/src/vite/workerEntrypoint.ts new file mode 100644 index 000000000000..0baf2720a4fd --- /dev/null +++ b/packages/cloudflare/src/vite/workerEntrypoint.ts @@ -0,0 +1,141 @@ +import type { BaseNode, ProgramBody } from './transform'; + +// --------------------------------------------------------------------------- +// Minimal ESTree node shapes for structural WorkerEntrypoint detection. +// --------------------------------------------------------------------------- + +interface IdentifierNode extends BaseNode { + name: string; +} + +interface MemberExpressionNode extends BaseNode { + object?: { type: string; name?: string }; + property?: { type: string; name?: string }; +} + +interface ClassDeclarationNode extends BaseNode { + id?: IdentifierNode | null; + superClass?: BaseNode | null; +} + +interface ExportNamedDeclNode extends BaseNode { + declaration?: BaseNode | null; +} + +interface ImportSpecifierNode { + type: string; + imported?: { type: string; name?: string }; + local?: { type: string; name?: string }; +} + +interface ImportDeclarationNode extends BaseNode { + source?: { value?: unknown }; + specifiers?: ImportSpecifierNode[]; +} + +interface WorkerEntrypointBases { + /** Local identifiers bound to the named `WorkerEntrypoint` import. */ + named: Set; + /** Local identifiers bound to a `* as ns` import of `cloudflare:workers`. */ + namespaces: Set; +} + +/** + * Find top-level classes that (transitively, within this module) extend + * `WorkerEntrypoint` imported from `cloudflare:workers`. esbuild has already + * stripped TypeScript by transform time, so a superclass is a plain identifier + * (`extends WorkerEntrypoint`) or member access (`extends cf.WorkerEntrypoint`). + * + * Only same-file base chains are resolvable here; a base class imported from + * another module is invisible and relies on the config self-binding fallback. + */ +export function detectWorkerEntrypointClasses(ast: ProgramBody): Set { + const bases = collectWorkerEntrypointImports(ast); + if (bases.named.size === 0 && bases.namespaces.size === 0) { + return new Set(); + } + + // Every top-level class, including the `export class Foo {}` form (where the + // class is nested inside an ExportNamedDeclaration) so directly-exported + // entrypoints are seen too. + const classes = new Map(); + for (const node of ast.body) { + const classNode = asClassDeclaration(node); + if (classNode?.id?.name) classes.set(classNode.id.name, classNode); + } + + const entrypoints = new Set(); + // Iterate to a fixed point so an indirect chain (A extends B extends WE) is + // fully resolved regardless of declaration order. + let changed = true; + while (changed) { + changed = false; + for (const [name, classNode] of classes) { + if (entrypoints.has(name)) continue; + if (extendsWorkerEntrypoint(classNode.superClass, bases, entrypoints)) { + entrypoints.add(name); + changed = true; + } + } + } + return entrypoints; +} + +/** Unwrap `export class Foo {}` to its ClassDeclaration; pass bare classes through. */ +function asClassDeclaration(node: BaseNode): ClassDeclarationNode | undefined { + if (node.type === 'ClassDeclaration') return node as ClassDeclarationNode; + if (node.type === 'ExportNamedDeclaration') { + const decl = (node as ExportNamedDeclNode).declaration; + if (decl?.type === 'ClassDeclaration') return decl as ClassDeclarationNode; + } + return undefined; +} + +function collectWorkerEntrypointImports(ast: ProgramBody): WorkerEntrypointBases { + const named = new Set(); + const namespaces = new Set(); + for (const node of ast.body) { + if (node.type !== 'ImportDeclaration') continue; + const importNode = node as ImportDeclarationNode; + if (importNode.source?.value !== 'cloudflare:workers') continue; + for (const specifier of importNode.specifiers ?? []) { + if ( + specifier.type === 'ImportSpecifier' && + specifier.imported?.name === 'WorkerEntrypoint' && + specifier.local?.name + ) { + named.add(specifier.local.name); + } else if (specifier.type === 'ImportNamespaceSpecifier' && specifier.local?.name) { + namespaces.add(specifier.local.name); + } + } + } + return { named, namespaces }; +} + +/** + * Whether a superclass expression resolves to `WorkerEntrypoint` — either a bare + * identifier from the named import (or an already-detected local subclass), or a + * `ns.WorkerEntrypoint` member access off a namespace import. + */ +function extendsWorkerEntrypoint( + superClass: BaseNode | null | undefined, + bases: WorkerEntrypointBases, + detected: Set, +): boolean { + if (!superClass) return false; + if (superClass.type === 'Identifier') { + const name = (superClass as IdentifierNode).name; + return bases.named.has(name) || detected.has(name); + } + if (superClass.type === 'MemberExpression') { + const member = superClass as MemberExpressionNode; + return ( + member.object?.type === 'Identifier' && + !!member.object.name && + bases.namespaces.has(member.object.name) && + member.property?.name === 'WorkerEntrypoint' + ); + } + return false; +} diff --git a/packages/cloudflare/src/vite/wranglerConfig.ts b/packages/cloudflare/src/vite/wranglerConfig.ts index dfaca12d1e0b..f95ca7264302 100644 --- a/packages/cloudflare/src/vite/wranglerConfig.ts +++ b/packages/cloudflare/src/vite/wranglerConfig.ts @@ -11,6 +11,13 @@ export interface WranglerConfig { main?: string; durableObjects: Array<{ name: string; className: string }>; workflows: Array<{ name: string; className: string }>; + /** + * Named `WorkerEntrypoint` exports this worker binds to itself via a service + * binding (`services[]` whose `service` is this worker's own `name`). Only + * self-bindings appear here: a service binding's `entrypoint` otherwise names + * an export on a *different* worker, which this build can't wrap. + */ + workerEntrypoints: string[]; } /** @@ -54,11 +61,31 @@ export function resolveWranglerConfig( main: raw.main, durableObjects: collectClassBindings(raw.durable_objects?.bindings), workflows: collectClassBindings(raw.workflows), + workerEntrypoints: collectSelfBoundEntrypoints(raw), }, configDir: dirname(raw.configPath ?? configPath), }; } +/** + * Collect named `WorkerEntrypoint` exports the worker binds to itself. A service + * binding's `entrypoint` normally names an export on the *target* worker, so it + * is only ours when `service` equals this worker's own `name`. Without a `name` + * there is nothing to match against, so no entrypoints are derivable. + */ +function collectSelfBoundEntrypoints(raw: Unstable_Config): string[] { + if (!raw.name) { + return []; + } + const entrypoints = new Set(); + for (const binding of raw.services ?? []) { + if (binding?.service === raw.name && typeof binding.entrypoint === 'string') { + entrypoints.add(binding.entrypoint); + } + } + return [...entrypoints]; +} + /** * 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 94ac59924356..640e278ae749 100644 --- a/packages/cloudflare/test/vite/autoInstrument.test.ts +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -1,16 +1,25 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parse } from 'acorn'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { sentryCloudflareAutoInstrumentPlugin } from '../../src/vite/autoInstrument'; function parseJS(code: string) { return parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as { body: any[] }; } +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } +}); + function writeTempDir(files: Record): string { const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-')); + tempDirs.push(dir); for (const [name, content] of Object.entries(files)) { writeFileSync(join(dir, name), content); } @@ -130,6 +139,99 @@ describe('sentryCloudflareAutoInstrumentPlugin', () => { expect(result.code).toContain('__SENTRY__.instrumentWorkflowWithSentry('); }); + it('wraps a directly-exported WorkerEntrypoint class (structural, no config)', () => { + const { transform: tx, entryPath } = createPlugin('main = "index.ts"'); + + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'export class AdminEntry extends WorkerEntrypoint {', + ' fetch() { return new Response("admin"); }', + '}', + ].join('\n'); + const result = tx(code, entryPath); + + expect(result).toBeDefined(); + expect(result.code).toBe( + [ + "import * as __SENTRY__ from '@sentry/cloudflare';", + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class __SENTRY_ORIGINAL_AdminEntry__ extends WorkerEntrypoint {', + ' fetch() { return new Response("admin"); }', + '}', + 'export const AdminEntry = __SENTRY__.withSentry(() => undefined, __SENTRY_ORIGINAL_AdminEntry__);', + '', + ].join('\n'), + ); + }); + + it('wraps a self-bound WorkerEntrypoint whose base class lives in another module (config fallback)', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + name: 'worker-self', + main: 'index.ts', + services: [{ binding: 'SELF', service: 'worker-self', entrypoint: 'AdminEntry' }], + }), + }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + // Base class is imported, so structural detection can't see it — the config + // self-binding supplies the name instead. + const code = ["import { BaseEntry } from './base';", 'export class AdminEntry extends BaseEntry {}'].join('\n'); + const result = plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, join(dir, 'index.ts')); + + expect(result).toBeDefined(); + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + }); + + it('wraps a WorkerEntrypoint named via a services[].entrypoint self-binding (jsonc config)', () => { + // Mirrors the `worker-workerentrypoint-rpc` integration test, which declares + // its entrypoints through `services[].entrypoint` in a wrangler.jsonc. + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' "name": "my-worker",', + ' "main": "index.ts",', + ' "services": [', + ' { "binding": "SELF", "service": "my-worker", "entrypoint": "BindingEntrypoint" },', + ' ],', + '}', + ].join('\n'), + }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + // Base class imported from another module, so only the config self-binding + // identifies `BindingEntrypoint` as an entrypoint to wrap. + const code = [ + "import { BaseEntrypoint } from './base';", + 'export class BindingEntrypoint extends BaseEntrypoint {}', + ].join('\n'); + const result = plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, join(dir, 'index.ts')); + + expect(result).toBeDefined(); + expect(result.code).toContain('export const BindingEntrypoint = __SENTRY__.withSentry('); + }); + + it('does not wrap an entrypoint that is neither detected nor self-bound', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + name: 'worker-self', + main: 'index.ts', + services: [{ binding: 'OTHER', service: 'worker-x', entrypoint: 'RemoteEntry' }], + }), + }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + // Base class imported (structural blind), and the only service binding is + // outward (names `worker-x`'s export), so there is nothing to wrap here. + const code = ["import { BaseEntry } from './base';", 'export class RemoteEntry extends BaseEntry {}'].join('\n'); + const result = plugin.transform.call({ parse: (c: string) => parseJS(c) }, code, join(dir, 'index.ts')); + + expect(result).toBeUndefined(); + }); + 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 f1c47881e2ac..d0e8b0690184 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -16,6 +16,11 @@ function workflowWrappers(...names: string[]): Map { return new Map(names.map(name => [name, 'workflow'])); } +/** Build a `classWrappers` map with every given class name marked as a WorkerEntrypoint. */ +function entrypointWrappers(...names: string[]): Map { + return new Map(names.map(name => [name, 'workerEntrypoint'])); +} + function transform(code: string, ctx: TransformContext) { return applyAutoInstrumentTransforms(code, parseJS(code), ctx); } @@ -298,6 +303,125 @@ describe('Workflow class wrapping', () => { }); }); +// --------------------------------------------------------------------------- +// WorkerEntrypoint class wrapping (structural detection) +// +// A worker's own entrypoints aren't listed in its wrangler config, so these are +// detected by their `extends WorkerEntrypoint` clause (the identifier imported +// from `cloudflare:workers`) rather than by a config entry. +// --------------------------------------------------------------------------- + +describe('WorkerEntrypoint class wrapping (structural)', () => { + // No config entry — detection is purely structural. + const ctx: TransformContext = { classWrappers: new Map(), optionsFn: '(env) => ({})' }; + + it('wraps a directly-exported class extending the imported WorkerEntrypoint', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'export class AdminEntry extends WorkerEntrypoint {', + ' fetch(request) { return new Response("admin"); }', + '}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('class __SENTRY_ORIGINAL_AdminEntry__'); + expect(result.code).not.toContain('export class AdminEntry'); + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + expect(result.wrappedClasses).toEqual(new Set(['AdminEntry'])); + }); + + it('wraps a class extending an aliased WorkerEntrypoint import', () => { + const code = [ + "import { WorkerEntrypoint as WE } from 'cloudflare:workers';", + 'export class AdminEntry extends WE {}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + }); + + it('wraps a class extending a namespace-imported WorkerEntrypoint', () => { + const code = [ + "import * as cf from 'cloudflare:workers';", + 'export class AdminEntry extends cf.WorkerEntrypoint {}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + }); + + it('wraps a class via an indirect same-file base chain', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class Base extends WorkerEntrypoint {}', + 'export class AdminEntry extends Base {}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + expect(result.wrappedClasses).toEqual(new Set(['AdminEntry'])); + }); + + it('wraps an entrypoint exported via a specifier', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class AdminEntry extends WorkerEntrypoint {}', + 'export { AdminEntry };', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain( + 'const AdminEntry = __SENTRY__.withSentry((env) => ({}), __SENTRY_ORIGINAL_AdminEntry__);', + ); + expect(result.code).toContain('export { AdminEntry };'); + expect(result.wrappedClasses).toEqual(new Set(['AdminEntry'])); + }); + + it('does not wrap a class extending a same-named local class (not the import)', () => { + // `WorkerEntrypoint` here is a local class, not the `cloudflare:workers` + // import, so it must not be mistaken for an entrypoint. + const code = ['class WorkerEntrypoint {}', 'export class NotAnEntry extends WorkerEntrypoint {}'].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('does not wrap a non-exported entrypoint class', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class Unexported extends WorkerEntrypoint {}', + ].join('\n'); + expect(transform(code, ctx)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// WorkerEntrypoint class wrapping (config self-binding fallback) +// +// When the base class lives in another module, structural detection can't see +// it; a self-bound service entrypoint in the config supplies the name instead. +// --------------------------------------------------------------------------- + +describe('WorkerEntrypoint class wrapping (config fallback)', () => { + const ctx: TransformContext = { + classWrappers: entrypointWrappers('AdminEntry'), + optionsFn: '(env) => ({})', + }; + + it('wraps a configured entrypoint whose base class is imported from another module', () => { + const code = ["import { BaseEntry } from './base';", 'export class AdminEntry extends BaseEntry {}'].join('\n'); + + const result = transform(code, ctx)!; + expect(result.code).toContain('export const AdminEntry = __SENTRY__.withSentry('); + expect(result.wrappedClasses).toEqual(new Set(['AdminEntry'])); + }); + + it('ignores an entrypoint that is neither structurally detected nor configured', () => { + const other: TransformContext = { classWrappers: new Map(), optionsFn: '(env) => ({})' }; + const code = ["import { BaseEntry } from './base';", 'export class AdminEntry extends BaseEntry {}'].join('\n'); + expect(transform(code, other)).toBeUndefined(); + }); +}); + // --------------------------------------------------------------------------- // Combined transforms (DO + Workflow + default export) // --------------------------------------------------------------------------- @@ -359,6 +483,39 @@ describe('combined transforms', () => { expect(importCount).toBe(1); }); + it('does not double-wrap a class exported both by name and as default', () => { + 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: '(env) => ({})' })!; + + // The named export wraps it once; the default re-export must not wrap again. + const wrapCount = (result.code.match(/withSentry\(/g) ?? []).length; + expect(wrapCount).toBe(1); + expect(result.code).toContain('const AdminEntry = __SENTRY__.withSentry('); + expect(result.code).not.toContain('__SENTRY_DEFAULT_EXPORT__'); + // The default export still points at the (single-)wrapped binding. + expect(result.code).toContain('export default AdminEntry;'); + }); + + it('handles the default export appearing before its named wrap in source order', () => { + const code = [ + "import { WorkerEntrypoint } from 'cloudflare:workers';", + 'class AdminEntry extends WorkerEntrypoint {}', + 'export default AdminEntry;', + 'export { AdminEntry };', + ].join('\n'); + + const result = transform(code, { classWrappers: new Map(), optionsFn: '(env) => ({})' })!; + + const wrapCount = (result.code.match(/withSentry\(/g) ?? []).length; + expect(wrapCount).toBe(1); + }); + it('wraps DO but skips already-wrapped 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 0279fd240333..7589d69da1c5 100644 --- a/packages/cloudflare/test/vite/wranglerConfig.test.ts +++ b/packages/cloudflare/test/vite/wranglerConfig.test.ts @@ -1,12 +1,21 @@ -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { unstable_readConfig } from 'wrangler'; import { resolveWranglerConfig } from '../../src/vite/wranglerConfig'; +const tempDirs: string[] = []; + +afterEach(() => { + while (tempDirs.length) { + rmSync(tempDirs.pop()!, { recursive: true, force: true }); + } +}); + function writeTempDir(files: Record): string { const dir = mkdtempSync(join(tmpdir(), 'sentry-cf-')); + tempDirs.push(dir); for (const [name, content] of Object.entries(files)) { writeFileSync(join(dir, name), content); } @@ -304,6 +313,76 @@ describe('resolveWranglerConfig', () => { const result = resolveWranglerConfig(dir); expect(result!.config.workflows).toEqual([]); }); + + it('collects a self-bound service entrypoint', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + name: 'worker-self', + main: 'src/index.ts', + services: [{ binding: 'SELF', service: 'worker-self', entrypoint: 'InternalEntry' }], + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workerEntrypoints).toEqual(['InternalEntry']); + }); + + it('collects multiple self-bound entrypoints from a wrangler.jsonc', () => { + // Mirrors the `worker-workerentrypoint-rpc` integration test's config shape: + // several `services[].entrypoint` entries in a JSONC file (comments + + // trailing commas), all self-bound to this worker. + const dir = writeTempDir({ + 'wrangler.jsonc': [ + '{', + ' // Worker exposing two named entrypoints to itself', + ' "name": "my-worker",', + ' "main": "index.ts",', + ' "services": [', + ' { "binding": "SELF_A", "service": "my-worker", "entrypoint": "BindingEntrypoint" },', + ' { "binding": "SELF_B", "service": "my-worker", "entrypoint": "NoPropagationEntrypoint" },', + ' ],', + '}', + ].join('\n'), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workerEntrypoints).toEqual(['BindingEntrypoint', 'NoPropagationEntrypoint']); + }); + + it("ignores outward service entrypoints (they name another worker's export)", () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + name: 'worker-self', + main: 'src/index.ts', + services: [ + { binding: 'SELF', service: 'worker-self', entrypoint: 'InternalEntry' }, + { binding: 'OTHER', service: 'worker-x', entrypoint: 'RemoteEntry' }, + ], + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workerEntrypoints).toEqual(['InternalEntry']); + }); + + it('derives no entrypoints when the worker has no name', () => { + const dir = writeTempDir({ + 'wrangler.json': JSON.stringify({ + main: 'src/index.ts', + services: [{ binding: 'S', service: 'x', entrypoint: 'E' }], + }), + }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workerEntrypoints).toEqual([]); + }); + + it('defaults workerEntrypoints to an empty array when no services are configured', () => { + const dir = writeTempDir({ 'wrangler.json': JSON.stringify({ name: 'w', main: 'src/index.ts' }) }); + + const result = resolveWranglerConfig(dir); + expect(result!.config.workerEntrypoints).toEqual([]); + }); }); // ---------------------------------------------------------------------------