Skip to content

Commit 5f77c76

Browse files
authored
feat(cloudflare): Support rpc trace propagation for WorkerEntrypoint (#20523)
closes #15942 closes [JS-212](https://linear.app/getsentry/issue/JS-212/add-support-for-cloudflare-workerentrypoint) We already have support for `WorkerEntrypoint`, but they were not yet fully supported because we didn't have trace propagation for them. This PR basically adds RPC trace propagation (and therefore full support for `WorkerEntrypoint`) by instrumenting `env` for the `WorkerEntrypoint`. The base implementation is pretty straight forward, it is using `instrumentEnv` and overwrites the `env` of the entrypoint instance. The rest are tests and different scenarios showing how it works. Most tests are the same as the normal exported handler tests: `worker-do-rpc` is the same as `workerentrypoint-do-rpc`, just with a `WorkerEntrypoint` rather than the `ExportedHandler`. The only additional test scenario is `worker-workerentrypoint-rpc` which showcases if `ExportedHandler` and `WorkerEntrypoint` are working out together.
1 parent 267c9ed commit 5f77c76

20 files changed

Lines changed: 1112 additions & 25 deletions

File tree

‎dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/index.ts‎

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,25 @@
11
import * as Sentry from '@sentry/cloudflare';
22
import { DurableObject } from 'cloudflare:workers';
3-
import type { RpcTarget } from 'cloudflare:workers';
43

54
interface Env {
65
SENTRY_DSN: string;
76
MY_DURABLE_OBJECT: DurableObjectNamespace<MyDurableObjectBase>;
87
}
98

10-
class MyDurableObjectBase extends DurableObject<Env> implements RpcTarget {
11-
async sayHello(name: string): Promise<string> {
12-
return `Hello, ${name}!`;
9+
class MyDurableObjectBase extends DurableObject<Env> {
10+
async fetch(request: Request): Promise<Response> {
11+
const url = new URL(request.url);
12+
if (url.pathname === '/hello') {
13+
return new Response('Hello, World!');
14+
}
15+
return new Response('Not found', { status: 404 });
1316
}
1417
}
1518

16-
// enableRpcTracePropagation is NOT enabled, so RPC methods won't be instrumented
1719
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
1820
(env: Env) => ({
1921
dsn: env.SENTRY_DSN,
2022
tracesSampleRate: 1.0,
21-
// enableRpcTracePropagation: false (default)
2223
}),
2324
MyDurableObjectBase,
2425
);
@@ -34,9 +35,11 @@ export default Sentry.withSentry(
3435
const id = env.MY_DURABLE_OBJECT.idFromName('test');
3536
const stub = env.MY_DURABLE_OBJECT.get(id);
3637

37-
if (url.pathname === '/rpc/hello') {
38-
const result = await stub.sayHello('World');
39-
return new Response(result);
38+
if (url.pathname === '/do/hello') {
39+
// Call DO via fetch instead of RPC
40+
const doResponse = await stub.fetch(new Request('http://do/hello'));
41+
const text = await doResponse.text();
42+
return new Response(text);
4043
}
4144

4245
return new Response('Not found', { status: 404 });

‎dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-disabled/test.ts‎

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,39 +2,65 @@ import { expect, it } from 'vitest';
22
import type { Event } from '@sentry/core';
33
import { createRunner } from '../../../../runner';
44

5-
it('does not create RPC transaction when enableRpcTracePropagation is disabled', async ({ signal }) => {
6-
let receivedTransactions: string[] = [];
5+
it('does not propagate trace when enableRpcTracePropagation is disabled', async ({ signal }) => {
6+
let workerTraceId: string | undefined;
7+
let doTraceId: string | undefined;
78

89
const runner = createRunner(__dirname)
910
.expect(envelope => {
1011
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
1112

12-
// Should only receive the worker HTTP transaction, not the DO RPC transaction
1313
expect(transactionEvent).toEqual(
1414
expect.objectContaining({
1515
contexts: expect.objectContaining({
1616
trace: expect.objectContaining({
1717
op: 'http.server',
18-
data: expect.objectContaining({
19-
'sentry.origin': 'auto.http.cloudflare',
20-
}),
21-
origin: 'auto.http.cloudflare',
2218
}),
2319
}),
24-
transaction: 'GET /rpc/hello',
2520
}),
2621
);
27-
receivedTransactions.push(transactionEvent.transaction as string);
22+
23+
const txName = transactionEvent.transaction as string;
24+
const traceId = transactionEvent.contexts?.trace?.trace_id as string;
25+
26+
if (txName === 'GET /do/hello') {
27+
workerTraceId = traceId;
28+
} else if (txName === 'GET /hello') {
29+
doTraceId = traceId;
30+
}
31+
})
32+
.expect(envelope => {
33+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
34+
35+
expect(transactionEvent).toEqual(
36+
expect.objectContaining({
37+
contexts: expect.objectContaining({
38+
trace: expect.objectContaining({
39+
op: 'http.server',
40+
}),
41+
}),
42+
}),
43+
);
44+
45+
const txName = transactionEvent.transaction as string;
46+
const traceId = transactionEvent.contexts?.trace?.trace_id as string;
47+
48+
if (txName === 'GET /do/hello') {
49+
workerTraceId = traceId;
50+
} else if (txName === 'GET /hello') {
51+
doTraceId = traceId;
52+
}
2853
})
54+
.unordered()
2955
.start(signal);
3056

31-
// The RPC call should still work, just not be instrumented
32-
const response = await runner.makeRequest<string>('get', '/rpc/hello');
57+
const response = await runner.makeRequest<string>('get', '/do/hello');
3358
expect(response).toBe('Hello, World!');
3459

3560
await runner.completed();
3661

37-
// Verify we only got the worker transaction, no RPC transaction
38-
expect(receivedTransactions).toEqual(['GET /rpc/hello']);
39-
expect(receivedTransactions).not.toContain('sayHello');
62+
// Both transactions should exist but have different trace IDs (no propagation)
63+
expect(workerTraceId).toBeDefined();
64+
expect(doTraceId).toBeDefined();
65+
expect(workerTraceId).not.toBe(doTraceId);
4066
});
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: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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 fetch(request: Request): Promise<Response> {
11+
const url = new URL(request.url);
12+
if (url.pathname === '/hello') {
13+
return new Response('Hello, World!');
14+
}
15+
return new Response('Not found', { status: 404 });
16+
}
17+
}
18+
19+
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
20+
(env: Env) => ({
21+
dsn: env.SENTRY_DSN,
22+
tracesSampleRate: 1.0,
23+
}),
24+
MyDurableObjectBase,
25+
);
26+
27+
class MyWorkerEntrypointBase extends WorkerEntrypoint<Env> {
28+
async fetch(request: Request): Promise<Response> {
29+
const url = new URL(request.url);
30+
const id = this.env.MY_DURABLE_OBJECT.idFromName('test');
31+
const stub = this.env.MY_DURABLE_OBJECT.get(id);
32+
33+
if (url.pathname === '/do/hello') {
34+
const doResponse = await stub.fetch(new Request('http://do/hello'));
35+
const text = await doResponse.text();
36+
return new Response(text);
37+
}
38+
39+
return new Response('Not found', { status: 404 });
40+
}
41+
}
42+
43+
export default Sentry.withSentry(
44+
(env: Env) => ({
45+
dsn: env.SENTRY_DSN,
46+
tracesSampleRate: 1.0,
47+
}),
48+
MyWorkerEntrypointBase,
49+
);

0 commit comments

Comments
 (0)