Skip to content

Commit 2de5e1c

Browse files
JPeer264andreiborza
authored andcommitted
feat(v10/cloudflare): Auto-instrument Durable Object classes
Backport of: #22437
1 parent fba1699 commit 2de5e1c

41 files changed

Lines changed: 1413 additions & 16 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
COUNTER: DurableObjectNamespace;
7+
}
8+
9+
class CounterImpl extends DurableObject<Env> {
10+
async fetch(): Promise<Response> {
11+
const current = ((await this.ctx.storage.get<number>('count')) ?? 0) + 1;
12+
await this.ctx.storage.put('count', current);
13+
return Response.json({ count: current });
14+
}
15+
}
16+
17+
// The Durable Object is already wrapped manually. The auto-instrument transform
18+
// must detect the existing `Sentry.instrumentDurableObjectWithSentry` call and
19+
// leave it untouched — no second wrap — while still wrapping the plain default
20+
// export below.
21+
export const Counter = Sentry.instrumentDurableObjectWithSentry(
22+
(env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }),
23+
CounterImpl,
24+
);
25+
26+
export default {
27+
async fetch(request: Request, env: Env): Promise<Response> {
28+
const url = new URL(request.url);
29+
30+
if (url.pathname === '/increment') {
31+
const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e'));
32+
return stub.fetch(new Request('https://do/increment'));
33+
}
34+
35+
return new Response('Not found', { status: 404 });
36+
},
37+
} satisfies ExportedHandler<Env>;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
4+
dsn: env.SENTRY_DSN,
5+
tracesSampleRate: 1.0,
6+
}));
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { TransactionEvent } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../../runner';
4+
5+
// A fetch-invoked Durable Object emits an `http.server` transaction whose only
6+
// children are the two `auto.db.cloudflare.durable_object` storage spans
7+
// (`get` + `put`) — present only when the class is instrumented.
8+
function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void {
9+
expect(transactionEvent).toEqual(
10+
expect.objectContaining({
11+
contexts: expect.objectContaining({
12+
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
13+
}),
14+
}),
15+
);
16+
expect(transactionEvent.spans).toHaveLength(2);
17+
expect(transactionEvent.spans).toEqual([
18+
expect.objectContaining({
19+
op: 'db',
20+
description: 'durable_object_storage_get',
21+
origin: 'auto.db.cloudflare.durable_object',
22+
}),
23+
expect.objectContaining({
24+
op: 'db',
25+
description: 'durable_object_storage_put',
26+
origin: 'auto.db.cloudflare.durable_object',
27+
}),
28+
]);
29+
}
30+
31+
// The main worker transaction just forwards to the DO, so it carries no child
32+
// spans. The empty-spans assertion keeps it disjoint from the DO transaction.
33+
function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void {
34+
expect(transactionEvent).toEqual(
35+
expect.objectContaining({
36+
contexts: expect.objectContaining({
37+
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
38+
}),
39+
}),
40+
);
41+
expect(transactionEvent.spans).toHaveLength(0);
42+
}
43+
44+
// The Durable Object is already wrapped manually with
45+
// `Sentry.instrumentDurableObjectWithSentry`. The transform must recognize the
46+
// existing wrap and NOT wrap it again (a double-wrap would either break the
47+
// build or nest proxies), while still auto-wrapping the plain default export.
48+
// We therefore expect exactly one storage-bearing DO transaction (from the
49+
// manual wrap) and one child-less main-worker transaction (from the auto wrap).
50+
it('leaves a manually wrapped Durable Object untouched and still wraps the default export', async ({ signal }) => {
51+
const runner = createRunner(__dirname)
52+
.unordered()
53+
.expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
54+
.expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
55+
.start(signal);
56+
57+
await runner.makeRequest('get', '/increment');
58+
await runner.completed();
59+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { cloudflare } from '@cloudflare/vite-plugin';
2+
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
3+
import { defineConfig } from 'vite';
4+
5+
export default defineConfig({
6+
// The Sentry plugin runs first so its build-time transform runs over the
7+
// worker entry — it must skip the manually wrapped `Counter` and only wrap
8+
// the plain default export.
9+
plugins: [
10+
cloudflare(),
11+
sentryCloudflareVitePlugin({
12+
_experimental: {
13+
autoInstrumentation: true,
14+
},
15+
}),
16+
],
17+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"$schema": "../../../node_modules/wrangler/config-schema.json",
3+
"name": "cloudflare-vite-autoinstrument-durableobject-manual-wrap",
4+
// `main` points at the source entry; the Sentry Vite plugin builds from it (so
5+
// the auto-instrument transform runs) and the runner serves the built output.
6+
"main": "index.ts",
7+
"compatibility_date": "2025-06-17",
8+
"compatibility_flags": ["nodejs_als"],
9+
"durable_objects": {
10+
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
11+
},
12+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
13+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
MANUAL: DurableObjectNamespace;
7+
AUTO: DurableObjectNamespace;
8+
}
9+
10+
class ManualImpl extends DurableObject<Env> {
11+
async fetch(): Promise<Response> {
12+
// Touch storage so the instrumented DO emits an
13+
// `auto.db.cloudflare.durable_object` span the test can fingerprint.
14+
const current = ((await this.ctx.storage.get<number>('count')) ?? 0) + 1;
15+
await this.ctx.storage.put('count', current);
16+
return Response.json({ kind: 'manual', count: current });
17+
}
18+
}
19+
20+
// Manually wrapped — the transform must leave this alone.
21+
export const Manual = Sentry.instrumentDurableObjectWithSentry(
22+
(env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }),
23+
ManualImpl,
24+
);
25+
26+
// Plain inline export — the transform must wrap this one. Both classes are
27+
// configured in wrangler, so this exercises wrapping only the unwrapped class
28+
// while skipping the manually wrapped sibling in the same file.
29+
export class Auto extends DurableObject<Env> {
30+
async fetch(): Promise<Response> {
31+
// Touch storage so the instrumented DO emits an
32+
// `auto.db.cloudflare.durable_object` span the test can fingerprint.
33+
const current = ((await this.ctx.storage.get<number>('count')) ?? 0) + 1;
34+
await this.ctx.storage.put('count', current);
35+
return Response.json({ kind: 'auto', count: current });
36+
}
37+
}
38+
39+
export default {
40+
async fetch(request: Request, env: Env): Promise<Response> {
41+
const url = new URL(request.url);
42+
43+
if (url.pathname === '/manual') {
44+
const stub = env.MANUAL.get(env.MANUAL.idFromName('e2e-manual'));
45+
return stub.fetch(new Request('https://do/manual'));
46+
}
47+
48+
if (url.pathname === '/auto') {
49+
const stub = env.AUTO.get(env.AUTO.idFromName('e2e-auto'));
50+
return stub.fetch(new Request('https://do/auto'));
51+
}
52+
53+
return new Response('Not found', { status: 404 });
54+
},
55+
} satisfies ExportedHandler<Env>;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { defineCloudflareOptions } from '@sentry/cloudflare';
2+
3+
export default defineCloudflareOptions((env: { SENTRY_DSN: string }) => ({
4+
dsn: env.SENTRY_DSN,
5+
tracesSampleRate: 1.0,
6+
}));
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import type { TransactionEvent } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../../runner';
4+
5+
// A fetch-invoked Durable Object emits an `http.server` transaction whose only
6+
// children are the two `auto.db.cloudflare.durable_object` storage spans
7+
// (`get` + `put`) — present only when the class is instrumented (whether by the
8+
// manual wrap or the build-time auto-wrap).
9+
function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void {
10+
expect(transactionEvent).toEqual(
11+
expect.objectContaining({
12+
contexts: expect.objectContaining({
13+
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
14+
}),
15+
}),
16+
);
17+
expect(transactionEvent.spans).toHaveLength(2);
18+
expect(transactionEvent.spans).toEqual([
19+
expect.objectContaining({
20+
op: 'db',
21+
description: 'durable_object_storage_get',
22+
origin: 'auto.db.cloudflare.durable_object',
23+
}),
24+
expect.objectContaining({
25+
op: 'db',
26+
description: 'durable_object_storage_put',
27+
origin: 'auto.db.cloudflare.durable_object',
28+
}),
29+
]);
30+
}
31+
32+
// The main worker transaction just forwards to the DO, so it carries no child
33+
// spans. The empty-spans assertion keeps it disjoint from the DO transactions.
34+
function expectMainWorkerTransaction(transactionEvent: TransactionEvent): void {
35+
expect(transactionEvent).toEqual(
36+
expect.objectContaining({
37+
contexts: expect.objectContaining({
38+
trace: expect.objectContaining({ op: 'http.server', origin: 'auto.http.cloudflare' }),
39+
}),
40+
}),
41+
);
42+
expect(transactionEvent.spans).toHaveLength(0);
43+
}
44+
45+
// One DO (`Manual`) is wrapped by hand, the other (`Auto`) is a plain inline
46+
// export. Both are bound in wrangler. The transform must skip the manual one and
47+
// auto-wrap only `Auto` — so both endpoints report a storage-bearing DO
48+
// transaction (one from the manual wrap, one from the auto wrap) without
49+
// double-instrumenting `Manual`.
50+
it('wraps only the unwrapped Durable Object when a sibling is manually wrapped', async ({ signal }) => {
51+
const runner = createRunner(__dirname)
52+
.unordered()
53+
// One storage-bearing DO transaction from the manual wrap, one from the auto wrap.
54+
.expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
55+
.expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
56+
// One child-less main worker transaction per request.
57+
.expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
58+
.expect(envelope => expectMainWorkerTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
59+
.start(signal);
60+
61+
await runner.makeRequest('get', '/manual');
62+
await runner.makeRequest('get', '/auto');
63+
await runner.completed();
64+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { cloudflare } from '@cloudflare/vite-plugin';
2+
import { sentryCloudflareVitePlugin } from '@sentry/cloudflare/vite';
3+
import { defineConfig } from 'vite';
4+
5+
export default defineConfig({
6+
// The Sentry plugin runs first so its build-time transform skips the manually
7+
// wrapped `Manual` DO and auto-wraps `Auto` before the Cloudflare plugin bundles it.
8+
plugins: [
9+
cloudflare(),
10+
sentryCloudflareVitePlugin({
11+
_experimental: {
12+
autoInstrumentation: true,
13+
},
14+
}),
15+
],
16+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"$schema": "../../../node_modules/wrangler/config-schema.json",
3+
"name": "cloudflare-vite-autoinstrument-durableobject-mixed",
4+
// `main` points at the source entry; the Sentry Vite plugin builds from it (so
5+
// the auto-instrument transform runs) and the runner serves the built output.
6+
"main": "index.ts",
7+
"compatibility_date": "2025-06-17",
8+
"compatibility_flags": ["nodejs_als"],
9+
"durable_objects": {
10+
"bindings": [
11+
{ "name": "MANUAL", "class_name": "Manual" },
12+
{ "name": "AUTO", "class_name": "Auto" },
13+
],
14+
},
15+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Manual", "Auto"] }],
16+
}

0 commit comments

Comments
 (0)