Skip to content

Commit de706ed

Browse files
authored
feat(cloudflare): Add trace propagation for RPC method calls (#20343)
closes #19327 closes [JS-1715](https://linear.app/getsentry/issue/JS-1715/sentrycloudflare-durable-object-rpc-spans-are-not-linked-to-worker) closes #16898 closes [JS-680](https://linear.app/getsentry/issue/JS-680/instrument-cloudflare-worker-rpc-methods) closes #16760 closes [JS-622](https://linear.app/getsentry/issue/JS-622/trace-propagation-in-cloudflare-workers-not-working-as-expected) ## Summary > Most of the additions are tests, the main implementation is rather small Adds trace propagation for Cloudflare Workers RPC method calls to Durable Objects. This is admittedly a bit of a hack: [Cap'n Proto](https://capnproto.org/) ([which powers Cloudflare RPC](https://blog.cloudflare.com/capnweb-javascript-rpc-library/)) has no native support for headers or metadata. To work around this, we append our trace data (sentry-trace + baggage) as a trailing argument object `{ __sentry: { trace, baggage } }` to every RPC call. On the receiving DO side, we strip this argument before the user's method is invoked, so it's completely transparent. **Caveat:** If the Durable Object is not instrumented with Sentry, the trailing `__sentry` argument will remain in the args array and be passed to the user's method. I would count this as ok since: - Users opting into RPC instrumentation are expected to instrument both sides - The extra argument is easy to ignore in most cases, unless users use `...args` to retrieve all arguments Otherwise, trace propagation should be seamless across Worker → DO and Worker → Worker → DO call chains. ### How it works As mentioned above a Sentry trace object is appended on each call ```ts const id = env.MY_DURABLE_OBJECT.idFromName('test'); const stub = env.MY_DURABLE_OBJECT.get(id); // User's RPC call const result = await stub.sayHello('World'); // What is actually sent (transparent to user) const result = await stub.sayHello('World', { __sentry: { trace, baggage } }); ```
1 parent e1c1077 commit de706ed

23 files changed

Lines changed: 1287 additions & 289 deletions

File tree

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

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,46 @@ 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+
// Private field used by RPC methods - tests that private fields work with instrumentation
13+
#greeting = 'Hello';
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+
// RPC method that uses a private field - this would throw TypeError if the Proxy
20+
// doesn't correctly bind `this` to the original object
1521
async sayHello(name: string): Promise<string> {
16-
return `Hello, ${name}`;
22+
return `${this.#greeting}, ${name}`;
23+
}
24+
25+
// RPC method that modifies a private field
26+
async setGreeting(greeting: string): Promise<void> {
27+
this.#greeting = greeting;
28+
}
29+
30+
// Other public methods that are not called - should not interfere with RPC
31+
async getStatus(): Promise<string> {
32+
return 'OK';
33+
}
34+
35+
async processData(data: Record<string, unknown>): Promise<Record<string, unknown>> {
36+
return { ...data, processed: true };
37+
}
38+
39+
async multiply(a: number, b: number): Promise<number> {
40+
return a * b;
1741
}
1842
}
1943

2044
export const TestDurableObject = Sentry.instrumentDurableObjectWithSentry(
2145
(env: Env) => ({
2246
dsn: env.SENTRY_DSN,
2347
tracesSampleRate: 1.0,
24-
instrumentPrototypeMethods: true,
48+
enableRpcTracePropagation: true,
2549
}),
2650
TestDurableObjectBase,
2751
);
@@ -36,6 +60,13 @@ export default {
3660
return new Response(greeting);
3761
}
3862

63+
// Test endpoint that modifies and reads a private field via RPC
64+
if (request.url.includes('custom-greeting')) {
65+
await stub.setGreeting('Howdy');
66+
const greeting = await stub.sayHello('partner');
67+
return new Response(greeting);
68+
}
69+
3970
return new Response('Usual response');
4071
},
4172
};

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect, it } from 'vitest';
2+
import type { Event } from '@sentry/core';
23
import { createRunner } from '../../../runner';
34

45
it('traces a durable object method', async ({ signal }) => {
@@ -25,3 +26,74 @@ it('traces a durable object method', async ({ signal }) => {
2526
await runner.makeRequest('get', '/hello');
2627
await runner.completed();
2728
});
29+
30+
// Regression test for https://github.com/getsentry/sentry-javascript/issues/17127
31+
// The RPC receiver does not implement the method error on consecutive calls
32+
it('handles consecutive RPC calls without throwing "RPC receiver does not implement method" error', async ({
33+
signal,
34+
}) => {
35+
const runner = createRunner(__dirname)
36+
.expect(envelope => {
37+
const transactionEvent = envelope[1]?.[0]?.[1];
38+
expect(transactionEvent).toEqual(
39+
expect.objectContaining({
40+
transaction: 'sayHello',
41+
}),
42+
);
43+
})
44+
.expect(envelope => {
45+
const transactionEvent = envelope[1]?.[0]?.[1];
46+
expect(transactionEvent).toEqual(
47+
expect.objectContaining({
48+
transaction: 'sayHello',
49+
}),
50+
);
51+
})
52+
.unordered()
53+
.start(signal);
54+
55+
// First request - this always worked
56+
const response1 = await runner.makeRequest<string>('get', '/hello');
57+
expect(response1).toBe('Hello, world');
58+
59+
// Second consecutive request - this used to fail with:
60+
// "The RPC receiver does not implement the method 'sayHello'"
61+
const response2 = await runner.makeRequest<string>('get', '/hello');
62+
expect(response2).toBe('Hello, world');
63+
64+
await runner.completed();
65+
});
66+
67+
// Regression test: RPC methods that access private fields should work correctly.
68+
// When enableRpcTracePropagation wraps the DO in a Proxy, calling methods through
69+
// the Proxy must ensure `this` refers to the original object (not the Proxy),
70+
// otherwise private field access throws: "Cannot read private member from an object
71+
// whose class did not declare it"
72+
it('allows RPC methods to access private class fields', async ({ signal }) => {
73+
const runner = createRunner(__dirname)
74+
.expect(envelope => {
75+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
76+
expect(transactionEvent).toEqual(
77+
expect.objectContaining({
78+
transaction: 'setGreeting',
79+
}),
80+
);
81+
})
82+
.expect(envelope => {
83+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
84+
expect(transactionEvent).toEqual(
85+
expect.objectContaining({
86+
transaction: 'sayHello',
87+
}),
88+
);
89+
})
90+
.unordered()
91+
.start(signal);
92+
93+
// This calls setGreeting (writes private field) then sayHello (reads private field)
94+
// Would throw TypeError if `this` is the Proxy instead of the original object
95+
const response = await runner.makeRequest<string>('get', '/custom-greeting');
96+
expect(response).toBe('Howdy, partner');
97+
98+
await runner.completed();
99+
});
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+
);

0 commit comments

Comments
 (0)