Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<Env> {
async fetch(request: Request): Promise<Response> {
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<Env> {
async fetch(): Promise<Response> {
const current = ((await this.ctx.storage.get<number>('count')) ?? 0) + 1;
await this.ctx.storage.put('count', current);
return Response.json({ count: current });
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
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<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
@@ -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,
},
}),
],
});
Original file line number Diff line number Diff line change
@@ -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"] }],
}
Original file line number Diff line number Diff line change
@@ -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<Env> {
async fetch(request: Request): Promise<Response> {
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<Env> {
async fetch(): Promise<Response> {
const current = ((await this.ctx.storage.get<number>('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<Response> {
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<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}));
Original file line number Diff line number Diff line change
@@ -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();
});
Original file line number Diff line number Diff line change
@@ -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,
},
}),
],
});
Original file line number Diff line number Diff line change
@@ -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"] }],
}
Original file line number Diff line number Diff line change
@@ -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<Env> {
async fetch(request: Request): Promise<Response> {
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<Env> {
async fetch(): Promise<Response> {
const current = ((await this.ctx.storage.get<number>('count')) ?? 0) + 1;
await this.ctx.storage.put('count', current);
return Response.json({ count: current });
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
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<Env>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineCloudflareOptions } from '@sentry/cloudflare';

export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
dsn: env.SENTRY_DSN,
tracesSampleRate: 1.0,
}));
Loading
Loading