Skip to content

Commit ace755b

Browse files
committed
feat(cloudflare): Support rpc trace propagation for WorkerEntrypoint
1 parent de706ed commit ace755b

18 files changed

Lines changed: 1034 additions & 2 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { WorkerEntrypoint } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
}
7+
8+
class MySubWorkerEntrypointBase extends WorkerEntrypoint<Env> {
9+
async fetch(request: Request): Promise<Response> {
10+
const url = new URL(request.url);
11+
12+
if (url.pathname === '/answer') {
13+
return new Response('The answer is 42');
14+
}
15+
16+
if (url.pathname === '/greet') {
17+
const name = url.searchParams.get('name') || 'Anonymous';
18+
return new Response(`Hello, ${name}!`);
19+
}
20+
21+
return new Response('Not found', { status: 404 });
22+
}
23+
}
24+
25+
export default Sentry.withSentry(
26+
(env: Env) => ({
27+
dsn: env.SENTRY_DSN,
28+
tracesSampleRate: 1.0,
29+
enableRpcTracePropagation: true,
30+
}),
31+
MySubWorkerEntrypointBase,
32+
);
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
3+
interface Env {
4+
SENTRY_DSN: string;
5+
SUB_WORKER: Fetcher;
6+
}
7+
8+
export default Sentry.withSentry(
9+
(env: Env) => ({
10+
dsn: env.SENTRY_DSN,
11+
tracesSampleRate: 1.0,
12+
enableRpcTracePropagation: true,
13+
}),
14+
{
15+
async fetch(request, env) {
16+
const url = new URL(request.url);
17+
18+
if (url.pathname === '/call-entrypoint') {
19+
const response = await env.SUB_WORKER.fetch(new Request('http://fake-host/answer'));
20+
const text = await response.text();
21+
return new Response(text);
22+
}
23+
24+
if (url.pathname === '/call-entrypoint-greet') {
25+
const response = await env.SUB_WORKER.fetch(new Request('http://fake-host/greet?name=World'));
26+
const text = await response.text();
27+
return new Response(text);
28+
}
29+
30+
return new Response('Not found', { status: 404 });
31+
},
32+
} satisfies ExportedHandler<Env>,
33+
);
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { expect, it } from 'vitest';
2+
import type { Event } from '@sentry/core';
3+
import { createRunner } from '../../../../runner';
4+
5+
it('propagates trace from Worker (ExportedHandler) to WorkerEntrypoint via service binding fetch', async ({
6+
signal,
7+
}) => {
8+
let workerTraceId: string | undefined;
9+
let workerSpanId: string | undefined;
10+
let entrypointTraceId: string | undefined;
11+
let entrypointParentSpanId: string | undefined;
12+
13+
const runner = createRunner(__dirname)
14+
.expect(envelope => {
15+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
16+
17+
// Main worker HTTP server transaction
18+
expect(transactionEvent).toEqual(
19+
expect.objectContaining({
20+
contexts: expect.objectContaining({
21+
trace: expect.objectContaining({
22+
op: 'http.server',
23+
data: expect.objectContaining({
24+
'sentry.origin': 'auto.http.cloudflare',
25+
}),
26+
origin: 'auto.http.cloudflare',
27+
}),
28+
}),
29+
transaction: 'GET /call-entrypoint',
30+
}),
31+
);
32+
workerTraceId = transactionEvent.contexts?.trace?.trace_id as string;
33+
workerSpanId = transactionEvent.contexts?.trace?.span_id as string;
34+
})
35+
.expect(envelope => {
36+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
37+
38+
// WorkerEntrypoint HTTP server transaction (from service binding fetch)
39+
expect(transactionEvent).toEqual(
40+
expect.objectContaining({
41+
contexts: expect.objectContaining({
42+
trace: expect.objectContaining({
43+
op: 'http.server',
44+
data: expect.objectContaining({
45+
'sentry.origin': 'auto.http.cloudflare',
46+
}),
47+
origin: 'auto.http.cloudflare',
48+
}),
49+
}),
50+
transaction: 'GET /answer',
51+
}),
52+
);
53+
entrypointTraceId = transactionEvent.contexts?.trace?.trace_id as string;
54+
entrypointParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string;
55+
})
56+
.unordered()
57+
.start(signal);
58+
59+
const response = await runner.makeRequest<string>('get', '/call-entrypoint');
60+
expect(response).toBe('The answer is 42');
61+
62+
await runner.completed();
63+
64+
// Both transactions should share the same trace_id
65+
expect(workerTraceId).toBeDefined();
66+
expect(entrypointTraceId).toBeDefined();
67+
expect(workerTraceId).toBe(entrypointTraceId);
68+
69+
// Verify the parent-child relationship: Worker -> WorkerEntrypoint
70+
expect(workerSpanId).toBeDefined();
71+
expect(entrypointParentSpanId).toBeDefined();
72+
expect(entrypointParentSpanId).toBe(workerSpanId);
73+
});
74+
75+
it('propagates trace for request with query params from Worker to WorkerEntrypoint', async ({ signal }) => {
76+
let workerTraceId: string | undefined;
77+
let workerSpanId: string | undefined;
78+
let entrypointTraceId: string | undefined;
79+
let entrypointParentSpanId: string | undefined;
80+
81+
const runner = createRunner(__dirname)
82+
.expect(envelope => {
83+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
84+
85+
expect(transactionEvent).toEqual(
86+
expect.objectContaining({
87+
contexts: expect.objectContaining({
88+
trace: expect.objectContaining({
89+
op: 'http.server',
90+
}),
91+
}),
92+
transaction: 'GET /call-entrypoint-greet',
93+
}),
94+
);
95+
workerTraceId = transactionEvent.contexts?.trace?.trace_id as string;
96+
workerSpanId = transactionEvent.contexts?.trace?.span_id as string;
97+
})
98+
.expect(envelope => {
99+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
100+
101+
expect(transactionEvent).toEqual(
102+
expect.objectContaining({
103+
contexts: expect.objectContaining({
104+
trace: expect.objectContaining({
105+
op: 'http.server',
106+
}),
107+
}),
108+
transaction: 'GET /greet',
109+
}),
110+
);
111+
entrypointTraceId = transactionEvent.contexts?.trace?.trace_id as string;
112+
entrypointParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string;
113+
})
114+
.unordered()
115+
.start(signal);
116+
117+
const response = await runner.makeRequest<string>('get', '/call-entrypoint-greet');
118+
expect(response).toBe('Hello, World!');
119+
120+
await runner.completed();
121+
122+
expect(workerTraceId).toBeDefined();
123+
expect(entrypointTraceId).toBeDefined();
124+
expect(workerTraceId).toBe(entrypointTraceId);
125+
126+
expect(workerSpanId).toBeDefined();
127+
expect(entrypointParentSpanId).toBeDefined();
128+
expect(entrypointParentSpanId).toBe(workerSpanId);
129+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "cloudflare-worker-workerentrypoint-rpc-sub",
3+
"main": "index-sub-worker.ts",
4+
"compatibility_date": "2025-06-17",
5+
"compatibility_flags": ["nodejs_als"],
6+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "cloudflare-worker-workerentrypoint-rpc",
3+
"main": "index.ts",
4+
"compatibility_date": "2025-06-17",
5+
"compatibility_flags": ["nodejs_als"],
6+
"services": [
7+
{
8+
"binding": "SUB_WORKER",
9+
"service": "cloudflare-worker-workerentrypoint-rpc-sub",
10+
},
11+
],
12+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject, WorkerEntrypoint } from 'cloudflare:workers';
3+
import type { RpcTarget } from 'cloudflare:workers';
4+
5+
interface Env {
6+
SENTRY_DSN: string;
7+
MY_DURABLE_OBJECT: DurableObjectNamespace<MyDurableObjectBase>;
8+
}
9+
10+
class MyDurableObjectBase extends DurableObject<Env> implements RpcTarget {
11+
async sayHello(name: string): Promise<string> {
12+
return `Hello, ${name}!`;
13+
}
14+
}
15+
16+
// enableRpcTracePropagation is NOT enabled, so RPC methods won't be instrumented
17+
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
18+
(env: Env) => ({
19+
dsn: env.SENTRY_DSN,
20+
tracesSampleRate: 1.0,
21+
// enableRpcTracePropagation: false (default)
22+
}),
23+
MyDurableObjectBase,
24+
);
25+
26+
class MyWorkerEntrypointBase extends WorkerEntrypoint<Env> {
27+
async fetch(request: Request): Promise<Response> {
28+
const url = new URL(request.url);
29+
const id = this.env.MY_DURABLE_OBJECT.idFromName('test');
30+
const stub = this.env.MY_DURABLE_OBJECT.get(id);
31+
32+
if (url.pathname === '/rpc/hello') {
33+
const result = await stub.sayHello('World');
34+
return new Response(result);
35+
}
36+
37+
return new Response('Not found', { status: 404 });
38+
}
39+
}
40+
41+
export default Sentry.withSentry(
42+
(env: Env) => ({
43+
dsn: env.SENTRY_DSN,
44+
tracesSampleRate: 1.0,
45+
}),
46+
MyWorkerEntrypointBase,
47+
);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { expect, it } from 'vitest';
2+
import type { Event } from '@sentry/core';
3+
import { createRunner } from '../../../../runner';
4+
5+
it('does not create RPC transaction when enableRpcTracePropagation is disabled (WorkerEntrypoint)', async ({
6+
signal,
7+
}) => {
8+
let receivedTransactions: string[] = [];
9+
10+
const runner = createRunner(__dirname)
11+
.expect(envelope => {
12+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
13+
14+
// Should only receive the worker HTTP transaction, not the DO RPC transaction
15+
expect(transactionEvent).toEqual(
16+
expect.objectContaining({
17+
contexts: expect.objectContaining({
18+
trace: expect.objectContaining({
19+
op: 'http.server',
20+
data: expect.objectContaining({
21+
'sentry.origin': 'auto.http.cloudflare',
22+
}),
23+
origin: 'auto.http.cloudflare',
24+
}),
25+
}),
26+
transaction: 'GET /rpc/hello',
27+
}),
28+
);
29+
receivedTransactions.push(transactionEvent.transaction as string);
30+
})
31+
.start(signal);
32+
33+
// The RPC call should still work, just not be instrumented
34+
const response = await runner.makeRequest<string>('get', '/rpc/hello');
35+
expect(response).toBe('Hello, World!');
36+
37+
await runner.completed();
38+
39+
// Verify we only got the worker transaction, no RPC transaction
40+
expect(receivedTransactions).toEqual(['GET /rpc/hello']);
41+
expect(receivedTransactions).not.toContain('sayHello');
42+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "cloudflare-workerentrypoint-do-rpc-disabled",
3+
"main": "index.ts",
4+
"compatibility_date": "2025-06-17",
5+
"compatibility_flags": ["nodejs_als"],
6+
"migrations": [
7+
{
8+
"new_sqlite_classes": ["MyDurableObject"],
9+
"tag": "v1",
10+
},
11+
],
12+
"durable_objects": {
13+
"bindings": [
14+
{
15+
"class_name": "MyDurableObject",
16+
"name": "MY_DURABLE_OBJECT",
17+
},
18+
],
19+
},
20+
}
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, WorkerEntrypoint } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
MY_DURABLE_OBJECT: DurableObjectNamespace<MyDurableObjectBase>;
7+
}
8+
9+
class MyDurableObjectBase extends DurableObject<Env> {
10+
async sayHello(name: string): Promise<string> {
11+
return `Hello, ${name}!`;
12+
}
13+
14+
async multiply(a: number, b: number): Promise<number> {
15+
return a * b;
16+
}
17+
}
18+
19+
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
20+
(env: Env) => ({
21+
dsn: env.SENTRY_DSN,
22+
tracesSampleRate: 1.0,
23+
enableRpcTracePropagation: true,
24+
}),
25+
MyDurableObjectBase,
26+
);
27+
28+
class MyWorkerEntrypointBase extends WorkerEntrypoint {
29+
async fetch(request: Request): Promise<Response> {
30+
const url = new URL(request.url);
31+
const id = this.env.MY_DURABLE_OBJECT.idFromName('test');
32+
const stub = this.env.MY_DURABLE_OBJECT.get(id);
33+
34+
if (url.pathname === '/rpc/hello') {
35+
const result = await stub.sayHello('World');
36+
return new Response(result);
37+
}
38+
39+
if (url.pathname === '/rpc/multiply') {
40+
const result = await stub.multiply(6, 7);
41+
return new Response(String(result));
42+
}
43+
44+
return new Response('Not found', { status: 404 });
45+
}
46+
}
47+
48+
export default Sentry.withSentry(
49+
(env: Env) => ({
50+
dsn: env.SENTRY_DSN,
51+
tracesSampleRate: 1.0,
52+
enableRpcTracePropagation: true,
53+
}),
54+
MyWorkerEntrypointBase,
55+
);

0 commit comments

Comments
 (0)