Skip to content
Open
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,37 @@
import * as Sentry from '@sentry/cloudflare';
import { DurableObject } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
COUNTER: DurableObjectNamespace;
}

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 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<Response> {
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<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,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();
});
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` and only wrap
// the plain default export.
plugins: [
cloudflare(),
sentryCloudflareVitePlugin({
_experimental: {
autoInstrumentation: true,
},
}),
],
});
Original file line number Diff line number Diff line change
@@ -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"] }],
}
Original file line number Diff line number Diff line change
@@ -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<Env> {
async fetch(): Promise<Response> {
// 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<number>('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<Env> {
async fetch(): Promise<Response> {
// 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<number>('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<Response> {
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<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,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();
});
Original file line number Diff line number Diff line change
@@ -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,
},
}),
],
});
Original file line number Diff line number Diff line change
@@ -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"] }],
}
Original file line number Diff line number Diff line change
@@ -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<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({ counter: 'a', count: current });
}
}

export class CounterB 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({ counter: 'b', count: current });
}
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
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<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