Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as Sentry from '@sentry/cloudflare';
import { DurableObject } from 'cloudflare:workers';

interface Env {
SENTRY_DSN: string;
MY_DURABLE_OBJECT: DurableObjectNamespace<MyDurableObjectBase>;
SVC_ALPHA: DurableObjectNamespace<MyDurableObjectBase>;
SVC_BETA: DurableObjectNamespace<MyDurableObjectBase>;
}

class MyDurableObjectBase extends DurableObject<Env> {
async sayHello(name: string): Promise<string> {
return `Hello, ${name}!`;
}

async alpha(): Promise<string> {
return 'alpha';
}

async beta(): Promise<string> {
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<Env>,
);
Original file line number Diff line number Diff line change
@@ -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<string, Event>();

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<string>('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);
}
});
Original file line number Diff line number Diff line change
@@ -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",
},
],
},
}
3 changes: 3 additions & 0 deletions packages/cloudflare/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions packages/cloudflare/src/utils/rpcPropagation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { stringMatchesSomePattern } from '@sentry/core';
import { matchesTracePropagationTargets } from '@sentry/core';
import type { CloudflareOptions } from '../client';

const PROPAGATE_TO_NONE = () => false;
Expand All @@ -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);
}
15 changes: 15 additions & 0 deletions packages/cloudflare/test/utils/rpcPropagation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
18 changes: 13 additions & 5 deletions packages/core/src/utils/tracePropagationTargets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Expand Down
17 changes: 17 additions & 0 deletions packages/core/test/lib/utils/tracePropagationTargets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading