Skip to content

Commit 08d395b

Browse files
committed
feat(cloudflare): Add trace propagation for RPC method calls
1 parent 2fd74e9 commit 08d395b

23 files changed

Lines changed: 1212 additions & 288 deletions

File tree

dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/index.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,50 @@ interface Env {
66
TEST_DURABLE_OBJECT: DurableObjectNamespace;
77
}
88

9+
// Regression test for https://github.com/getsentry/sentry-javascript/issues/17127
10+
// This class mimics a real-world DO with private fields/methods and multiple public methods
911
class TestDurableObjectBase extends DurableObject<Env> {
12+
// Real private field for internal state (not accessed by RPC methods due to proxy limitations)
13+
#requestCount = 0;
14+
1015
public constructor(ctx: DurableObjectState, env: Env) {
1116
super(ctx, env);
1217
}
1318

14-
// eslint-disable-next-line @typescript-eslint/explicit-member-accessibility
19+
// Real private method for internal use
20+
#incrementCount(): void {
21+
this.#requestCount++;
22+
}
23+
24+
// Internal method that uses private fields (called from non-RPC context like alarm/fetch)
25+
getRequestCount(): number {
26+
return this.#requestCount;
27+
}
28+
29+
// The method being called in tests via RPC
1530
async sayHello(name: string): Promise<string> {
1631
return `Hello, ${name}`;
1732
}
33+
34+
// Other public methods that are not called - should not interfere with RPC
35+
async getStatus(): Promise<string> {
36+
return 'OK';
37+
}
38+
39+
async processData(data: Record<string, unknown>): Promise<Record<string, unknown>> {
40+
return { ...data, processed: true };
41+
}
42+
43+
async multiply(a: number, b: number): Promise<number> {
44+
return a * b;
45+
}
1846
}
1947

2048
export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry(
2149
(env: Env) => ({
2250
dsn: env.SENTRY_DSN,
2351
tracesSampleRate: 1.0,
24-
instrumentPrototypeMethods: true,
52+
enableRpcTracePropagation: true,
2553
}),
2654
TestDurableObjectBase,
2755
);

dev-packages/cloudflare-integration-tests/suites/tracing/durableobject/test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,40 @@ it('traces a durable object method', async ({ signal }) => {
2525
await runner.makeRequest('get', '/hello');
2626
await runner.completed();
2727
});
28+
29+
// Regression test for https://github.com/getsentry/sentry-javascript/issues/17127
30+
// The RPC receiver does not implement the method error on consecutive calls
31+
it('handles consecutive RPC calls without throwing "RPC receiver does not implement method" error', async ({
32+
signal,
33+
}) => {
34+
const runner = createRunner(__dirname)
35+
.expect(envelope => {
36+
const transactionEvent = envelope[1]?.[0]?.[1];
37+
expect(transactionEvent).toEqual(
38+
expect.objectContaining({
39+
transaction: 'sayHello',
40+
}),
41+
);
42+
})
43+
.expect(envelope => {
44+
const transactionEvent = envelope[1]?.[0]?.[1];
45+
expect(transactionEvent).toEqual(
46+
expect.objectContaining({
47+
transaction: 'sayHello',
48+
}),
49+
);
50+
})
51+
.unordered()
52+
.start(signal);
53+
54+
// First request - this always worked
55+
const response1 = await runner.makeRequest<string>('get', '/hello');
56+
expect(response1).toBe('Hello, world');
57+
58+
// Second consecutive request - this used to fail with:
59+
// "The RPC receiver does not implement the method 'sayHello'"
60+
const response2 = await runner.makeRequest<string>('get', '/hello');
61+
expect(response2).toBe('Hello, world');
62+
63+
await runner.completed();
64+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject } 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+
export default Sentry.withSentry(
27+
(env: Env) => ({
28+
dsn: env.SENTRY_DSN,
29+
tracesSampleRate: 1.0,
30+
}),
31+
{
32+
async fetch(request, env) {
33+
const url = new URL(request.url);
34+
const id = env.MY_DURABLE_OBJECT.idFromName('test');
35+
const stub = env.MY_DURABLE_OBJECT.get(id);
36+
37+
if (url.pathname === '/rpc/hello') {
38+
const result = await stub.sayHello('World');
39+
return new Response(result);
40+
}
41+
42+
return new Response('Not found', { status: 404 });
43+
},
44+
} satisfies ExportedHandler<Env>,
45+
);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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', async ({ signal }) => {
6+
let receivedTransactions: string[] = [];
7+
8+
const runner = createRunner(__dirname)
9+
.expect(envelope => {
10+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
11+
12+
// Should only receive the worker HTTP transaction, not the DO RPC transaction
13+
expect(transactionEvent).toEqual(
14+
expect.objectContaining({
15+
contexts: expect.objectContaining({
16+
trace: expect.objectContaining({
17+
op: 'http.server',
18+
data: expect.objectContaining({
19+
'sentry.origin': 'auto.http.cloudflare',
20+
}),
21+
origin: 'auto.http.cloudflare',
22+
}),
23+
}),
24+
transaction: 'GET /rpc/hello',
25+
}),
26+
);
27+
receivedTransactions.push(transactionEvent.transaction as string);
28+
})
29+
.start(signal);
30+
31+
// The RPC call should still work, just not be instrumented
32+
const response = await runner.makeRequest<string>('get', '/rpc/hello');
33+
expect(response).toBe('Hello, World!');
34+
35+
await runner.completed();
36+
37+
// Verify we only got the worker transaction, no RPC transaction
38+
expect(receivedTransactions).toEqual(['GET /rpc/hello']);
39+
expect(receivedTransactions).not.toContain('sayHello');
40+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "cloudflare-worker-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: 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 } 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+
async multiply(a: number, b: number): Promise<number> {
16+
return a * b;
17+
}
18+
}
19+
20+
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
21+
(env: Env) => ({
22+
dsn: env.SENTRY_DSN,
23+
tracesSampleRate: 1.0,
24+
enableRpcTracePropagation: true,
25+
}),
26+
MyDurableObjectBase,
27+
);
28+
29+
export default Sentry.withSentry(
30+
(env: Env) => ({
31+
dsn: env.SENTRY_DSN,
32+
tracesSampleRate: 1.0,
33+
enableRpcTracePropagation: true,
34+
}),
35+
{
36+
async fetch(request, env) {
37+
const url = new URL(request.url);
38+
const id = env.MY_DURABLE_OBJECT.idFromName('test');
39+
const stub = env.MY_DURABLE_OBJECT.get(id);
40+
41+
if (url.pathname === '/rpc/hello') {
42+
const result = await stub.sayHello('World');
43+
return new Response(result);
44+
}
45+
46+
if (url.pathname === '/rpc/multiply') {
47+
const result = await stub.multiply(6, 7);
48+
return new Response(String(result));
49+
}
50+
51+
return new Response('Not found', { status: 404 });
52+
},
53+
} satisfies ExportedHandler<Env>,
54+
);
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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 to durable object via RPC method call', async ({ signal }) => {
6+
let workerTraceId: string | undefined;
7+
let workerSpanId: string | undefined;
8+
let doTraceId: string | undefined;
9+
let doParentSpanId: string | undefined;
10+
11+
const runner = createRunner(__dirname)
12+
.expect(envelope => {
13+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
14+
15+
expect(transactionEvent).toEqual(
16+
expect.objectContaining({
17+
contexts: expect.objectContaining({
18+
trace: expect.objectContaining({
19+
op: 'rpc',
20+
data: expect.objectContaining({
21+
'sentry.origin': 'auto.faas.cloudflare.durable_object',
22+
}),
23+
origin: 'auto.faas.cloudflare.durable_object',
24+
}),
25+
}),
26+
transaction: 'sayHello',
27+
}),
28+
);
29+
doTraceId = transactionEvent.contexts?.trace?.trace_id as string;
30+
doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string;
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+
data: expect.objectContaining({
41+
'sentry.origin': 'auto.http.cloudflare',
42+
}),
43+
origin: 'auto.http.cloudflare',
44+
}),
45+
}),
46+
transaction: 'GET /rpc/hello',
47+
}),
48+
);
49+
workerTraceId = transactionEvent.contexts?.trace?.trace_id as string;
50+
workerSpanId = transactionEvent.contexts?.trace?.span_id as string;
51+
})
52+
.unordered()
53+
.start(signal);
54+
55+
const response = await runner.makeRequest<string>('get', '/rpc/hello');
56+
expect(response).toBe('Hello, World!');
57+
58+
await runner.completed();
59+
60+
expect(workerTraceId).toBeDefined();
61+
expect(doTraceId).toBeDefined();
62+
expect(workerTraceId).toBe(doTraceId);
63+
64+
expect(workerSpanId).toBeDefined();
65+
expect(doParentSpanId).toBeDefined();
66+
expect(doParentSpanId).toBe(workerSpanId);
67+
});
68+
69+
it('propagates trace for RPC method with multiple arguments', async ({ signal }) => {
70+
let workerTraceId: string | undefined;
71+
let workerSpanId: string | undefined;
72+
let doTraceId: string | undefined;
73+
let doParentSpanId: string | undefined;
74+
75+
const runner = createRunner(__dirname)
76+
.expect(envelope => {
77+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
78+
79+
expect(transactionEvent).toEqual(
80+
expect.objectContaining({
81+
contexts: expect.objectContaining({
82+
trace: expect.objectContaining({
83+
op: 'rpc',
84+
}),
85+
}),
86+
transaction: 'multiply',
87+
}),
88+
);
89+
doTraceId = transactionEvent.contexts?.trace?.trace_id as string;
90+
doParentSpanId = transactionEvent.contexts?.trace?.parent_span_id as string;
91+
})
92+
.expect(envelope => {
93+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
94+
95+
expect(transactionEvent).toEqual(
96+
expect.objectContaining({
97+
contexts: expect.objectContaining({
98+
trace: expect.objectContaining({
99+
op: 'http.server',
100+
}),
101+
}),
102+
transaction: 'GET /rpc/multiply',
103+
}),
104+
);
105+
workerTraceId = transactionEvent.contexts?.trace?.trace_id as string;
106+
workerSpanId = transactionEvent.contexts?.trace?.span_id as string;
107+
})
108+
.unordered()
109+
.start(signal);
110+
111+
const response = await runner.makeRequest<string>('get', '/rpc/multiply');
112+
expect(response).toBe('42');
113+
114+
await runner.completed();
115+
116+
expect(workerTraceId).toBeDefined();
117+
expect(doTraceId).toBeDefined();
118+
expect(workerTraceId).toBe(doTraceId);
119+
120+
expect(workerSpanId).toBeDefined();
121+
expect(doParentSpanId).toBeDefined();
122+
expect(doParentSpanId).toBe(workerSpanId);
123+
});

0 commit comments

Comments
 (0)