Skip to content

Commit 4e52052

Browse files
JPeer264andreiborza
authored andcommitted
feat(v10/cloudflare): Auto-instrument WorkerEntrypoint classes
Backport of: #22493
1 parent 3ee3d15 commit 4e52052

27 files changed

Lines changed: 1129 additions & 16 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
2+
3+
interface Env {
4+
SENTRY_DSN: string;
5+
SELF: Fetcher;
6+
COUNTER: DurableObjectNamespace;
7+
}
8+
9+
// The entrypoint itself reaches into the Durable Object. This exercises a nested
10+
// chain — default handler → auto-wrapped `WorkerEntrypoint` → auto-wrapped
11+
// `DurableObject` — proving a DO invoked from *inside* an auto-instrumented
12+
// entrypoint is still instrumented. Nothing here is manually wrapped.
13+
export class CounterEntrypoint extends WorkerEntrypoint<Env> {
14+
async fetch(request: Request): Promise<Response> {
15+
const url = new URL(request.url);
16+
if (url.pathname === '/work') {
17+
const stub = this.env.COUNTER.get(this.env.COUNTER.idFromName('e2e'));
18+
return stub.fetch(new Request('https://do/increment'));
19+
}
20+
return new Response('Not found', { status: 404 });
21+
}
22+
}
23+
24+
export class Counter extends DurableObject<Env> {
25+
async fetch(): Promise<Response> {
26+
const current = ((await this.ctx.storage.get<number>('count')) ?? 0) + 1;
27+
await this.ctx.storage.put('count', current);
28+
return Response.json({ count: current });
29+
}
30+
}
31+
32+
export default {
33+
async fetch(request: Request, env: Env): Promise<Response> {
34+
const url = new URL(request.url);
35+
36+
if (url.pathname === '/chain') {
37+
// Hops into the entrypoint (which in turn hits the DO) via the self service
38+
// binding, so the whole auto-wrapped chain runs on one request.
39+
return env.SELF.fetch(new Request('https://self/work'));
40+
}
41+
42+
return new Response('Not found', { status: 404 });
43+
},
44+
} satisfies ExportedHandler<Env>;
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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { TransactionEvent } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../../runner';
4+
5+
// The Durable Object, reached from inside the entrypoint, emits an `http.server`
6+
// transaction whose only children are the two
7+
// `auto.db.cloudflare.durable_object` storage spans (`get` + `put`) — present
8+
// only when the class was auto-instrumented.
9+
function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void {
10+
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
11+
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare');
12+
expect(transactionEvent.spans).toEqual([
13+
expect.objectContaining({
14+
op: 'db',
15+
description: 'durable_object_storage_get',
16+
origin: 'auto.db.cloudflare.durable_object',
17+
}),
18+
expect.objectContaining({
19+
op: 'db',
20+
description: 'durable_object_storage_put',
21+
origin: 'auto.db.cloudflare.durable_object',
22+
}),
23+
]);
24+
}
25+
26+
function expectPlainTransaction(name: string) {
27+
return (transactionEvent: TransactionEvent): void => {
28+
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
29+
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare');
30+
expect(transactionEvent.transaction).toBe(name);
31+
expect(transactionEvent.spans).toHaveLength(0);
32+
};
33+
}
34+
35+
// A single request fans out through the whole auto-wrapped chain: default
36+
// handler (`/chain`) → self-bound `CounterEntrypoint` (`/work`) → `Counter`
37+
// Durable Object. All three transactions arrive only if the build-time transform
38+
// wrapped the default export, the entrypoint, and the DO — and it proves a DO
39+
// invoked from *within* an auto-instrumented entrypoint is itself instrumented.
40+
it('auto-instruments a Durable Object invoked from within a WorkerEntrypoint', async ({ signal }) => {
41+
const runner = createRunner(__dirname)
42+
.unordered()
43+
.expect(envelope => expectPlainTransaction('GET /chain')(envelope[1]?.[0]?.[1] as TransactionEvent))
44+
.expect(envelope => expectPlainTransaction('GET /work')(envelope[1]?.[0]?.[1] as TransactionEvent))
45+
.expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
46+
.start(signal);
47+
48+
await runner.makeRequest('get', '/chain');
49+
await runner.completed();
50+
});
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 wraps the worker
7+
// entry, the self-bound `CounterEntrypoint`, and the `Counter` Durable Object
8+
// before the Cloudflare plugin bundles it.
9+
plugins: [
10+
cloudflare(),
11+
sentryCloudflareVitePlugin({
12+
_experimental: {
13+
autoInstrumentation: true,
14+
},
15+
}),
16+
],
17+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"$schema": "../../../node_modules/wrangler/config-schema.json",
3+
"name": "cloudflare-vite-autoinstrument-combination-entrypoint-do-chained",
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+
// Self-service-binding: the worker binds to its own named `WorkerEntrypoint`
10+
// export, so the auto-instrument transform can identify and wrap it.
11+
"services": [
12+
{
13+
"binding": "SELF",
14+
"service": "cloudflare-vite-autoinstrument-combination-entrypoint-do-chained",
15+
"entrypoint": "CounterEntrypoint",
16+
},
17+
],
18+
"durable_objects": {
19+
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
20+
},
21+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
22+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
SELF: Fetcher;
7+
COUNTER: DurableObjectNamespace;
8+
}
9+
10+
// The WorkerEntrypoint is left plain — the auto-instrument transform must wrap
11+
// it (and the default export) at build time.
12+
export class GreeterEntrypoint extends WorkerEntrypoint<Env> {
13+
async fetch(request: Request): Promise<Response> {
14+
const url = new URL(request.url);
15+
if (url.pathname === '/greet') {
16+
return new Response('Hello from the entrypoint');
17+
}
18+
return new Response('Not found', { status: 404 });
19+
}
20+
}
21+
22+
class CounterImpl extends DurableObject<Env> {
23+
async fetch(): Promise<Response> {
24+
const current = ((await this.ctx.storage.get<number>('count')) ?? 0) + 1;
25+
await this.ctx.storage.put('count', current);
26+
return Response.json({ count: current });
27+
}
28+
}
29+
30+
// The Durable Object is already wrapped manually. The transform must detect the
31+
// existing `Sentry.instrumentDurableObjectWithSentry` call and leave it
32+
// untouched (no double-wrap) while still auto-wrapping the plain entrypoint and
33+
// default export in the same file.
34+
export const Counter = Sentry.instrumentDurableObjectWithSentry(
35+
(env: Env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0 }),
36+
CounterImpl,
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 === '/call-entrypoint') {
44+
return env.SELF.fetch(new Request('https://self/greet'));
45+
}
46+
47+
if (url.pathname === '/increment') {
48+
const stub = env.COUNTER.get(env.COUNTER.idFromName('e2e'));
49+
return stub.fetch(new Request('https://do/increment'));
50+
}
51+
52+
return new Response('Not found', { status: 404 });
53+
},
54+
} satisfies ExportedHandler<Env>;
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: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { TransactionEvent } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../../runner';
4+
5+
// A Durable Object invoked via `fetch` emits an `http.server` transaction whose
6+
// only children are the two `auto.db.cloudflare.durable_object` storage spans
7+
// (`get` + `put`). Here they come from the manual wrap — the assertion also
8+
// proves the transform did NOT double-wrap (a double-wrap would nest proxies or
9+
// break the build).
10+
function expectDurableObjectTransaction(transactionEvent: TransactionEvent): void {
11+
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
12+
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare');
13+
expect(transactionEvent.spans).toEqual([
14+
expect.objectContaining({
15+
op: 'db',
16+
description: 'durable_object_storage_get',
17+
origin: 'auto.db.cloudflare.durable_object',
18+
}),
19+
expect.objectContaining({
20+
op: 'db',
21+
description: 'durable_object_storage_put',
22+
origin: 'auto.db.cloudflare.durable_object',
23+
}),
24+
]);
25+
}
26+
27+
function expectPlainTransaction(name: string) {
28+
return (transactionEvent: TransactionEvent): void => {
29+
expect(transactionEvent.contexts?.trace?.op).toBe('http.server');
30+
expect(transactionEvent.contexts?.trace?.origin).toBe('auto.http.cloudflare');
31+
expect(transactionEvent.transaction).toBe(name);
32+
expect(transactionEvent.spans).toHaveLength(0);
33+
};
34+
}
35+
36+
// The Durable Object is manually wrapped with
37+
// `Sentry.instrumentDurableObjectWithSentry`; the `GreeterEntrypoint` and the
38+
// default export are plain. The transform must skip the manual DO (no
39+
// double-wrap) yet still auto-wrap the entrypoint and default handler — so the
40+
// manual DO transaction (with storage spans) and both auto-wrapped transactions
41+
// all arrive exactly once.
42+
it('leaves a manually wrapped Durable Object untouched while auto-wrapping a sibling WorkerEntrypoint', async ({
43+
signal,
44+
}) => {
45+
const runner = createRunner(__dirname)
46+
.unordered()
47+
.expect(envelope => expectPlainTransaction('GET /call-entrypoint')(envelope[1]?.[0]?.[1] as TransactionEvent))
48+
.expect(envelope => expectPlainTransaction('GET /greet')(envelope[1]?.[0]?.[1] as TransactionEvent))
49+
.expect(envelope => expectPlainTransaction('GET /increment')(envelope[1]?.[0]?.[1] as TransactionEvent))
50+
.expect(envelope => expectDurableObjectTransaction(envelope[1]?.[0]?.[1] as TransactionEvent))
51+
.start(signal);
52+
53+
await runner.makeRequest('get', '/call-entrypoint');
54+
await runner.makeRequest('get', '/increment');
55+
await runner.completed();
56+
});
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` Durable Object
8+
// and only auto-wrap the plain `GreeterEntrypoint` and default export.
9+
plugins: [
10+
cloudflare(),
11+
sentryCloudflareVitePlugin({
12+
_experimental: {
13+
autoInstrumentation: true,
14+
},
15+
}),
16+
],
17+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"$schema": "../../../node_modules/wrangler/config-schema.json",
3+
"name": "cloudflare-vite-autoinstrument-combination-entrypoint-do-manual-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+
// Self-service-binding: the worker binds to its own named `WorkerEntrypoint`
10+
// export, so the auto-instrument transform can identify and wrap it.
11+
"services": [
12+
{
13+
"binding": "SELF",
14+
"service": "cloudflare-vite-autoinstrument-combination-entrypoint-do-manual-mixed",
15+
"entrypoint": "GreeterEntrypoint",
16+
},
17+
],
18+
"durable_objects": {
19+
"bindings": [{ "name": "COUNTER", "class_name": "Counter" }],
20+
},
21+
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["Counter"] }],
22+
}

0 commit comments

Comments
 (0)