From 4a5d06f87fa5b721eb51b4f28b94936e5f1814f2 Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Tue, 14 Jul 2026 16:35:09 +0200 Subject: [PATCH] feat(cloudflare): Auto-instrument Durable Object classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the auto-instrument transform to wrap exported Durable Object classes (those named in the wrangler `durable_objects` bindings) with `instrumentDurableObjectWithSentry`, alongside the existing default-export `withSentry` wrapping. Handles the inline (`export class MyDO {}`) and specifier (`class MyDO {}` … `export { MyDO }` / `export { Foo as MyDO }`) forms, leaves classes already wrapped manually untouched, and warns about configured classes it can't find in the entry (e.g. re-exports from another module, which can't be wrapped in place). Adds a `vite-autoinstrument/durableobject` integration suite: a worker whose default handler and `Counter` Durable Object are both left unwrapped and wrapped at build time via the Vite plugin. Co-Authored-By: Claude Opus 4.8 --- .../durableobject-manual-wrap/index.ts | 37 ++++ .../instrument.server.ts | 6 + .../durableobject-manual-wrap/test.ts | 59 +++++ .../durableobject-manual-wrap/vite.config.mts | 17 ++ .../durableobject-manual-wrap/wrangler.jsonc | 13 ++ .../durableobject-mixed/index.ts | 55 +++++ .../durableobject-mixed/instrument.server.ts | 6 + .../durableobject-mixed/test.ts | 64 ++++++ .../durableobject-mixed/vite.config.mts | 16 ++ .../durableobject-mixed/wrangler.jsonc | 16 ++ .../durableobject-multiple/index.ts | 44 ++++ .../instrument.server.ts | 6 + .../durableobject-multiple/test.ts | 61 ++++++ .../durableobject-multiple/vite.config.mts | 16 ++ .../durableobject-multiple/wrangler.jsonc | 16 ++ .../counter.ts | 22 ++ .../index.ts | 28 +++ .../instrument.server.ts | 6 + .../test.ts | 62 ++++++ .../vite.config.mts | 17 ++ .../wrangler.jsonc | 13 ++ .../durableobject-specifier-alias/index.ts | 33 +++ .../instrument.server.ts | 6 + .../durableobject-specifier-alias/test.ts | 57 +++++ .../vite.config.mts | 16 ++ .../wrangler.jsonc | 15 ++ .../durableobject-specifier/index.ts | 33 +++ .../instrument.server.ts | 6 + .../durableobject-specifier/test.ts | 57 +++++ .../durableobject-specifier/vite.config.mts | 16 ++ .../durableobject-specifier/wrangler.jsonc | 13 ++ .../durableobject/index.ts | 31 +++ .../durableobject/instrument.server.ts | 6 + .../vite-autoinstrument/durableobject/test.ts | 62 ++++++ .../durableobject/vite.config.mts | 16 ++ .../durableobject/wrangler.jsonc | 13 ++ .../cloudflare/src/vite/autoInstrument.ts | 22 +- packages/cloudflare/src/vite/index.ts | 9 +- packages/cloudflare/src/vite/transform.ts | 203 +++++++++++++++++- .../test/vite/autoInstrument.test.ts | 44 +++- .../cloudflare/test/vite/transform.test.ts | 191 +++++++++++++++- 41 files changed, 1413 insertions(+), 16 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/counter.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/wrangler.jsonc create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/instrument.server.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/vite.config.mts create mode 100644 dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/wrangler.jsonc diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/index.ts new file mode 100644 index 000000000000..15ab3a07d5c3 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/index.ts @@ -0,0 +1,37 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; +} + +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 auto-instrument transform +// must detect the existing `Sentry.instrumentDurableObjectWithSentry` call and +// leave it untouched — no second wrap — while still wrapping the plain default +// export below. +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 === '/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/durableobject-manual-wrap/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/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-manual-wrap/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts new file mode 100644 index 000000000000..bfefd563f474 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/test.ts @@ -0,0 +1,59 @@ +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 is 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', + }), + ]); +} + +// The main worker transaction just forwards to the DO, so it carries no child +// spans. The empty-spans assertion keeps it disjoint from the DO transaction. +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 already wrapped manually with +// `Sentry.instrumentDurableObjectWithSentry`. The transform must recognize the +// existing wrap and NOT wrap it again (a double-wrap would either break the +// build or nest proxies), while still auto-wrapping the plain default export. +// We therefore expect exactly one storage-bearing DO transaction (from the +// manual wrap) and one child-less main-worker transaction (from the auto wrap). +it('leaves a manually wrapped Durable Object untouched and still wraps the default export', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/increment'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/vite.config.mts new file mode 100644 index 000000000000..940cc5b002bf --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/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` and only wrap + // the plain default export. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/wrangler.jsonc new file mode 100644 index 000000000000..d296b629350c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-manual-wrap/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject-manual-wrap", + // `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"] }], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/index.ts new file mode 100644 index 000000000000..a936b747202d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/index.ts @@ -0,0 +1,55 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MANUAL: DurableObjectNamespace; + AUTO: DurableObjectNamespace; +} + +class ManualImpl extends DurableObject { + async fetch(): Promise { + // Touch storage so the instrumented DO emits an + // `auto.db.cloudflare.durable_object` span the test can fingerprint. + const current = ((await this.ctx.storage.get('count')) ?? 0) + 1; + await this.ctx.storage.put('count', current); + return Response.json({ kind: 'manual', count: current }); + } +} + +// Manually wrapped — the transform must leave this alone. +export const Manual = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }), + ManualImpl, +); + +// Plain inline export — the transform must wrap this one. Both classes are +// configured in wrangler, so this exercises wrapping only the unwrapped class +// while skipping the manually wrapped sibling in the same file. +export class Auto extends DurableObject { + async fetch(): Promise { + // Touch storage so the instrumented DO emits an + // `auto.db.cloudflare.durable_object` span the test can fingerprint. + const current = ((await this.ctx.storage.get('count')) ?? 0) + 1; + await this.ctx.storage.put('count', current); + return Response.json({ kind: 'auto', count: current }); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/manual') { + const stub = env.MANUAL.get(env.MANUAL.idFromName('e2e-manual')); + return stub.fetch(new Request('https://do/manual')); + } + + if (url.pathname === '/auto') { + const stub = env.AUTO.get(env.AUTO.idFromName('e2e-auto')); + return stub.fetch(new Request('https://do/auto')); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-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-mixed/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts new file mode 100644 index 000000000000..6cb366444117 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/test.ts @@ -0,0 +1,64 @@ +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 is instrumented (whether by the +// manual wrap or the build-time auto-wrap). +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', + }), + ]); +} + +// The main worker transaction just forwards to the DO, so it carries no child +// spans. The empty-spans assertion keeps it disjoint from the DO transactions. +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); +} + +// One DO (`Manual`) is wrapped by hand, the other (`Auto`) is a plain inline +// export. Both are bound in wrangler. The transform must skip the manual one and +// auto-wrap only `Auto` — so both endpoints report a storage-bearing DO +// transaction (one from the manual wrap, one from the auto wrap) without +// double-instrumenting `Manual`. +it('wraps only the unwrapped Durable Object when a sibling is manually wrapped', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + // One storage-bearing DO transaction from the manual wrap, one from the auto wrap. + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + // One child-less main worker transaction per request. + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/manual'); + await runner.makeRequest('get', '/auto'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/vite.config.mts new file mode 100644 index 000000000000..7a2c4dc4d75a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/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 skips the manually + // wrapped `Manual` DO and auto-wraps `Auto` before the Cloudflare plugin bundles it. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/wrangler.jsonc new file mode 100644 index 000000000000..e9e39d2a3ed4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-mixed/wrangler.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject-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": "MANUAL", "class_name": "Manual" }, + { "name": "AUTO", "class_name": "Auto" }, + ], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Manual", "Auto"] }], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/index.ts new file mode 100644 index 000000000000..e841ab4342c9 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/index.ts @@ -0,0 +1,44 @@ +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER_A: DurableObjectNamespace; + COUNTER_B: DurableObjectNamespace; +} + +// Two Durable Object classes are configured in wrangler and both exported +// inline. The auto-instrument transform must wrap each of them — not just the +// first match. +export class CounterA 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({ counter: 'a', count: current }); + } +} + +export class CounterB 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({ counter: 'b', count: current }); + } +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname === '/increment-a') { + const stub = env.COUNTER_A.get(env.COUNTER_A.idFromName('e2e-a')); + return stub.fetch(new Request('https://do/increment')); + } + + if (url.pathname === '/increment-b') { + const stub = env.COUNTER_B.get(env.COUNTER_B.idFromName('e2e-b')); + 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/durableobject-multiple/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/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-multiple/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts new file mode 100644 index 000000000000..6ecb348b4382 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/test.ts @@ -0,0 +1,61 @@ +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 actually 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', + }), + ]); +} + +// The main worker transaction just forwards to the DO, so it carries no child +// spans. The empty-spans assertion keeps it disjoint from the DO transactions. +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); +} + +// Two Durable Object classes are bound in wrangler. Hitting both must produce a +// storage-bearing DO transaction for each, proving the transform wrapped every +// configured class rather than stopping after the first match. +it('auto-instruments multiple Durable Object classes in one entry', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + // One storage-bearing DO transaction per configured class. + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + // One child-less main worker transaction per request. + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/increment-a'); + await runner.makeRequest('get', '/increment-b'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/vite.config.mts new file mode 100644 index 000000000000..770531239ab7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/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 both Durable Object 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-multiple/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/wrangler.jsonc new file mode 100644 index 000000000000..25d19154d2a5 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-multiple/wrangler.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject-multiple", + // `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_A", "class_name": "CounterA" }, + { "name": "COUNTER_B", "class_name": "CounterB" }, + ], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["CounterA", "CounterB"] }], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/counter.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/counter.ts new file mode 100644 index 000000000000..c06b3753cfca --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/counter.ts @@ -0,0 +1,22 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; +} + +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 manually instrumented here, in a module *separate* from +// the worker entry. The entry only imports and re-exports the wrapped class. +export const Counter = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }), + CounterImpl, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/index.ts new file mode 100644 index 000000000000..55b813a8daac --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/index.ts @@ -0,0 +1,28 @@ +import { Counter } from './counter'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; +} + +// `Counter` is imported from another module (`./counter`) where it was already +// manually wrapped with `instrumentDurableObjectWithSentry`, then re-exported +// here. The auto-instrument transform runs over this entry and sees +// `export { Counter }`, but `Counter` is an imported binding — not a local class +// declaration — so it cannot (and must not) wrap it. The DO stays instrumented +// solely via the manual wrap in `./counter`, and the plain default export below +// is still auto-wrapped with `withSentry`. +export { Counter }; + +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')); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/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-reexport-instrumented/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts new file mode 100644 index 000000000000..d09b4ceeeda6 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/test.ts @@ -0,0 +1,62 @@ +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 is 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', + }), + ]); +} + +// The main worker transaction just forwards to the DO, so it carries no child +// spans. The empty-spans assertion keeps it disjoint from the DO transaction. +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); +} + +// `Counter` is manually wrapped with `instrumentDurableObjectWithSentry` in a +// separate module (`./counter`), imported into the entry, and re-exported via a +// plain `export { Counter }`. Because `Counter` is an imported binding rather +// than a local class declaration, the transform cannot wrap it in the entry and +// must leave it alone — no double-wrap, no broken build. The DO stays +// instrumented via the manual wrap, so we still expect a storage-bearing DO +// transaction, alongside the auto-wrapped default export's child-less one. +it('leaves an imported, already-instrumented Durable Object untouched and still wraps the default export', async ({ + signal, +}) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/increment'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/vite.config.mts new file mode 100644 index 000000000000..519ddddca31c --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/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 imported/re-exported `Counter` (wrapped in + // `./counter`) and only wrap the plain default export. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/wrangler.jsonc new file mode 100644 index 000000000000..11ff2a02a4a4 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-reexport-instrumented/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject-reexport-instrumented", + // `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"] }], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/index.ts new file mode 100644 index 000000000000..05e688ea71f9 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/index.ts @@ -0,0 +1,33 @@ +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; +} + +// The Durable Object's local class name differs from the name the wrangler +// binding references: `Counter` here is only the *exported* alias of the local +// `CounterImpl` class. The auto-instrument transform resolves the aliased +// specifier, renames the local class, and rebinds it to the wrapped class. +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 }); + } +} + +export { CounterImpl as Counter }; + +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')); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/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-specifier-alias/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts new file mode 100644 index 000000000000..e943483b6542 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/test.ts @@ -0,0 +1,57 @@ +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 actually 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', + }), + ]); +} + +// The main worker transaction just forwards to the DO, so it carries no child +// spans. The empty-spans assertion keeps it disjoint from the DO transaction. +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 wrangler binding references `Counter`, which is only an exported *alias* +// of the local `CounterImpl` class (`export { CounterImpl as Counter }`). The +// transform resolves the alias and wraps the local class, so the DO storage +// spans only arrive if the aliased-specifier form was handled. +it('auto-instruments a Durable Object exported via an aliased specifier', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/increment'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/vite.config.mts new file mode 100644 index 000000000000..842d83510d2d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/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 aliased Durable Object class before the Cloudflare plugin bundles it. + plugins: [ + cloudflare(), + sentryCloudflareVitePlugin({ + _experimental: { + autoInstrumentation: true, + }, + }), + ], +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/wrangler.jsonc new file mode 100644 index 000000000000..68b8de16bff7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier-alias/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject-specifier-alias", + // `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": { + // The binding references the *exported* alias (`Counter`), while the class + // is declared locally as `CounterImpl` and exported via `export { ... as Counter }`. + "bindings": [{ "name": "COUNTER", "class_name": "Counter" }], + }, + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/index.ts new file mode 100644 index 000000000000..29621c29d1e3 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/index.ts @@ -0,0 +1,33 @@ +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; +} + +// The Durable Object is declared as a plain class and exported through a +// specifier list (`export { Counter }`) rather than inline. The +// `@sentry/cloudflare/vite` plugin's auto-instrumentation renames the class and +// rebinds the exported name to the wrapped class at build time. +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 { Counter }; + +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')); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-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-specifier/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts new file mode 100644 index 000000000000..1935635262e3 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/test.ts @@ -0,0 +1,57 @@ +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 actually 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', + }), + ]); +} + +// The main worker transaction just forwards to the DO, so it carries no child +// spans. The empty-spans assertion keeps it disjoint from the DO transaction. +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 exported through a specifier list (`export { Counter }`) +// instead of an inline `export class`. The transform renames the class to +// `__SENTRY_ORIGINAL_Counter__` and rebinds `Counter` to the wrapped class, so +// the DO storage spans only arrive if that specifier form was handled. +it('auto-instruments a Durable Object exported via a specifier list', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/increment'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/vite.config.mts new file mode 100644 index 000000000000..efd0e098c528 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/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 `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/durableobject-specifier/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/wrangler.jsonc new file mode 100644 index 000000000000..86517a797c6d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject-specifier/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject-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"] }], +} diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/index.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/index.ts new file mode 100644 index 000000000000..b896ec299e97 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/index.ts @@ -0,0 +1,31 @@ +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + COUNTER: DurableObjectNamespace; +} + +// Neither the Durable Object nor the default handler is manually wrapped. The +// `@sentry/cloudflare/vite` plugin's auto-instrumentation wraps both at build +// time — `Counter` via `instrumentDurableObjectWithSentry`, 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 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')); + } + + return new Response('Not found', { status: 404 }); + }, +} satisfies ExportedHandler; diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/instrument.server.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/instrument.server.ts new file mode 100644 index 000000000000..e577550ae0b8 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/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/test.ts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts new file mode 100644 index 000000000000..42a78ea2840a --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/test.ts @@ -0,0 +1,62 @@ +import type { TransactionEvent } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../../runner'; + +// A Durable Object invoked via `fetch` produces its own `http.server` / +// `auto.http.cloudflare` transaction (its `fetch` is wrapped with +// `wrapRequestHandler`, not the faas wrapper used for alarms/websockets/RPC). +// The proof the class was auto-instrumented is the pair of +// `auto.db.cloudflare.durable_object` storage spans (`get` + `put`) it emits — +// absent entirely when the class is left unwrapped. +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', + }), + ]); +} + +// The main worker transaction just forwards to the DO, so it carries no child +// spans. The empty-spans assertion keeps it disjoint from the DO transaction, so +// neither can satisfy the other's expectation 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 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 (`withSentry`) and the `Counter` Durable Object +// (`instrumentDurableObjectWithSentry`). +it('auto-instruments the default handler and a Durable Object', async ({ signal }) => { + const runner = createRunner(__dirname) + .unordered() + .expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent)) + .start(signal); + + await runner.makeRequest('get', '/increment'); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/vite.config.mts b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/vite.config.mts new file mode 100644 index 000000000000..efd0e098c528 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/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 `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/durableobject/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/wrangler.jsonc new file mode 100644 index 000000000000..681e81cbc0be --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/vite-autoinstrument/durableobject/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "../../../node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-autoinstrument-durableobject", + // `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"] }], +} diff --git a/packages/cloudflare/src/vite/autoInstrument.ts b/packages/cloudflare/src/vite/autoInstrument.ts index 9a1196ab11d8..331676ea1995 100644 --- a/packages/cloudflare/src/vite/autoInstrument.ts +++ b/packages/cloudflare/src/vite/autoInstrument.ts @@ -1,5 +1,5 @@ import { buildOptionsImport, ENV_FALLBACK_OPTIONS_FN, resolveInstrumentFile } from './instrumentFile'; -import { applyAutoInstrumentTransforms, type ProgramBody } from './transform'; +import { applyAutoInstrumentTransforms, type ClassWrapperKind, type ProgramBody } from './transform'; import { resolveWranglerConfig, type WranglerConfig } from './wranglerConfig'; // Vite normalizes module IDs to posix separators even on Windows, while @@ -78,11 +78,31 @@ export function sentryCloudflareAutoInstrumentPlugin() { return undefined; } + const classWrappers = new Map(); + for (const { className } of wranglerConfig.durableObjects) { + classWrappers.set(className, 'durableObject'); + } + + // No registration import is injected here: the orchestrion plugin's + // subscribe-injection makes each bundled package self-register its channel + // subscriber on the global marker, so wrapping the entry with `withSentry` + // is all this plugin needs to do. const result = applyAutoInstrumentTransforms(code, ast, { + classWrappers, optionsFn, optionsImport, }); + const wrappedClasses = result?.wrappedClasses ?? new Set(); + const missing = [...classWrappers.keys()].filter(name => !wrappedClasses.has(name)); + if (missing.length > 0) { + this.warn?.( + `[sentry] Could not auto-instrument class(es) ${missing.join(', ')}: no matching exported class ` + + 'declaration found in the worker entry (re-exports from other modules cannot be wrapped ' + + 'automatically). Wrap them manually with the matching `instrument*WithSentry` helper.', + ); + } + return result ?? undefined; }, }; diff --git a/packages/cloudflare/src/vite/index.ts b/packages/cloudflare/src/vite/index.ts index 6d6a9c6487f8..094c7df03081 100644 --- a/packages/cloudflare/src/vite/index.ts +++ b/packages/cloudflare/src/vite/index.ts @@ -31,10 +31,11 @@ export interface SentryCloudflareVitePluginOptions { useDiagnosticsChannelInjection?: boolean; /** * Automatically wraps your Worker at build time so you don't have to edit - * your entry: the plugin reads your wrangler config and wraps the default - * export with `Sentry.withSentry()`, sourcing options from a co-located - * `instrument.*` file and falling back to env. Both `vite build` and - * `vite dev` are instrumented. + * your entry: the plugin reads your wrangler config, wraps the default + * export with `Sentry.withSentry()` (sourcing options from a co-located + * `instrument.*` file, falling back to env), and wraps any configured + * Durable Object class with `instrumentDurableObjectWithSentry`. Both + * `vite build` and `vite dev` are instrumented. * * @default false * @experimental May change or be removed in any release. diff --git a/packages/cloudflare/src/vite/transform.ts b/packages/cloudflare/src/vite/transform.ts index 09680fa5e491..7050f594c722 100644 --- a/packages/cloudflare/src/vite/transform.ts +++ b/packages/cloudflare/src/vite/transform.ts @@ -14,6 +14,10 @@ export interface ProgramBody { body: BaseNode[]; } +interface IdentifierNode extends BaseNode { + name: string; +} + interface CalleeNode { type: string; name?: string; @@ -24,10 +28,35 @@ interface CallExpressionNode extends BaseNode { callee?: CalleeNode; } +interface ClassDeclarationNode extends BaseNode { + id?: IdentifierNode | null; +} + interface ExportDefaultNode extends BaseNode { declaration: BaseNode; } +interface ExportSpecifierNode { + type: string; + local?: { type: string; name?: string }; + exported?: { type: string; name?: string }; +} + +interface ExportNamedNode extends BaseNode { + declaration?: BaseNode | null; + source?: unknown; + specifiers?: ExportSpecifierNode[]; +} + +interface VariableDeclaratorNode { + id?: { type: string; name?: string }; + init?: BaseNode | null; +} + +interface VariableDeclarationNode extends BaseNode { + declarations?: VariableDeclaratorNode[]; +} + function isCallToMethod(node: BaseNode, methodName: string): boolean { if (node.type !== 'CallExpression') return false; const callee = (node as CallExpressionNode).callee; @@ -38,7 +67,26 @@ 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. + */ +export type ClassWrapperKind = 'durableObject'; + +/** The `@sentry/cloudflare` helper each wrapper kind emits. */ +const WRAPPER_METHODS: Record = { + durableObject: 'instrumentDurableObjectWithSentry', +}; + export interface TransformContext { + /** + * Exported class name → the kind of Sentry wrapper to apply. Populated from + * the wrangler config, so the transform can wrap by name without resolving + * each class's base type. + */ + classWrappers: Map; optionsFn: string; /** Import statement prepended when `optionsFn` references a separate module. */ optionsImport?: string; @@ -47,13 +95,29 @@ export interface TransformContext { export interface TransformResult { code: string; map: ReturnType; + /** + * The configured class names that were actually wrapped. Lets the plugin warn + * about configured classes it could not find, instead of silently leaving + * them uninstrumented. + */ + wrappedClasses: Set; } /** - * Rewrite the worker entry source to wrap its default export with `withSentry`. + * Rewrite the worker entry source to wrap its default export with `withSentry` + * and any configured class export with its matching Sentry wrapper (see + * {@link TransformContext.classWrappers}, e.g. Durable Object classes with + * `instrumentDurableObjectWithSentry`). + * + * Handles both `export class MyDO {}` and the specifier form + * (`class MyDO {}` … `export { MyDO }` / `export { Foo as MyDO }`). + * Re-exports from other modules (`export { MyDO } from './do'`) cannot be + * wrapped here and are left alone — the plugin warns about them via + * {@link TransformResult.wrappedClasses}. * * Exported (rather than inlined into the plugin) so it can be unit-tested with a - * plain AST and no Vite context. Returns `undefined` when nothing was wrapped. + * plain AST and no Vite context. Returns `undefined` when nothing was wrapped and + * there are no already-manually-wrapped classes to report. */ export function applyAutoInstrumentTransforms( code: string, @@ -61,15 +125,30 @@ export function applyAutoInstrumentTransforms( ctx: TransformContext, ): TransformResult | undefined { const ms = new MagicString(code); - const state: TransformState = { ms, needsImport: false }; + const state: TransformState = { + ms, + needsImport: false, + wrappedClasses: new Set(), + topLevelClasses: collectTopLevelClasses(ast), + renamedLocals: new Set(), + }; + const { wrappedClasses } = 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); } } - if (!state.needsImport) return undefined; + if (!state.needsImport) { + // Nothing was rewritten. Still surface any classes found already wrapped + // manually (via `wrappedClasses`) so the caller doesn't warn about them; + // return undefined only when there was nothing to report either. + if (wrappedClasses.size === 0) return undefined; + return { code, map: ms.generateMap({ hires: true }), wrappedClasses }; + } if (ctx.optionsImport) ms.prepend(ctx.optionsImport); ms.prepend("import * as __SENTRY__ from '@sentry/cloudflare';\n"); @@ -77,12 +156,34 @@ export function applyAutoInstrumentTransforms( return { code: ms.toString(), map: ms.generateMap({ hires: true }), + wrappedClasses, }; } interface TransformState { ms: MagicString; needsImport: boolean; + wrappedClasses: Set; + /** + * Top-level (non-exported) class declarations, so specifier exports like + * `export { MyDO }` can locate the class they refer to. + */ + topLevelClasses: Map; + /** + * Local class names already renamed + wrapped, so two specifiers pointing at + * the same class don't produce duplicate bindings. + */ + renamedLocals: Set; +} + +function collectTopLevelClasses(ast: ProgramBody): Map { + const classes = new Map(); + for (const node of ast.body) { + if (node.type !== 'ClassDeclaration') continue; + const classNode = node as ClassDeclarationNode; + if (classNode.id?.name) classes.set(classNode.id.name, classNode); + } + return classes; } function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state: TransformState): void { @@ -97,3 +198,97 @@ function wrapDefaultExport(node: ExportDefaultNode, ctx: TransformContext, state state.ms.append(`\nexport default __SENTRY__.withSentry(${ctx.optionsFn}, __SENTRY_DEFAULT_EXPORT__);\n`); state.needsImport = true; } + +function handleNamedExport(node: ExportNamedNode, ctx: TransformContext, state: TransformState): void { + const decl = node.declaration; + + // ---- Manually wrapped class export ---- + // `export const MyDO = instrumentDurableObjectWithSentry(...)` — count it + // as wrapped so the plugin doesn't warn about it, but leave it alone. + if (decl?.type === 'VariableDeclaration') { + collectManuallyWrappedClassExports(decl as VariableDeclarationNode, ctx, state); + return; + } + + // ---- Named class export matching a configured binding ---- + if (decl?.type === 'ClassDeclaration') { + wrapInlineClassExport(node, decl as ClassDeclarationNode, ctx, state); + return; + } + + // ---- Specifier export of a local class (`export { Foo as MyDO }`) ---- + // Re-exports from another module carry a `source` — nothing local to wrap. + if (node.source) return; + for (const specifier of node.specifiers ?? []) { + wrapSpecifierExport(specifier, ctx, state); + } +} + +function collectManuallyWrappedClassExports( + varDecl: VariableDeclarationNode, + ctx: TransformContext, + state: TransformState, +): void { + for (const declarator of varDecl.declarations ?? []) { + const name = declarator.id?.type === 'Identifier' ? declarator.id.name : undefined; + const kind = name ? ctx.classWrappers.get(name) : undefined; + if (name && kind && declarator.init && isCallToMethod(declarator.init, WRAPPER_METHODS[kind])) { + state.wrappedClasses.add(name); + } + } +} + +function wrapInlineClassExport( + exportNode: ExportNamedNode, + classDecl: ClassDeclarationNode, + ctx: TransformContext, + state: TransformState, +): void { + const classId = classDecl.id; + const kind = classId ? ctx.classWrappers.get(classId.name) : undefined; + if (!classId || !kind) return; + + const className = classId.name; + const renamedClass = `__SENTRY_ORIGINAL_${className}__`; + + // Strip the `export ` keyword + state.ms.overwrite(exportNode.start, classDecl.start, ''); + + // Rename the class to avoid a duplicate binding + state.ms.overwrite(classId.start, classId.end, renamedClass); + + // Insert the wrapped re-export after the class body + state.ms.appendLeft( + exportNode.end, + `\nexport const ${className} = __SENTRY__.${WRAPPER_METHODS[kind]}(${ctx.optionsFn}, ${renamedClass});\n`, + ); + + state.wrappedClasses.add(className); + state.renamedLocals.add(className); + state.needsImport = true; +} + +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; + + const localName = specifier.local?.type === 'Identifier' ? specifier.local.name : undefined; + const localClass = localName ? state.topLevelClasses.get(localName) : undefined; + if (!localName || !localClass?.id) return; + + state.wrappedClasses.add(exportedName); + state.needsImport = true; + if (state.renamedLocals.has(localName)) return; + state.renamedLocals.add(localName); + + const renamedClass = `__SENTRY_ORIGINAL_${localName}__`; + state.ms.overwrite(localClass.id.start, localClass.id.end, renamedClass); + // The existing `export { ... }` statement keeps exporting the (now + // wrapped) `localName` binding, so the wrapper is NOT exported here. + state.ms.appendLeft( + localClass.end, + `\nconst ${localName} = __SENTRY__.${WRAPPER_METHODS[kind]}(${ctx.optionsFn}, ${renamedClass});\n`, + ); +} diff --git a/packages/cloudflare/test/vite/autoInstrument.test.ts b/packages/cloudflare/test/vite/autoInstrument.test.ts index 3785c7e6cf96..efa63695280e 100644 --- a/packages/cloudflare/test/vite/autoInstrument.test.ts +++ b/packages/cloudflare/test/vite/autoInstrument.test.ts @@ -53,7 +53,7 @@ describe('sentryCloudflareAutoInstrumentPlugin', () => { "import { withSentry } from '@sentry/cloudflare';", 'export default withSentry((env) => ({}), { fetch() {} });', ].join('\n'); - // Nothing to wrap → no transform result. + // No DO classes configured and nothing to wrap → no transform result. expect(tx(code, entryPath)).toBeUndefined(); }); @@ -108,6 +108,31 @@ describe('sentryCloudflareAutoInstrumentPlugin', () => { ); expect(result).toBeUndefined(); }); + + it('warns when a configured DO class cannot be wrapped', () => { + const dir = writeTempDir({ + 'wrangler.toml': [ + 'main = "index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "MY_DO"', + 'class_name = "MyDO"', + ].join('\n'), + }); + const plugin = sentryCloudflareAutoInstrumentPlugin(); + plugin.configResolved({ root: dir }); + + const warnings: string[] = []; + const code = "export { MyDO } from './do';"; + plugin.transform.call( + { parse: (c: string) => parseJS(c), warn: (msg: string) => warnings.push(msg) }, + code, + join(dir, 'index.ts'), + ); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('MyDO'); + }); }); // --------------------------------------------------------------------------- @@ -172,4 +197,21 @@ describe('instrument file auto-detection', () => { expect(result.code).not.toContain('__SENTRY_OPTIONS_CALLBACK__'); expect(result.code).toContain('__SENTRY__.withSentry(() => undefined,'); }); + + it('applies the detected callback to Durable Object classes too', () => { + const { transform: tx, entryPath } = createPluginWithDir({ + 'wrangler.toml': [ + 'main = "index.ts"', + '', + '[[durable_objects.bindings]]', + 'name = "MY_DO"', + 'class_name = "MyDO"', + ].join('\n'), + 'instrument.server.ts': 'export default (env) => ({ dsn: env.SENTRY_DSN });', + }); + + const code = ['class DurableObject {}', 'export class MyDO extends DurableObject {}'].join('\n'); + const result = tx(code, entryPath)!; + expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry(__SENTRY_OPTIONS_CALLBACK__,'); + }); }); diff --git a/packages/cloudflare/test/vite/transform.test.ts b/packages/cloudflare/test/vite/transform.test.ts index 0c0165303667..4abacfc429f2 100644 --- a/packages/cloudflare/test/vite/transform.test.ts +++ b/packages/cloudflare/test/vite/transform.test.ts @@ -1,11 +1,16 @@ import { parse } from 'acorn'; import { describe, expect, it } from 'vitest'; -import { applyAutoInstrumentTransforms, type TransformContext } from '../../src/vite/transform'; +import { applyAutoInstrumentTransforms, type ClassWrapperKind, type TransformContext } from '../../src/vite/transform'; function parseJS(code: string) { return parse(code, { ecmaVersion: 'latest', sourceType: 'module' }) as unknown as { body: any[] }; } +/** Build a `classWrappers` map with every given class name marked as a DO. */ +function doWrappers(...names: string[]): Map { + return new Map(names.map(name => [name, 'durableObject'])); +} + function transform(code: string, ctx: TransformContext) { return applyAutoInstrumentTransforms(code, parseJS(code), ctx); } @@ -15,7 +20,7 @@ function transform(code: string, ctx: TransformContext) { // --------------------------------------------------------------------------- describe('default export wrapping', () => { - const ctx: TransformContext = { optionsFn: '(env) => ({})' }; + const ctx: TransformContext = { classWrappers: doWrappers(), optionsFn: '(env) => ({})' }; it('wraps an object-literal default export', () => { const code = [ @@ -57,6 +62,7 @@ describe('default export wrapping', () => { it('uses custom options callback', () => { const custom: TransformContext = { + classWrappers: doWrappers(), optionsFn: '(env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 })', }; @@ -91,16 +97,189 @@ describe('default export wrapping', () => { }); // --------------------------------------------------------------------------- -// Nothing to wrap +// Durable Object class wrapping // --------------------------------------------------------------------------- -describe('nothing to wrap', () => { - it('returns undefined when the entry is already wrapped manually', () => { +describe('Durable Object class wrapping', () => { + const ctx: TransformContext = { + classWrappers: doWrappers('MyDurableObject'), + optionsFn: '(env) => ({})', + }; + + it('wraps an exported DO class', () => { const code = [ + 'class DurableObject {}', + 'export class MyDurableObject extends DurableObject {', + ' fetch(request) { return new Response("DO ok"); }', + '}', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + + expect(result.code).toContain('class __SENTRY_ORIGINAL_MyDurableObject__'); + expect(result.code).not.toContain('export class MyDurableObject'); + expect(result.code).toContain('__SENTRY__.instrumentDurableObjectWithSentry('); + expect(result.code).toContain('export const MyDurableObject ='); + expect(result.code).toContain('__SENTRY_ORIGINAL_MyDurableObject__'); + }); + + it('wraps multiple DO classes', () => { + const multi: TransformContext = { + classWrappers: doWrappers('DOA', 'DOB'), + optionsFn: '(env) => ({})', + }; + + const code = [ + 'class DurableObject {}', + 'export class DOA extends DurableObject {}', + 'export class DOB extends DurableObject {}', + ].join('\n'); + + const result = transform(code, multi)!; + expect(result).toBeDefined(); + expect(result.code).toContain('export const DOA ='); + expect(result.code).toContain('export const DOB ='); + expect(result.code).toContain('class __SENTRY_ORIGINAL_DOA__'); + expect(result.code).toContain('class __SENTRY_ORIGINAL_DOB__'); + }); + + it('ignores classes not listed in wrangler config', () => { + const code = ['class DurableObject {}', 'export class SomeOtherClass extends DurableObject {}'].join('\n'); + + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('ignores non-class named exports', () => { + const code = 'export const MyDurableObject = 42;'; + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('wraps a DO class exported via a specifier list', () => { + const code = [ + 'class DurableObject {}', + 'class MyDurableObject extends DurableObject {', + ' fetch(request) { return new Response("DO ok"); }', + '}', + 'export { MyDurableObject };', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('class __SENTRY_ORIGINAL_MyDurableObject__'); + expect(result.code).toContain( + 'const MyDurableObject = __SENTRY__.instrumentDurableObjectWithSentry((env) => ({}), __SENTRY_ORIGINAL_MyDurableObject__);', + ); + // The original specifier export keeps exporting the wrapped binding. + expect(result.code).toContain('export { MyDurableObject };'); + expect(result.wrappedClasses).toEqual(new Set(['MyDurableObject'])); + }); + + it('wraps a DO class exported via an aliased specifier', () => { + const code = [ + 'class DurableObject {}', + 'class Internal extends DurableObject {}', + 'export { Internal as MyDurableObject };', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + expect(result.code).toContain('class __SENTRY_ORIGINAL_Internal__'); + expect(result.code).toContain( + 'const Internal = __SENTRY__.instrumentDurableObjectWithSentry((env) => ({}), __SENTRY_ORIGINAL_Internal__);', + ); + expect(result.code).toContain('export { Internal as MyDurableObject };'); + expect(result.wrappedClasses).toEqual(new Set(['MyDurableObject'])); + }); + + it('leaves re-exports from other modules alone and reports them unwrapped', () => { + const code = "export { MyDurableObject } from './do';"; + expect(transform(code, ctx)).toBeUndefined(); + }); + + it('reports wrapped DO classes for the inline export form', () => { + const code = ['class DurableObject {}', 'export class MyDurableObject extends DurableObject {}'].join('\n'); + const result = transform(code, ctx)!; + expect(result.wrappedClasses).toEqual(new Set(['MyDurableObject'])); + }); + + it('counts a manually wrapped DO export as wrapped without touching it', () => { + const code = [ + "import { instrumentDurableObjectWithSentry } from '@sentry/cloudflare';", + 'class Impl {}', + 'export const MyDurableObject = instrumentDurableObjectWithSentry((env) => ({}), Impl);', + ].join('\n'); + + // The DO is configured, so its manual wrapping is reported (letting the + // plugin skip the "could not auto-instrument" warning) but the code is left + // untouched — no rewrite, no injected `@sentry/cloudflare` import. + const result = transform(code, { classWrappers: doWrappers('MyDurableObject'), optionsFn: '(env) => ({})' })!; + expect(result.wrappedClasses).toEqual(new Set(['MyDurableObject'])); + expect(result.code).toBe(code); + expect(result.code).not.toContain('__SENTRY_ORIGINAL_'); + expect(result.code).not.toContain("import * as __SENTRY__ from '@sentry/cloudflare'"); + }); + + it('returns undefined when nothing is wrapped and no DO classes are configured', () => { + const code = [ + "import { withSentry } from '@sentry/cloudflare';", + 'export default withSentry((env) => ({}), { fetch() {} });', + ].join('\n'); + + expect(transform(code, ctx)).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Combined transforms (DO + default export) +// --------------------------------------------------------------------------- + +describe('combined transforms', () => { + const ctx: TransformContext = { + classWrappers: doWrappers('MyDO'), + optionsFn: '(env) => ({ dsn: env.SENTRY_DSN })', + }; + + it('wraps both DO class and default export', () => { + const code = [ + 'class DurableObject {}', + 'export class MyDO extends DurableObject {', + ' fetch(r) { return new Response("do"); }', + '}', + 'export default {', + ' fetch(r) { return new Response("main"); }', + '};', + ].join('\n'); + + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + + // DO wrapped + expect(result.code).toContain('class __SENTRY_ORIGINAL_MyDO__'); + expect(result.code).toContain('export const MyDO = __SENTRY__.instrumentDurableObjectWithSentry('); + + // Default export wrapped + expect(result.code).toContain('const __SENTRY_DEFAULT_EXPORT__ ='); + expect(result.code).toContain('export default __SENTRY__.withSentry('); + + // Single import + const importCount = (result.code.match(/import \* as __SENTRY__/g) ?? []).length; + expect(importCount).toBe(1); + }); + + it('wraps DO but skips already-wrapped default export', () => { + const code = [ + 'class DurableObject {}', + 'export class MyDO extends DurableObject {}', "import { withSentry } from '@sentry/cloudflare';", 'export default withSentry((env) => ({}), { fetch() {} });', ].join('\n'); - expect(transform(code, { optionsFn: '(env) => ({})' })).toBeUndefined(); + const result = transform(code, ctx)!; + expect(result).toBeDefined(); + // DO still wrapped + expect(result.code).toContain('export const MyDO ='); + // Default not double-wrapped + expect(result.code).not.toContain('__SENTRY_DEFAULT_EXPORT__'); }); });