Skip to content

Commit a641812

Browse files
authored
feat(cloudflare): Propagate traceparent to RPC calls - via fetch (#19991)
relates to #19327 related to #16898 (it is not really closing it as we just add context propagation without adding spans for individual calls. It needs to be defined if we need it) It is important to know that these RPC calls do only work with the `.fetch` call: ```js const id = env.MY_DURABLE_OBJECT.idFromName('workflow-test'); const stub = env.MY_DURABLE_OBJECT.get(id); await stub.fetch(new Request('http://my-worker/my-do-call')); ``` This adds RPC fetch calls between: - Workers -> Workers ([Service bindings](https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/rpc/)) - Workers -> DurableObjects (via [standard RPC](https://developers.cloudflare.com/workers/runtime-apis/rpc/)) - Workflows -> DurableObjects (also via standard RPC) This works by instrumenting `env` (via `instrumentEnv`), which then goes over the bindings and see if there is a DurableObject or a normal Fetcher (full list of current bindings: https://developers.cloudflare.com/workers/runtime-apis/bindings/). This got inspired by how `otel-cf-workers` instruments their env: https://github.com/evanderkoogh/otel-cf-workers/blob/effeb549f0a4ed1c55ea0c4f0d8e8e37e5494fb3/src/instrumentation/env.ts With this PR I added a lot of tests to check if trace propagation works (so this PR might look like it added a lot of LoC, but it is mostly tests). So I added it for `schedule` and `queue`, but it is not possible for `email` and `tail` with `wrangler dev`. ## Potential things to change ### Trace propagagtion I added the `addTraceHeaders.ts` helper, as there is currently no way to reuse the existing logic (it is baked-in into the fetch instrumentations). It would be nice once #19960 lands that we can reuse it in Cloudflare to reuse existing code. I tried to write couple of tests so we don't have duplicated headers. ### Adding extra spans So there is actually a guide by OTel to [add RPC spans](https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/), but was talking with someone from the OTel maintainers and they meant that this wouldn't be necessary as we already have an `http.server` span from out instrumented DurableObjects (and other resources) - so it wouldn't add much of information. Without RPC span: <img width="451" height="130" alt="Screenshot 2026-03-25 at 10 59 01" src="https://github.com/user-attachments/assets/dc280b38-0879-4306-8d61-7fcc5e9cacc0" /> With RPC span: <img width="433" height="170" alt="Screenshot 2026-03-25 at 10 55 48" src="https://github.com/user-attachments/assets/e30fa84b-068a-4eca-aa06-5668f3f0081c" />
1 parent 6b3b09b commit a641812

31 files changed

+1771
-4
lines changed

dev-packages/cloudflare-integration-tests/runner.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,12 +68,17 @@ export function createRunner(...paths: string[]) {
6868
// By default, we ignore session & sessions
6969
const ignored: Set<EnvelopeItemType> = new Set(['session', 'sessions', 'client_report']);
7070
let serverUrl: string | undefined;
71+
const extraWranglerArgs: string[] = [];
7172

7273
return {
7374
withServerUrl: function (url: string) {
7475
serverUrl = url;
7576
return this;
7677
},
78+
withWranglerArgs: function (...args: string[]) {
79+
extraWranglerArgs.push(...args);
80+
return this;
81+
},
7782
expect: function (expected: Expected) {
7883
expectedEnvelopes.push(expected);
7984
return this;
@@ -237,6 +242,7 @@ export function createRunner(...paths: string[]) {
237242
`SENTRY_DSN:http://public@localhost:${mockServerPort}/1337`,
238243
'--var',
239244
`SERVER_URL:${serverUrl}`,
245+
...extraWranglerArgs,
240246
],
241247
{ stdio, signal },
242248
);
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { instrumentDurableObjectWithSentry, withSentry } from '@sentry/cloudflare';
2+
import { DurableObject } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
ECHO_HEADERS_DO: DurableObjectNamespace;
7+
}
8+
9+
class EchoHeadersDurableObjectBase extends DurableObject<Env> {
10+
async fetch(incoming: Request): Promise<Response> {
11+
return Response.json({
12+
sentryTrace: incoming.headers.get('sentry-trace'),
13+
baggage: incoming.headers.get('baggage'),
14+
authorization: incoming.headers.get('authorization'),
15+
xFromInit: incoming.headers.get('x-from-init'),
16+
xExtra: incoming.headers.get('x-extra'),
17+
xMergeProbe: incoming.headers.get('x-merge-probe'),
18+
});
19+
}
20+
}
21+
22+
export const EchoHeadersDurableObject = instrumentDurableObjectWithSentry(
23+
(env: Env) => ({
24+
dsn: env.SENTRY_DSN,
25+
tracesSampleRate: 1.0,
26+
}),
27+
EchoHeadersDurableObjectBase,
28+
);
29+
30+
export default withSentry(
31+
(env: Env) => ({
32+
dsn: env.SENTRY_DSN,
33+
tracesSampleRate: 1.0,
34+
}),
35+
{
36+
async fetch(request, env) {
37+
const url = new URL(request.url);
38+
const id = env.ECHO_HEADERS_DO.idFromName('instrument-fetcher-echo');
39+
const stub = env.ECHO_HEADERS_DO.get(id);
40+
const doUrl = new URL(request.url);
41+
42+
let subResponse: Response;
43+
44+
if (url.pathname === '/via-init') {
45+
subResponse = await stub.fetch(doUrl, {
46+
headers: {
47+
Authorization: 'Bearer from-init',
48+
'X-Extra': 'init-extra',
49+
'X-Merge-Probe': 'via-init-probe',
50+
},
51+
});
52+
} else if (url.pathname === '/via-request') {
53+
subResponse = await stub.fetch(
54+
new Request(doUrl, {
55+
headers: {
56+
Authorization: 'Bearer from-request',
57+
'X-Extra': 'request-extra',
58+
'X-Merge-Probe': 'via-request-probe',
59+
},
60+
}),
61+
);
62+
} else if (url.pathname === '/via-request-and-init') {
63+
subResponse = await stub.fetch(
64+
new Request(doUrl, {
65+
headers: {
66+
Authorization: 'Bearer from-request',
67+
'X-Extra': 'request-extra',
68+
'X-Merge-Probe': 'dropped-from-request',
69+
},
70+
}),
71+
{
72+
headers: {
73+
'X-From-Init': '1',
74+
'X-Merge-Probe': 'via-init-wins',
75+
},
76+
},
77+
);
78+
} else if (url.pathname === '/with-preset-sentry-baggage') {
79+
subResponse = await stub.fetch(
80+
new Request(doUrl, {
81+
headers: {
82+
baggage: 'sentry-environment=preset,acme=vendor',
83+
},
84+
}),
85+
);
86+
} else {
87+
return new Response('not found', { status: 404 });
88+
}
89+
90+
const payload: unknown = await subResponse.json();
91+
return Response.json(payload);
92+
},
93+
} satisfies ExportedHandler<Env>,
94+
);
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { expect, it } from 'vitest';
2+
import type { Event } from '@sentry/core';
3+
import { createRunner } from '../../../runner';
4+
5+
type EchoedHeaders = {
6+
sentryTrace: string | null;
7+
baggage: string | null;
8+
authorization: string | null;
9+
xFromInit: string | null;
10+
xExtra: string | null;
11+
xMergeProbe: string | null;
12+
};
13+
14+
const SENTRY_TRACE_HEADER_RE = /^[0-9a-f]{32}-[0-9a-f]{16}-[01]$/;
15+
16+
type ScenarioPath = '/via-init' | '/via-request' | '/via-request-and-init' | '/with-preset-sentry-baggage';
17+
18+
function startStubFetchScenario(path: ScenarioPath, signal: AbortSignal) {
19+
let mainTraceId: string | undefined;
20+
let mainSpanId: string | undefined;
21+
let doTraceId: string | undefined;
22+
let doParentSpanId: string | undefined;
23+
24+
const traceBase = {
25+
op: 'http.server',
26+
data: expect.objectContaining({
27+
'sentry.origin': 'auto.http.cloudflare',
28+
}),
29+
origin: 'auto.http.cloudflare',
30+
};
31+
32+
const { makeRequest, completed } = createRunner(__dirname)
33+
.expect(envelope => {
34+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
35+
const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id;
36+
37+
expect(transactionEvent).toEqual(
38+
expect.objectContaining({
39+
contexts: expect.objectContaining({
40+
trace: expect.objectContaining(traceBase),
41+
}),
42+
transaction: `GET ${path}`,
43+
}),
44+
);
45+
expect(parentSpanId).toBeUndefined();
46+
47+
mainTraceId = transactionEvent.contexts?.trace?.trace_id as string;
48+
mainSpanId = transactionEvent.contexts?.trace?.span_id as string;
49+
})
50+
.expect(envelope => {
51+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
52+
const parentSpanId = transactionEvent.contexts?.trace?.parent_span_id;
53+
54+
expect(transactionEvent).toEqual(
55+
expect.objectContaining({
56+
contexts: expect.objectContaining({
57+
trace: expect.objectContaining(traceBase),
58+
}),
59+
transaction: `GET ${path}`,
60+
}),
61+
);
62+
expect(parentSpanId).toBeDefined();
63+
64+
doTraceId = transactionEvent.contexts?.trace?.trace_id as string;
65+
doParentSpanId = parentSpanId as string;
66+
})
67+
.unordered()
68+
.start(signal);
69+
70+
return {
71+
makeRequest,
72+
async completedWithTraceCheck(): Promise<void> {
73+
await completed();
74+
expect(mainTraceId).toBeDefined();
75+
expect(doTraceId).toBeDefined();
76+
expect(mainTraceId).toBe(doTraceId);
77+
expect(mainSpanId).toBeDefined();
78+
expect(doParentSpanId).toBeDefined();
79+
expect(doParentSpanId).toBe(mainSpanId);
80+
},
81+
};
82+
}
83+
84+
it('stub.fetch: headers in init (URL string + init)', async ({ signal }) => {
85+
const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-init', signal);
86+
const body = await makeRequest<EchoedHeaders>('get', '/via-init');
87+
await completedWithTraceCheck();
88+
89+
expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE));
90+
expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id=');
91+
expect(body?.authorization).toBe('Bearer from-init');
92+
expect(body?.xExtra).toBe('init-extra');
93+
expect(body?.xMergeProbe).toBe('via-init-probe');
94+
expect(body?.xFromInit).toBeNull();
95+
});
96+
97+
it('stub.fetch: headers on Request (URL from incoming request)', async ({ signal }) => {
98+
const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request', signal);
99+
const body = await makeRequest<EchoedHeaders>('get', '/via-request');
100+
await completedWithTraceCheck();
101+
102+
expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE));
103+
expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id=');
104+
expect(body?.authorization).toBe('Bearer from-request');
105+
expect(body?.xExtra).toBe('request-extra');
106+
expect(body?.xMergeProbe).toBe('via-request-probe');
107+
expect(body?.xFromInit).toBeNull();
108+
});
109+
110+
it('stub.fetch: Request + init — only init headers are sent', async ({ signal }) => {
111+
const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/via-request-and-init', signal);
112+
const body = await makeRequest<EchoedHeaders>('get', '/via-request-and-init');
113+
await completedWithTraceCheck();
114+
115+
expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE));
116+
expect(body?.baggage).toContain('sentry-environment=production,sentry-public_key=public,sentry-trace_id=');
117+
expect(body?.authorization).toBeNull();
118+
expect(body?.xExtra).toBeNull();
119+
expect(body?.xMergeProbe).toBe('via-init-wins');
120+
expect(body?.xFromInit).toBe('1');
121+
});
122+
123+
it('stub.fetch: does not append SDK baggage when the Request already includes Sentry baggage', async ({ signal }) => {
124+
const { makeRequest, completedWithTraceCheck } = startStubFetchScenario('/with-preset-sentry-baggage', signal);
125+
const body = await makeRequest<EchoedHeaders>('get', '/with-preset-sentry-baggage');
126+
await completedWithTraceCheck();
127+
128+
expect(body?.sentryTrace).toEqual(expect.stringMatching(SENTRY_TRACE_HEADER_RE));
129+
// Dynamic SDK baggage includes `sentry-trace_id=…`; appending it again would change this string.
130+
expect(body?.baggage).toBe('sentry-environment=preset,acme=vendor');
131+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "cloudflare-instrument-fetcher",
3+
"main": "index.ts",
4+
"compatibility_date": "2025-06-17",
5+
"compatibility_flags": ["nodejs_als"],
6+
"migrations": [
7+
{
8+
"new_sqlite_classes": ["EchoHeadersDurableObject"],
9+
"tag": "v1",
10+
},
11+
],
12+
"durable_objects": {
13+
"bindings": [
14+
{
15+
"class_name": "EchoHeadersDurableObject",
16+
"name": "ECHO_HEADERS_DO",
17+
},
18+
],
19+
},
20+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import * as Sentry from '@sentry/cloudflare';
2+
import { DurableObject } from 'cloudflare:workers';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
MY_DURABLE_OBJECT: DurableObjectNamespace;
7+
MY_QUEUE: Queue;
8+
}
9+
10+
class MyDurableObjectBase extends DurableObject<Env> {
11+
async fetch(request: Request) {
12+
return new Response('DO is fine');
13+
}
14+
}
15+
16+
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
17+
(env: Env) => ({
18+
dsn: env.SENTRY_DSN,
19+
tracesSampleRate: 1.0,
20+
}),
21+
MyDurableObjectBase,
22+
);
23+
24+
export default Sentry.withSentry(
25+
(env: Env) => ({
26+
dsn: env.SENTRY_DSN,
27+
tracesSampleRate: 1.0,
28+
}),
29+
{
30+
async fetch(request, env) {
31+
const url = new URL(request.url);
32+
33+
if (url.pathname === '/queue/send') {
34+
await env.MY_QUEUE.send({ action: 'test' });
35+
return new Response('Queued');
36+
}
37+
38+
const id = env.MY_DURABLE_OBJECT.idFromName('test');
39+
const stub = env.MY_DURABLE_OBJECT.get(id);
40+
const response = await stub.fetch(new Request('http://fake-host/hello'));
41+
const text = await response.text();
42+
return new Response(text);
43+
},
44+
45+
async queue(batch, env, _ctx) {
46+
const id = env.MY_DURABLE_OBJECT.idFromName('test');
47+
const stub = env.MY_DURABLE_OBJECT.get(id);
48+
for (const message of batch.messages) {
49+
await stub.fetch(new Request('http://fake-host/hello'));
50+
message.ack();
51+
}
52+
},
53+
54+
async scheduled(controller, env, _ctx) {
55+
const id = env.MY_DURABLE_OBJECT.idFromName('test');
56+
const stub = env.MY_DURABLE_OBJECT.get(id);
57+
await stub.fetch(new Request('http://fake-host/hello'));
58+
},
59+
} satisfies ExportedHandler<Env>,
60+
);

0 commit comments

Comments
 (0)