From 4d6c2e7b7dfa05aa04e845f45ac8cdc9fef242cc Mon Sep 17 00:00:00 2001 From: JPeer264 Date: Wed, 26 Aug 2026 11:45:09 +0300 Subject: [PATCH] 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) --- MIGRATION.md | 4 +- .../worker-do-rpc-binding-casing/index.ts | 60 +++++++++++++++++++ .../worker-do-rpc-binding-casing/test.ts | 38 ++++++++++++ .../wrangler.jsonc | 28 +++++++++ packages/cloudflare/src/client.ts | 3 + .../cloudflare/src/utils/rpcPropagation.ts | 4 +- .../test/utils/rpcPropagation.test.ts | 15 +++++ .../core/src/utils/tracePropagationTargets.ts | 18 ++++-- .../lib/utils/tracePropagationTargets.test.ts | 17 ++++++ 9 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/index.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/test.ts create mode 100644 dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/wrangler.jsonc diff --git a/MIGRATION.md b/MIGRATION.md index 7524ce352448..96a7f506c9b2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1200,7 +1200,7 @@ Sentry.httpIntegration({ ); ``` -- 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. +- 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. ```diff export default Sentry.withSentry( @@ -1213,6 +1213,8 @@ Sentry.httpIntegration({ ); ``` +`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`. + - 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. ```diff diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/index.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/index.ts new file mode 100644 index 000000000000..bff88cbdcdd7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/index.ts @@ -0,0 +1,60 @@ +import * as Sentry from '@sentry/cloudflare'; +import { DurableObject } from 'cloudflare:workers'; + +interface Env { + SENTRY_DSN: string; + MY_DURABLE_OBJECT: DurableObjectNamespace; + SVC_ALPHA: DurableObjectNamespace; + SVC_BETA: DurableObjectNamespace; +} + +class MyDurableObjectBase extends DurableObject { + async sayHello(name: string): Promise { + return `Hello, ${name}!`; + } + + async alpha(): Promise { + return 'alpha'; + } + + async beta(): Promise { + return 'beta'; + } +} + +export const MyDurableObject = Sentry.instrumentDurableObjectWithSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1.0, + }), + MyDurableObjectBase, +); + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + traceLifecycle: 'static', + tracesSampleRate: 1.0, + // Both targets are written in a casing the bindings do not use, and the regex carries the `g` + // flag, which makes `test()` stateful unless the SDK normalizes it away. + rpcTracePropagationBindings: ['my_durable_object', /^svc_/g], + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/rpc/all') { + const results = [ + await env.MY_DURABLE_OBJECT.get(env.MY_DURABLE_OBJECT.idFromName('test')).sayHello('World'), + await env.SVC_ALPHA.get(env.SVC_ALPHA.idFromName('test')).alpha(), + await env.SVC_BETA.get(env.SVC_BETA.idFromName('test')).beta(), + ]; + + return new Response(results.join(',')); + } + + return new Response('Not found', { status: 404 }); + }, + } satisfies ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/test.ts b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/test.ts new file mode 100644 index 000000000000..feb00eae6a1e --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/test.ts @@ -0,0 +1,38 @@ +import { expect, it } from 'vitest'; +import type { Envelope, Event } from '@sentry/core'; +import { createRunner } from '../../../../runner'; + +it('propagates trace over RPC when the binding casing differs from rpcTracePropagationBindings', async ({ signal }) => { + const transactionsByName = new Map(); + + const collect = (envelope: Envelope): void => { + const transactionEvent = envelope[1]?.[0]?.[1] as Event; + transactionsByName.set(transactionEvent.transaction as string, transactionEvent); + }; + + const runner = createRunner(__dirname) + .expect(collect) + .expect(collect) + .expect(collect) + .expect(collect) + .unordered() + .start(signal); + + const response = await runner.makeRequest('get', '/rpc/all'); + expect(response).toBe('Hello, World!,alpha,beta'); + + await runner.completed(); + + const worker = transactionsByName.get('GET /rpc/all'); + expect(worker?.contexts?.trace?.op).toBe('http.server'); + + // `sayHello` comes from the string target, `alpha` and `beta` from the regex target. `beta` is the + // one a stateful `g` regex would miss, because `alpha` already advanced its `lastIndex`. + for (const methodName of ['sayHello', 'alpha', 'beta']) { + const durableObject = transactionsByName.get(methodName); + + expect(durableObject?.contexts?.trace?.op).toBe('rpc'); + expect(durableObject?.contexts?.trace?.trace_id).toBe(worker?.contexts?.trace?.trace_id); + expect(durableObject?.contexts?.trace?.parent_span_id).toBe(worker?.contexts?.trace?.span_id); + } +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/wrangler.jsonc new file mode 100644 index 000000000000..a610921ae3a7 --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-do-rpc-binding-casing/wrangler.jsonc @@ -0,0 +1,28 @@ +{ + "name": "cloudflare-worker-do-rpc-binding-casing", + "main": "index.ts", + "compatibility_date": "2025-06-17", + "compatibility_flags": ["nodejs_compat"], + "migrations": [ + { + "new_sqlite_classes": ["MyDurableObject"], + "tag": "v1", + }, + ], + "durable_objects": { + "bindings": [ + { + "class_name": "MyDurableObject", + "name": "MY_DURABLE_OBJECT", + }, + { + "class_name": "MyDurableObject", + "name": "SVC_ALPHA", + }, + { + "class_name": "MyDurableObject", + "name": "SVC_BETA", + }, + ], + }, +} diff --git a/packages/cloudflare/src/client.ts b/packages/cloudflare/src/client.ts index ea53f22b32c9..291b8c5629df 100644 --- a/packages/cloudflare/src/client.ts +++ b/packages/cloudflare/src/client.ts @@ -336,6 +336,9 @@ interface BaseCloudflareOptions { * Propagation over `stub.fetch()` and service binding `fetch()` uses HTTP headers and is not * affected by this option. * + * Strings match a binding name exactly, regular expressions match by pattern. Both match + * case-insensitively. + * * When you build with the Sentry Cloudflare Vite plugin, bindings that resolve to *this* worker * (its own Durable Objects, its self service bindings) are added for you, because the plugin * instruments those receivers itself. Whatever you list here is added on top of them. diff --git a/packages/cloudflare/src/utils/rpcPropagation.ts b/packages/cloudflare/src/utils/rpcPropagation.ts index 66188640b81f..d914b21032a9 100644 --- a/packages/cloudflare/src/utils/rpcPropagation.ts +++ b/packages/cloudflare/src/utils/rpcPropagation.ts @@ -1,4 +1,4 @@ -import { stringMatchesSomePattern } from '@sentry/core'; +import { matchesTracePropagationTargets } from '@sentry/core'; import type { CloudflareOptions } from '../client'; const PROPAGATE_TO_NONE = () => false; @@ -19,5 +19,5 @@ export function createRpcPropagationResolver(options: CloudflareOptions | undefi // Strings must match a binding name exactly, without this, an entry of `DB` would also enable // propagation for a binding named `MY_DB`. Regular expressions still give pattern matching. - return (bindingName: string) => stringMatchesSomePattern(bindingName, bindings, true); + return (bindingName: string) => matchesTracePropagationTargets(bindingName, bindings, true); } diff --git a/packages/cloudflare/test/utils/rpcPropagation.test.ts b/packages/cloudflare/test/utils/rpcPropagation.test.ts index e8900e435457..047424e442ed 100644 --- a/packages/cloudflare/test/utils/rpcPropagation.test.ts +++ b/packages/cloudflare/test/utils/rpcPropagation.test.ts @@ -45,4 +45,19 @@ describe('createRpcPropagationResolver', () => { expect(shouldPropagate('ORDERS')).toBe(false); expect(shouldPropagate('PREFIXED_SVC_ORDERS')).toBe(false); }); + + it('matches binding names case-insensitively', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: ['my_do', /^svc_/] }); + + expect(shouldPropagate('MY_DO')).toBe(true); + expect(shouldPropagate('SVC_ORDERS')).toBe(true); + expect(shouldPropagate('OTHER')).toBe(false); + }); + + it('matches consistently across calls for a regular expression with the `g` flag', () => { + const shouldPropagate = createRpcPropagationResolver({ rpcTracePropagationBindings: [/^SVC_/g] }); + + expect(shouldPropagate('SVC_ORDERS')).toBe(true); + expect(shouldPropagate('SVC_USERS')).toBe(true); + }); }); diff --git a/packages/core/src/utils/tracePropagationTargets.ts b/packages/core/src/utils/tracePropagationTargets.ts index 7bdb317d7e74..b093d84b1b7d 100644 --- a/packages/core/src/utils/tracePropagationTargets.ts +++ b/packages/core/src/utils/tracePropagationTargets.ts @@ -33,20 +33,28 @@ function normalizeRegExpTarget(pattern: RegExp): RegExp { } /** - * Check if a URL matches any of the given `tracePropagationTargets`. + * Check if a value matches any of the given `tracePropagationTargets`. + * + * The value is usually a URL, but it can be anything a propagation decision is made on, such as a + * Cloudflare binding name. String targets match as a substring unless `requireExactStringMatch` is set. * * Matching is case-insensitive: URL normalization (e.g. `new URL()`) lower-cases the origin, so a target * written with the same casing as the request (`'myApi.com'`, `/^myApi\.com/`) would otherwise never match. */ -export function matchesTracePropagationTargets(url: string, tracePropagationTargets: TracePropagationTargets): boolean { - const lowerCaseUrl = url.toLowerCase(); +export function matchesTracePropagationTargets( + value: string, + tracePropagationTargets: TracePropagationTargets, + requireExactStringMatch: boolean = false, +): boolean { + const lowerCaseValue = value.toLowerCase(); for (const target of tracePropagationTargets) { if (isString(target)) { - if (lowerCaseUrl.includes(target.toLowerCase())) { + const lowerCaseTarget = target.toLowerCase(); + if (requireExactStringMatch ? lowerCaseValue === lowerCaseTarget : lowerCaseValue.includes(lowerCaseTarget)) { return true; } - } else if (isRegExp(target) && normalizeRegExpTarget(target).test(url)) { + } else if (isRegExp(target) && normalizeRegExpTarget(target).test(value)) { return true; } } diff --git a/packages/core/test/lib/utils/tracePropagationTargets.test.ts b/packages/core/test/lib/utils/tracePropagationTargets.test.ts index 6e7d973e115a..3bf66f043a95 100644 --- a/packages/core/test/lib/utils/tracePropagationTargets.test.ts +++ b/packages/core/test/lib/utils/tracePropagationTargets.test.ts @@ -76,6 +76,23 @@ describe('matchesTracePropagationTargets', () => { expect(matchesTracePropagationTargets('https://myapi.com/v1', [target])).toBe(true); expect(matchesTracePropagationTargets('https://myapi.com/v2', [target])).toBe(true); }); + + describe('with requireExactStringMatch', () => { + it.each([ + ['MY_DO', ['MY_DO'], true], + ['MY_DO', ['my_do'], true], + ['my_do', ['MY_DO'], true], + ['MY_DB', ['DB'], false], + ['DB_REPLICA', ['DB'], false], + ])('for value %j and string targets %j returns %j', (value, targets, expected) => { + expect(matchesTracePropagationTargets(value, targets, true)).toBe(expected); + }); + + it('leaves regex targets matching by pattern', () => { + expect(matchesTracePropagationTargets('SVC_ORDERS', [/^svc_/], true)).toBe(true); + expect(matchesTracePropagationTargets('ORDERS', [/^SVC_/], true)).toBe(false); + }); + }); }); describe('shouldPropagateTraceForUrl', () => {