Skip to content

Commit 1230b45

Browse files
committed
feat(cloudflare): Add allow list to enableRpcTracePropagation
1 parent 1288882 commit 1230b45

9 files changed

Lines changed: 219 additions & 3 deletions

File tree

dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index-sub-worker.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,4 +67,12 @@ export const NoPropagationEntrypoint = Sentry.withSentry(
6767
MySubWorkerEntrypointBase,
6868
);
6969

70+
// Deliberately not wrapped with Sentry: nothing strips a trailing RPC metadata argument here, so
71+
// this is what a caller corrupts if it propagates to a receiver it has no guarantees about.
72+
export class UninstrumentedEntrypoint extends WorkerEntrypoint<Env> {
73+
get(key: string): { argumentCount: number; key: string } {
74+
return { argumentCount: arguments.length, key };
75+
}
76+
}
77+
7078
export default BindingEntrypoint;

dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ interface Env {
1111
SUB_WORKER_NO_PROPAGATION: Fetcher & {
1212
get(key: string): Promise<{ argumentCount: number; key: string }>;
1313
};
14+
SUB_WORKER_UNINSTRUMENTED: Fetcher & {
15+
get(key: string): Promise<{ argumentCount: number; key: string }>;
16+
};
1417
}
1518

1619
class LoopbackEntrypointBase extends WorkerEntrypoint<Env> {
@@ -29,7 +32,9 @@ export default Sentry.withSentry(
2932
dsn: env.SENTRY_DSN,
3033
traceLifecycle: 'static',
3134
tracesSampleRate: 1.0,
32-
enableRpcTracePropagation: true,
35+
// Allowlisted by binding name: `SUB_WORKER_UNINSTRUMENTED` is deliberately left out, since its
36+
// receiver has no Sentry to strip a trailing metadata argument.
37+
enableRpcTracePropagation: ['SUB_WORKER', 'SUB_WORKER_NO_PROPAGATION'],
3338
}),
3439
{
3540
async fetch(request, env, ctx) {
@@ -61,6 +66,10 @@ export default Sentry.withSentry(
6166
}
6267
}
6368

69+
if (url.pathname === '/call-uninstrumented-rpc') {
70+
return Response.json(await env.SUB_WORKER_UNINSTRUMENTED.get('uninstrumented-key'));
71+
}
72+
6473
if (url.pathname === '/call-entrypoint-rpc-no-propagation') {
6574
const result = await env.SUB_WORKER_NO_PROPAGATION.get('no-prop-key');
6675
return Response.json(result);

dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,23 @@ it('captures errors thrown by custom WorkerEntrypoint RPC methods', async ({ sig
244244
await runner.completed();
245245
});
246246

247+
// Regression test for https://github.com/getsentry/sentry-javascript/issues/23233: a receiver that
248+
// is not instrumented never strips Sentry's trailing metadata argument, so a caller must only
249+
// propagate to bindings it was explicitly told about.
250+
it('does not change RPC method arguments for a binding left off the allowlist', async ({ signal }) => {
251+
const runner = createRunner(__dirname)
252+
.expect(envelope => {
253+
const transactionEvent = envelope[1]?.[0]?.[1] as Event;
254+
expect(transactionEvent.transaction).toBe('GET /call-uninstrumented-rpc');
255+
})
256+
.start(signal);
257+
258+
const response = await runner.makeRequest<{ argumentCount: number; key: string }>('get', '/call-uninstrumented-rpc');
259+
expect(response).toEqual({ argumentCount: 1, key: 'uninstrumented-key' });
260+
261+
await runner.completed();
262+
});
263+
247264
it('does not inject RPC trace metadata into receiver calls when enableRpcTracePropagation is disabled', async ({
248265
signal,
249266
}) => {

dev-packages/cloudflare-integration-tests/suites/tracing/propagation/worker-workerentrypoint-rpc/wrangler.jsonc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,10 @@
1414
"service": "cloudflare-worker-workerentrypoint-rpc-sub",
1515
"entrypoint": "NoPropagationEntrypoint",
1616
},
17+
{
18+
"binding": "SUB_WORKER_UNINSTRUMENTED",
19+
"service": "cloudflare-worker-workerentrypoint-rpc-sub",
20+
"entrypoint": "UninstrumentedEntrypoint",
21+
},
1722
],
1823
}

packages/cloudflare/src/client.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,16 @@ interface BaseCloudflareOptions {
200200
* - Create spans for each RPC method invocation
201201
* - Capture errors thrown by RPC methods
202202
*
203+
* Accepts:
204+
* - `false` (default) - never propagate.
205+
* - `true` - propagate on every Durable Object and Service Binding.
206+
* - `Array<string | RegExp>` - propagate on only the bindings whose names match the given strings or regular expressions.
207+
*
208+
*
209+
* Prefer the array form when you call bindings whose receiver may not run Sentry. RPC calls carry
210+
* trace context as a trailing argument, and only a Sentry-instrumented receiver strips it again —
211+
* anywhere else it arrives as a real argument and changes the method's signature.
212+
*
203213
* **Important:** This option should be enabled on **both sides** for full trace propagation.
204214
*
205215
* @default false
@@ -229,8 +239,19 @@ interface BaseCloudflareOptions {
229239
* MyEntrypointBase,
230240
* );
231241
* ```
242+
* @example
243+
* ```ts
244+
* // Only propagate to `env.ORDERS` and every `env.SVC_*` binding
245+
* export default Sentry.withSentry(
246+
* (env) => ({
247+
* dsn: env.SENTRY_DSN,
248+
* enableRpcTracePropagation: ['ORDERS', /^SVC_/],
249+
* }),
250+
* handler,
251+
* );
252+
* ```
232253
*/
233-
enableRpcTracePropagation?: boolean;
254+
enableRpcTracePropagation?: boolean | Array<string | RegExp>;
234255

235256
/**
236257
* Table names that should stay instrumented even though they match the reserved `cf_` prefix used

packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '../../utils/isBinding';
1313
import { instrumentD1 } from './instrumentD1';
1414
import { appendRpcMeta } from '../../utils/rpcMeta';
15+
import { createRpcPropagationResolver } from '../../utils/rpcPropagation';
1516
import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace';
1617
import { instrumentFetcher } from './instrumentFetcher';
1718
import { instrumentQueueProducer } from './instrumentQueueProducer';
@@ -44,6 +45,8 @@ export function instrumentEnv<Env extends Record<string, unknown>>(env: Env, opt
4445
return env;
4546
}
4647

48+
const shouldPropagateRpcTrace = createRpcPropagationResolver(options);
49+
4750
return new Proxy(env, {
4851
get(target, prop, receiver) {
4952
const item = Reflect.get(target, prop, receiver);
@@ -91,7 +94,7 @@ export function instrumentEnv<Env extends Record<string, unknown>>(env: Env, opt
9194
return instrumented;
9295
}
9396

94-
if (!options?.enableRpcTracePropagation) {
97+
if (!shouldPropagateRpcTrace(String(prop))) {
9598
return item;
9699
}
97100

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { stringMatchesSomePattern } from '@sentry/core';
2+
import type { CloudflareOptions } from '../client';
3+
4+
const PROPAGATE_TO_NONE = () => false;
5+
const PROPAGATE_TO_ALL = () => true;
6+
7+
/**
8+
* Builds the per-binding predicate that decides whether a binding takes part in RPC trace
9+
* propagation.
10+
*/
11+
export function createRpcPropagationResolver(options: CloudflareOptions | undefined): (bindingName: string) => boolean {
12+
const value: CloudflareOptions['enableRpcTracePropagation'] | undefined = options?.enableRpcTracePropagation;
13+
14+
if (value === true) {
15+
return PROPAGATE_TO_ALL;
16+
}
17+
18+
if (!Array.isArray(value) || !value.length) {
19+
return PROPAGATE_TO_NONE;
20+
}
21+
22+
// Strings must match a binding name exactly, without this, an entry of `DB` would also enable
23+
// propagation for a binding named `MY_DB`. Regular expressions still give pattern matching.
24+
return (bindingName: string) => stringMatchesSomePattern(bindingName, value, true);
25+
}

packages/cloudflare/test/instrumentations/instrumentEnv.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,34 @@ describe('instrumentEnv', () => {
8989
expect(instrumentDurableObjectNamespace).not.toHaveBeenCalled();
9090
});
9191

92+
it('instruments only the DurableObjectNamespace bindings named in the allowlist', () => {
93+
const allowed = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() };
94+
const denied = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() };
95+
const env = { COUNTER: allowed, SESSIONS: denied };
96+
const instrumented = instrumentEnv(env, { enableRpcTracePropagation: ['COUNTER'] });
97+
98+
expect((instrumented.COUNTER as any).__instrumented).toBe(true);
99+
expect(instrumented.SESSIONS).toBe(denied);
100+
expect(instrumentDurableObjectNamespace).toHaveBeenCalledTimes(1);
101+
expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(allowed);
102+
});
103+
104+
it('matches allowlisted binding names exactly rather than as substrings', () => {
105+
const doNamespace = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() };
106+
const env = { MY_COUNTER: doNamespace };
107+
const instrumented = instrumentEnv(env, { enableRpcTracePropagation: ['COUNTER'] });
108+
109+
expect(instrumented.MY_COUNTER).toBe(doNamespace);
110+
});
111+
112+
it('supports regular expressions in the allowlist', () => {
113+
const doNamespace = { idFromName: vi.fn(), idFromString: vi.fn(), get: vi.fn(), newUniqueId: vi.fn() };
114+
const env = { SVC_ORDERS: doNamespace };
115+
const instrumented = instrumentEnv(env, { enableRpcTracePropagation: [/^SVC_/] });
116+
117+
expect((instrumented.SVC_ORDERS as any).__instrumented).toBe(true);
118+
});
119+
92120
it('detects and instruments DurableObjectNamespace bindings when enableRpcTracePropagation is enabled', () => {
93121
const doNamespace = {
94122
idFromName: vi.fn(),
@@ -486,5 +514,44 @@ describe('instrumentEnv', () => {
486514

487515
expect(rpcMethod).toHaveBeenCalledWith('arg1');
488516
});
517+
518+
// A receiver without Sentry never strips the trailing metadata argument, so a caller has to be
519+
// able to limit propagation to the bindings it knows are instrumented.
520+
// See https://github.com/getsentry/sentry-javascript/issues/23233.
521+
it('injects meta only into JSRPC calls on allowlisted bindings', () => {
522+
vi.spyOn(SentryCore, 'getTraceData').mockReturnValue({
523+
'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
524+
baggage: 'sentry-environment=production',
525+
});
526+
527+
const allowedMethod = vi.fn();
528+
const deniedMethod = vi.fn();
529+
const createJsrpcBinding = (rpcMethod: ReturnType<typeof vi.fn>) =>
530+
new Proxy(
531+
{ fetch: vi.fn(), myRpcMethod: rpcMethod },
532+
{
533+
get(target, prop) {
534+
if (prop in target) {
535+
return Reflect.get(target, prop);
536+
}
537+
return () => {};
538+
},
539+
},
540+
);
541+
542+
const env = { ORDERS: createJsrpcBinding(allowedMethod), EXTERNAL: createJsrpcBinding(deniedMethod) };
543+
const instrumented = instrumentEnv(env, { enableRpcTracePropagation: ['ORDERS'] });
544+
545+
instrumented.ORDERS.myRpcMethod('first');
546+
instrumented.EXTERNAL.myRpcMethod('first');
547+
548+
expect(allowedMethod).toHaveBeenCalledWith('first', {
549+
__sentry_rpc_meta__: {
550+
'sentry-trace': '12345678901234567890123456789012-1234567890123456-1',
551+
baggage: 'sentry-environment=production',
552+
},
553+
});
554+
expect(deniedMethod).toHaveBeenCalledWith('first');
555+
});
489556
});
490557
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createRpcPropagationResolver } from '../../src/utils/rpcPropagation';
3+
4+
describe('createRpcPropagationResolver', () => {
5+
it('propagates to nothing when no options are available', () => {
6+
const shouldPropagate = createRpcPropagationResolver(undefined);
7+
8+
expect(shouldPropagate('MY_DO')).toBe(false);
9+
});
10+
11+
it('propagates to nothing when the option is unset', () => {
12+
const shouldPropagate = createRpcPropagationResolver({ enableRpcTracePropagation: undefined });
13+
14+
expect(shouldPropagate('MY_DO')).toBe(false);
15+
expect(shouldPropagate('EXTERNAL')).toBe(false);
16+
});
17+
18+
it('propagates to nothing when the option is `false`', () => {
19+
const shouldPropagate = createRpcPropagationResolver({ enableRpcTracePropagation: false });
20+
21+
expect(shouldPropagate('MY_DO')).toBe(false);
22+
});
23+
24+
it('propagates to every binding when the option is `true`', () => {
25+
const shouldPropagate = createRpcPropagationResolver({ enableRpcTracePropagation: true });
26+
27+
expect(shouldPropagate('MY_DO')).toBe(true);
28+
expect(shouldPropagate('EXTERNAL')).toBe(true);
29+
});
30+
31+
it('propagates only to allowlisted binding names', () => {
32+
const shouldPropagate = createRpcPropagationResolver({ enableRpcTracePropagation: ['MY_DO', 'EXTERNAL'] });
33+
34+
expect(shouldPropagate('MY_DO')).toBe(true);
35+
expect(shouldPropagate('EXTERNAL')).toBe(true);
36+
expect(shouldPropagate('OTHER')).toBe(false);
37+
});
38+
39+
it('propagates to nothing for an empty allowlist', () => {
40+
const shouldPropagate = createRpcPropagationResolver({ enableRpcTracePropagation: [] });
41+
42+
expect(shouldPropagate('MY_DO')).toBe(false);
43+
});
44+
45+
it('matches binding names exactly, never as a substring', () => {
46+
const shouldPropagate = createRpcPropagationResolver({ enableRpcTracePropagation: ['DB'] });
47+
48+
expect(shouldPropagate('DB')).toBe(true);
49+
expect(shouldPropagate('MY_DB')).toBe(false);
50+
expect(shouldPropagate('DB_REPLICA')).toBe(false);
51+
});
52+
53+
it('supports regular expressions for pattern matching', () => {
54+
const shouldPropagate = createRpcPropagationResolver({ enableRpcTracePropagation: [/^SVC_/] });
55+
56+
expect(shouldPropagate('SVC_ORDERS')).toBe(true);
57+
expect(shouldPropagate('SVC_USERS')).toBe(true);
58+
expect(shouldPropagate('ORDERS')).toBe(false);
59+
expect(shouldPropagate('PREFIXED_SVC_ORDERS')).toBe(false);
60+
});
61+
});

0 commit comments

Comments
 (0)