Skip to content

Commit ecf53cc

Browse files
JPeer264claude
andcommitted
feat(cloudflare)!: Match rpcTracePropagationBindings case-insensitively
`rpcTracePropagationBindings` matched binding names with `stringMatchesSomePattern`, which compares strings case-sensitively and calls `test()` on the regex as given. A target carrying the `g` or `y` flag is stateful through `lastIndex`, so it matched only every other binding lookup. The bindings now go through `matchesTracePropagationTargets`, the same matcher `tracePropagationTargets` uses since #23534. It lower-cases both sides and drops the `g`/`y` flags before testing. `matchesTracePropagationTargets` gained a `requireExactStringMatch` parameter for this, named after the same parameter on `isMatchingPattern`. Binding names need an exact string match, otherwise an entry of `DB` would also enable propagation for `MY_DB`. The new integration suite lists the bindings as `'my_durable_object'` and `/^svc_/g` while they are named `MY_DURABLE_OBJECT`, `SVC_ALPHA` and `SVC_BETA`. `SVC_BETA` is the binding a stateful `g` regex drops, because matching `SVC_ALPHA` already moved its `lastIndex` past the start of the string. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 29752e8 commit ecf53cc

9 files changed

Lines changed: 179 additions & 8 deletions

File tree

MIGRATION.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1200,7 +1200,7 @@ Sentry.httpIntegration({
12001200
);
12011201
```
12021202

1203-
- The `enableRpcTracePropagation` option was removed. Trace context is no longer appended to every RPC call on `env`. List the bindings you call in `rpcTracePropagationBindings` instead. Strings match a binding name exactly, regular expressions match by pattern. The option covers RPC method calls only, because they carry the trace context as a trailing argument that a non-Sentry receiver would see as a real argument. `stub.fetch()` and service binding `fetch()` carry it in HTTP headers, so they propagate regardless of this option. Receivers no longer take the option at all: an instrumented Durable Object or WorkerEntrypoint reads the trace context whenever a caller sends it.
1203+
- The `enableRpcTracePropagation` option was removed. Trace context is no longer appended to every RPC call on `env`. List the bindings you call in `rpcTracePropagationBindings` instead. Strings match a binding name exactly, regular expressions match by pattern, and both match case-insensitively. The option covers RPC method calls only, because they carry the trace context as a trailing argument that a non-Sentry receiver would see as a real argument. `stub.fetch()` and service binding `fetch()` carry it in HTTP headers, so they propagate regardless of this option. Receivers no longer take the option at all: an instrumented Durable Object or WorkerEntrypoint reads the trace context whenever a caller sends it.
12041204

12051205
```diff
12061206
export default Sentry.withSentry(
@@ -1213,6 +1213,8 @@ Sentry.httpIntegration({
12131213
);
12141214
```
12151215

1216+
`rpcTracePropagationBindings` follows the matching rules `tracePropagationTargets` has in v11: casing does not matter on either side, and the `g` and `y` flags are ignored on regular expressions, because they made matching stateful via `lastIndex`. The one difference is that a string target has to equal the whole binding name, so `'DB'` does not cover a binding named `MY_DB`.
1217+
12161218
- The `instrumentPrototypeMethods` option of `instrumentDurableObjectWithSentry` was removed. A Durable Object's prototype methods are now wrapped unconditionally, so every RPC method is instrumented and there is no longer an option to turn this on. Delete the option from your config.
12171219

12181220
```diff
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<MyDurableObjectBase>;
7+
SVC_ALPHA: DurableObjectNamespace<MyDurableObjectBase>;
8+
SVC_BETA: DurableObjectNamespace<MyDurableObjectBase>;
9+
}
10+
11+
class MyDurableObjectBase extends DurableObject<Env> {
12+
async sayHello(name: string): Promise<string> {
13+
return `Hello, ${name}!`;
14+
}
15+
16+
async alpha(): Promise<string> {
17+
return 'alpha';
18+
}
19+
20+
async beta(): Promise<string> {
21+
return 'beta';
22+
}
23+
}
24+
25+
export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry(
26+
(env: Env) => ({
27+
dsn: env.SENTRY_DSN,
28+
traceLifecycle: 'static',
29+
tracesSampleRate: 1.0,
30+
}),
31+
MyDurableObjectBase,
32+
);
33+
34+
export default Sentry.withSentry(
35+
(env: Env) => ({
36+
dsn: env.SENTRY_DSN,
37+
traceLifecycle: 'static',
38+
tracesSampleRate: 1.0,
39+
// Both targets are written in a casing the bindings do not use, and the regex carries the `g`
40+
// flag, which makes `test()` stateful unless the SDK normalizes it away.
41+
rpcTracePropagationBindings: ['my_durable_object', /^svc_/g],
42+
}),
43+
{
44+
async fetch(request, env) {
45+
const url = new URL(request.url);
46+
47+
if (url.pathname === '/rpc/all') {
48+
const results = [
49+
await env.MY_DURABLE_OBJECT.get(env.MY_DURABLE_OBJECT.idFromName('test')).sayHello('World'),
50+
await env.SVC_ALPHA.get(env.SVC_ALPHA.idFromName('test')).alpha(),
51+
await env.SVC_BETA.get(env.SVC_BETA.idFromName('test')).beta(),
52+
];
53+
54+
return new Response(results.join(','));
55+
}
56+
57+
return new Response('Not found', { status: 404 });
58+
},
59+
} satisfies ExportedHandler<Env>,
60+
);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect, it } from 'vitest';
2+
import type { Envelope, Event } from '@sentry/core';
3+
import { createRunner } from '../../../../runner';
4+
5+
it('propagates trace over RPC when the binding casing differs from rpcTracePropagationBindings', async ({ signal }) => {
6+
const transactionsByName = new Map<string, Event>();
7+
8+
const collect = (envelope: Envelope): void => {
9+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
10+
transactionsByName.set(transactionEvent.transaction as string, transactionEvent);
11+
};
12+
13+
const runner = createRunner(__dirname)
14+
.expect(collect)
15+
.expect(collect)
16+
.expect(collect)
17+
.expect(collect)
18+
.unordered()
19+
.start(signal);
20+
21+
const response = await runner.makeRequest<string>('get', '/rpc/all');
22+
expect(response).toBe('Hello, World!,alpha,beta');
23+
24+
await runner.completed();
25+
26+
const worker = transactionsByName.get('GET /rpc/all');
27+
expect(worker?.contexts?.trace?.op).toBe('http.server');
28+
29+
// `sayHello` comes from the string target, `alpha` and `beta` from the regex target. `beta` is the
30+
// one a stateful `g` regex would miss, because `alpha` already advanced its `lastIndex`.
31+
for (const methodName of ['sayHello', 'alpha', 'beta']) {
32+
const durableObject = transactionsByName.get(methodName);
33+
34+
expect(durableObject?.contexts?.trace?.op).toBe('rpc');
35+
expect(durableObject?.contexts?.trace?.trace_id).toBe(worker?.contexts?.trace?.trace_id);
36+
expect(durableObject?.contexts?.trace?.parent_span_id).toBe(worker?.contexts?.trace?.span_id);
37+
}
38+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "cloudflare-worker-do-rpc-binding-casing",
3+
"main": "index.ts",
4+
"compatibility_date": "2025-06-17",
5+
"compatibility_flags": ["nodejs_compat"],
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+
"class_name": "MyDurableObject",
20+
"name": "SVC_ALPHA",
21+
},
22+
{
23+
"class_name": "MyDurableObject",
24+
"name": "SVC_BETA",
25+
},
26+
],
27+
},
28+
}

packages/cloudflare/src/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,9 @@ interface BaseCloudflareOptions {
336336
* Propagation over `stub.fetch()` and service binding `fetch()` uses HTTP headers and is not
337337
* affected by this option.
338338
*
339+
* Strings match a binding name exactly, regular expressions match by pattern. Both match
340+
* case-insensitively.
341+
*
339342
* When you build with the Sentry Cloudflare Vite plugin, bindings that resolve to *this* worker
340343
* (its own Durable Objects, its self service bindings) are added for you, because the plugin
341344
* instruments those receivers itself. Whatever you list here is added on top of them.

packages/cloudflare/src/utils/rpcPropagation.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { stringMatchesSomePattern } from '@sentry/core';
1+
import { matchesTracePropagationTargets } from '@sentry/core';
22
import type { CloudflareOptions } from '../client';
33

44
const PROPAGATE_TO_NONE = () => false;
@@ -19,5 +19,5 @@ export function createRpcPropagationResolver(options: CloudflareOptions | undefi
1919

2020
// Strings must match a binding name exactly, without this, an entry of `DB` would also enable
2121
// propagation for a binding named `MY_DB`. Regular expressions still give pattern matching.
22-
return (bindingName: string) => stringMatchesSomePattern(bindingName, bindings, true);
22+
return (bindingName: string) => matchesTracePropagationTargets(bindingName, bindings, true);
2323
}

packages/cloudflare/test/utils/rpcPropagation.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,19 @@ describe('createRpcPropagationResolver', () => {
4545
expect(shouldPropagate('ORDERS')).toBe(false);
4646
expect(shouldPropagate('PREFIXED_SVC_ORDERS')).toBe(false);
4747
});
48+
49+
it('matches binding names case-insensitively', () => {
50+
const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: ['my_do', /^svc_/] });
51+
52+
expect(shouldPropagate('MY_DO')).toBe(true);
53+
expect(shouldPropagate('SVC_ORDERS')).toBe(true);
54+
expect(shouldPropagate('OTHER')).toBe(false);
55+
});
56+
57+
it('matches consistently across calls for a regular expression with the `g` flag', () => {
58+
const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: [/^SVC_/g] });
59+
60+
expect(shouldPropagate('SVC_ORDERS')).toBe(true);
61+
expect(shouldPropagate('SVC_USERS')).toBe(true);
62+
});
4863
});

packages/core/src/utils/tracePropagationTargets.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,28 @@ function normalizeRegExpTarget(pattern: RegExp): RegExp {
3333
}
3434

3535
/**
36-
* Check if a URL matches any of the given `tracePropagationTargets`.
36+
* Check if a value matches any of the given `tracePropagationTargets`.
37+
*
38+
* The value is usually a URL, but it can be anything a propagation decision is made on, such as a
39+
* Cloudflare binding name. String targets match as a substring unless `requireExactStringMatch` is set.
3740
*
3841
* Matching is case-insensitive: URL normalization (e.g. `new URL()`) lower-cases the origin, so a target
3942
* written with the same casing as the request (`'myApi.com'`, `/^myApi\.com/`) would otherwise never match.
4043
*/
41-
export function matchesTracePropagationTargets(url: string, tracePropagationTargets: TracePropagationTargets): boolean {
42-
const lowerCaseUrl = url.toLowerCase();
44+
export function matchesTracePropagationTargets(
45+
value: string,
46+
tracePropagationTargets: TracePropagationTargets,
47+
requireExactStringMatch: boolean = false,
48+
): boolean {
49+
const lowerCaseValue = value.toLowerCase();
4350

4451
for (const target of tracePropagationTargets) {
4552
if (isString(target)) {
46-
if (lowerCaseUrl.includes(target.toLowerCase())) {
53+
const lowerCaseTarget = target.toLowerCase();
54+
if (requireExactStringMatch ? lowerCaseValue === lowerCaseTarget : lowerCaseValue.includes(lowerCaseTarget)) {
4755
return true;
4856
}
49-
} else if (isRegExp(target) && normalizeRegExpTarget(target).test(url)) {
57+
} else if (isRegExp(target) && normalizeRegExpTarget(target).test(value)) {
5058
return true;
5159
}
5260
}

packages/core/test/lib/utils/tracePropagationTargets.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,23 @@ describe('matchesTracePropagationTargets', () => {
7676
expect(matchesTracePropagationTargets('https://myapi.com/v1', [target])).toBe(true);
7777
expect(matchesTracePropagationTargets('https://myapi.com/v2', [target])).toBe(true);
7878
});
79+
80+
describe('with requireExactStringMatch', () => {
81+
it.each([
82+
['MY_DO', ['MY_DO'], true],
83+
['MY_DO', ['my_do'], true],
84+
['my_do', ['MY_DO'], true],
85+
['MY_DB', ['DB'], false],
86+
['DB_REPLICA', ['DB'], false],
87+
])('for value %j and string targets %j returns %j', (value, targets, expected) => {
88+
expect(matchesTracePropagationTargets(value, targets, true)).toBe(expected);
89+
});
90+
91+
it('leaves regex targets matching by pattern', () => {
92+
expect(matchesTracePropagationTargets('SVC_ORDERS', [/^svc_/], true)).toBe(true);
93+
expect(matchesTracePropagationTargets('ORDERS', [/^SVC_/], true)).toBe(false);
94+
});
95+
});
7996
});
8097

8198
describe('shouldPropagateTraceForUrl', () => {

0 commit comments

Comments
 (0)