Skip to content

Commit ea3764e

Browse files
authored
feat(cloudflare): Instrument Cloudflare rate limiter bindings (#22035)
Adds automatic tracing for Cloudflare Workers rate limiter bindings, mirroring the existing R2/Queue/D1 binding instrumentation. When a `RateLimit` binding is accessed on `env`, its `limit()` calls are wrapped in a span. ### Details - New `instrumentRateLimit` wraps the binding in a `Proxy` and starts a span named `rate_limit <binding>` around `limit()`, with the standard `auto.faas.cloudflare.rate_limit` origin. - Detection uses a `limit` duck-type in `isBinding`, wired into `instrumentEnv` after the more specific Queue/R2/D1 checks so those win when a binding also happens to expose `limit`. - The rate limit `key` is intentionally not recorded, since it commonly contains user-identifying data (e.g. an IP address or user id). - Cloudflare does not emit a native span for the rate limiter binding, so no `op` or custom `cloudflare.rate_limit.*` attributes are set for now. These can be added later if/when they land in Sentry's semantic conventions. - Includes unit tests plus an integration suite covering both an allowed call and a rate-limited (`success: false`) call. Fixes #20871
1 parent 1c9dabf commit ea3764e

9 files changed

Lines changed: 309 additions & 3 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import type { RateLimit } from '@cloudflare/workers-types';
2+
import * as Sentry from '@sentry/cloudflare';
3+
4+
interface Env {
5+
SENTRY_DSN: string;
6+
MY_RATE_LIMITER: RateLimit;
7+
}
8+
9+
function json(data: unknown): Response {
10+
return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } });
11+
}
12+
13+
export default Sentry.withSentry(
14+
(env: Env) => ({
15+
dsn: env.SENTRY_DSN,
16+
tracesSampleRate: 1,
17+
}),
18+
{
19+
async fetch(request, env) {
20+
const url = new URL(request.url);
21+
22+
if (url.pathname === '/ratelimit/allowed') {
23+
const outcome = await env.MY_RATE_LIMITER.limit({ key: 'allowed-key' });
24+
return json(outcome);
25+
}
26+
27+
if (url.pathname === '/ratelimit/blocked') {
28+
// The binding's limit is 1, so the second call within the period is rate limited.
29+
await env.MY_RATE_LIMITER.limit({ key: 'blocked-key' });
30+
const outcome = await env.MY_RATE_LIMITER.limit({ key: 'blocked-key' });
31+
return json(outcome);
32+
}
33+
34+
return new Response('not found', { status: 404 });
35+
},
36+
} as ExportedHandler<Env>,
37+
);
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Envelope } from '@sentry/core';
2+
import { expect, it } from 'vitest';
3+
import { createRunner } from '../../runner';
4+
5+
function envelopeItemType(envelope: Envelope): string | undefined {
6+
return envelope[1][0]?.[0]?.type as string | undefined;
7+
}
8+
9+
function envelopeItem(envelope: Envelope): Record<string, unknown> {
10+
return envelope[1][0]![1] as Record<string, unknown>;
11+
}
12+
13+
function findRateLimitSpans(envelope: Envelope): Array<Record<string, unknown>> {
14+
if (envelopeItemType(envelope) !== 'transaction') return [];
15+
const spans = (envelopeItem(envelope).spans as Array<Record<string, unknown>>) || [];
16+
return spans.filter(
17+
s => (s.data as Record<string, unknown> | undefined)?.['sentry.origin'] === 'auto.faas.cloudflare.rate_limit',
18+
);
19+
}
20+
21+
it('instruments an allowed rate limiter call automatically via env', async ({ signal }) => {
22+
const runner = createRunner(__dirname)
23+
.ignore('event')
24+
.expect((envelope: Envelope) => {
25+
expect(envelopeItemType(envelope)).toBe('transaction');
26+
const event = envelopeItem(envelope);
27+
28+
expect(event.spans).toEqual([
29+
{
30+
data: {
31+
'sentry.origin': 'auto.faas.cloudflare.rate_limit',
32+
},
33+
description: 'rate_limit MY_RATE_LIMITER',
34+
origin: 'auto.faas.cloudflare.rate_limit',
35+
parent_span_id: expect.any(String),
36+
span_id: expect.any(String),
37+
start_timestamp: expect.any(Number),
38+
timestamp: expect.any(Number),
39+
trace_id: expect.any(String),
40+
},
41+
]);
42+
})
43+
.start(signal);
44+
45+
const response = await runner.makeRequest('get', '/ratelimit/allowed');
46+
expect(response).toEqual({ success: true });
47+
await runner.completed();
48+
});
49+
50+
it('instruments a rate-limited call automatically via env', async ({ signal }) => {
51+
const runner = createRunner(__dirname)
52+
.ignore('event')
53+
.expect((envelope: Envelope) => {
54+
expect(envelopeItemType(envelope)).toBe('transaction');
55+
// Both `limit()` calls on the blocked endpoint are instrumented.
56+
expect(findRateLimitSpans(envelope)).toHaveLength(2);
57+
})
58+
.start(signal);
59+
60+
const response = await runner.makeRequest('get', '/ratelimit/blocked');
61+
expect(response).toEqual({ success: false });
62+
await runner.completed();
63+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "worker-name",
3+
"compatibility_date": "2025-06-17",
4+
"main": "index.ts",
5+
"compatibility_flags": ["nodejs_als"],
6+
"ratelimits": [
7+
{
8+
"name": "MY_RATE_LIMITER",
9+
"namespace_id": "1001",
10+
"simple": { "limit": 1, "period": 60 },
11+
},
12+
],
13+
}

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import { isObjectLike } from '@sentry/core';
22
import type { CloudflareOptions } from '../../client';
3-
import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue, isR2Bucket } from '../../utils/isBinding';
3+
import {
4+
isD1Database,
5+
isDurableObjectNamespace,
6+
isJSRPC,
7+
isQueue,
8+
isR2Bucket,
9+
isRateLimit,
10+
} from '../../utils/isBinding';
411
import { instrumentD1 } from './instrumentD1';
512
import { appendRpcMeta } from '../../utils/rpcMeta';
613
import { getEffectiveRpcPropagation } from '../../utils/rpcOptions';
714
import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instrumentDurableObjectNamespace';
815
import { instrumentFetcher } from './instrumentFetcher';
916
import { instrumentQueueProducer } from './instrumentQueueProducer';
1017
import { instrumentR2Bucket } from './instrumentR2';
18+
import { instrumentRateLimit } from './instrumentRateLimit';
1119

1220
function isProxyable(item: unknown): item is object {
1321
return isObjectLike(item) || typeof item === 'function';
@@ -24,6 +32,7 @@ const instrumentedBindings = new WeakMap<object, unknown>();
2432
* - Service bindings / JSRPC proxies
2533
* - Queue producers (via `send` + `sendBatch` duck-typing)
2634
* - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing)
35+
* - Rate limiters (via `limit` duck-typing)
2736
*
2837
* @param env - The Cloudflare env object to instrument
2938
* @param options - Optional CloudflareOptions to control RPC trace propagation
@@ -69,6 +78,13 @@ export function instrumentEnv<Env extends Record<string, unknown>>(env: Env, opt
6978
return instrumented;
7079
}
7180

81+
if (isRateLimit(item)) {
82+
const bindingName = typeof prop === 'string' ? prop : String(prop);
83+
const instrumented = instrumentRateLimit(item, bindingName);
84+
instrumentedBindings.set(item, instrumented);
85+
return instrumented;
86+
}
87+
7288
if (!rpcPropagation) {
7389
return item;
7490
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { RateLimit, RateLimitOptions, RateLimitOutcome } from '@cloudflare/workers-types';
2+
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core';
3+
4+
const ORIGIN = 'auto.faas.cloudflare.rate_limit';
5+
6+
/**
7+
* Wraps a Cloudflare rate limiter binding to create a span on each `limit()` call.
8+
*/
9+
export function instrumentRateLimit<T extends RateLimit>(rateLimit: T, bindingName: string): T {
10+
return new Proxy(rateLimit, {
11+
get(target, prop, receiver) {
12+
if (prop !== 'limit') {
13+
return Reflect.get(target, prop, receiver);
14+
}
15+
16+
const original = Reflect.get(target, prop, receiver) as RateLimit['limit'];
17+
18+
return function (this: unknown, options: RateLimitOptions): Promise<RateLimitOutcome> {
19+
return startSpan(
20+
{
21+
name: `rate_limit ${bindingName}`,
22+
attributes: {
23+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN,
24+
},
25+
},
26+
() => Reflect.apply(original, target, [options]),
27+
);
28+
};
29+
},
30+
});
31+
}

packages/cloudflare/src/utils/isBinding.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
3232
*/
3333

34-
import type { D1Database, DurableObjectNamespace, Queue, R2Bucket } from '@cloudflare/workers-types';
34+
import type { D1Database, DurableObjectNamespace, Queue, R2Bucket, RateLimit } from '@cloudflare/workers-types';
3535

3636
/**
3737
* Checks if a value is a JSRPC proxy (service binding).
@@ -95,3 +95,14 @@ export function isR2Bucket(item: unknown): item is R2Bucket {
9595
typeof item.createMultipartUpload === 'function'
9696
);
9797
}
98+
99+
/**
100+
* Duck-type check for RateLimit bindings.
101+
* RateLimit only exposes a single `limit` method. Because that is a fairly
102+
* common method name, this check is intentionally run after the more specific
103+
* binding checks (Queue, R2, D1) in `instrumentEnv`, so those win when a binding
104+
* also happens to expose `limit`.
105+
*/
106+
export function isRateLimit(item: unknown): item is RateLimit {
107+
return item != null && isNotJSRPC(item) && typeof item.limit === 'function';
108+
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,34 @@ describe('instrumentEnv', () => {
256256
expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace);
257257
});
258258

259+
it('wraps RateLimit bindings in a proxy and forwards calls', async () => {
260+
const startSpanSpy = vi.spyOn(SentryCore, 'startSpan');
261+
const limit = vi.fn().mockResolvedValue({ success: true });
262+
const rateLimiter = { limit };
263+
const env = { MY_RATE_LIMITER: rateLimiter };
264+
const instrumented = instrumentEnv(env);
265+
266+
const wrapped = instrumented.MY_RATE_LIMITER as typeof rateLimiter;
267+
// Wrapped binding is a Proxy, not the original reference
268+
expect(wrapped).not.toBe(rateLimiter);
269+
270+
const outcome = await wrapped.limit({ key: 'user-123' });
271+
expect(outcome).toEqual({ success: true });
272+
expect(limit).toHaveBeenCalledTimes(1);
273+
expect(startSpanSpy).toHaveBeenCalledWith(
274+
expect.objectContaining({ name: 'rate_limit MY_RATE_LIMITER' }),
275+
expect.any(Function),
276+
);
277+
});
278+
279+
it('caches the wrapped RateLimit binding across repeated access', () => {
280+
const rateLimiter = { limit: vi.fn() };
281+
const env = { MY_RATE_LIMITER: rateLimiter };
282+
const instrumented = instrumentEnv(env);
283+
284+
expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER);
285+
});
286+
259287
describe('mTLS Fetcher bindings', () => {
260288
function createMtlsFetcherProxy(mockFetch: ReturnType<typeof vi.fn>) {
261289
return new Proxy(
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import type { RateLimit } from '@cloudflare/workers-types';
2+
import * as SentryCore from '@sentry/core';
3+
import type { MockInstance } from 'vitest';
4+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
5+
import { instrumentRateLimit } from '../../../src/instrumentations/worker/instrumentRateLimit';
6+
7+
function createMockRateLimit(success = true): RateLimit {
8+
return {
9+
limit: vi.fn().mockResolvedValue({ success }),
10+
} as unknown as RateLimit;
11+
}
12+
13+
describe('instrumentRateLimit', () => {
14+
let startSpanSpy: MockInstance;
15+
16+
beforeEach(() => {
17+
startSpanSpy = vi.spyOn(SentryCore, 'startSpan');
18+
});
19+
20+
afterEach(() => {
21+
vi.restoreAllMocks();
22+
});
23+
24+
describe('limit', () => {
25+
test('forwards the call and returns the outcome', async () => {
26+
const rateLimit = createMockRateLimit(true);
27+
const wrapped = instrumentRateLimit(rateLimit, 'MY_RATE_LIMITER');
28+
29+
const outcome = await wrapped.limit({ key: 'user-123' });
30+
31+
expect(outcome).toEqual({ success: true });
32+
expect(rateLimit.limit).toHaveBeenCalledTimes(1);
33+
expect(rateLimit.limit).toHaveBeenCalledWith({ key: 'user-123' });
34+
});
35+
36+
test('returns an unsuccessful (rate-limited) outcome unchanged', async () => {
37+
const wrapped = instrumentRateLimit(createMockRateLimit(false), 'MY_RATE_LIMITER');
38+
39+
const outcome = await wrapped.limit({ key: 'user-123' });
40+
41+
expect(outcome).toEqual({ success: false });
42+
});
43+
44+
test('starts a span with the binding name and origin', async () => {
45+
const wrapped = instrumentRateLimit(createMockRateLimit(true), 'MY_RATE_LIMITER');
46+
await wrapped.limit({ key: 'user-123' });
47+
48+
expect(startSpanSpy).toHaveBeenCalledTimes(1);
49+
expect(startSpanSpy).toHaveBeenLastCalledWith(
50+
{
51+
name: 'rate_limit MY_RATE_LIMITER',
52+
attributes: {
53+
'sentry.origin': 'auto.faas.cloudflare.rate_limit',
54+
},
55+
},
56+
expect.any(Function),
57+
);
58+
});
59+
60+
test('does not record the rate limit key (avoids leaking PII)', async () => {
61+
const wrapped = instrumentRateLimit(createMockRateLimit(true), 'MY_RATE_LIMITER');
62+
await wrapped.limit({ key: 'super-secret-user-id' });
63+
64+
expect(JSON.stringify(startSpanSpy.mock.calls[0]![0])).not.toContain('super-secret-user-id');
65+
});
66+
});
67+
68+
test('forwards unknown property accesses transparently', () => {
69+
const rateLimit = Object.assign(createMockRateLimit(), {
70+
customMethod: vi.fn().mockReturnValue('hi'),
71+
}) as unknown as RateLimit & { customMethod: () => string };
72+
const wrapped = instrumentRateLimit(rateLimit, 'MY_RATE_LIMITER') as RateLimit & { customMethod: () => string };
73+
74+
expect(wrapped.customMethod()).toBe('hi');
75+
});
76+
});

packages/cloudflare/test/utils/isBinding.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue } from '../../src/utils/isBinding';
2+
import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue, isRateLimit } from '../../src/utils/isBinding';
33

44
describe('isJSRPC', () => {
55
it('returns false for a plain object', () => {
@@ -210,3 +210,34 @@ describe('isD1Database', () => {
210210
expect(isD1Database(jsrpcProxy)).toBe(false);
211211
});
212212
});
213+
214+
describe('isRateLimit', () => {
215+
it('returns true for an object with a limit method', () => {
216+
expect(isRateLimit({ limit: async () => ({ success: true }) })).toBe(true);
217+
});
218+
219+
it('returns false when limit is missing', () => {
220+
expect(isRateLimit({ foo: 'bar' })).toBe(false);
221+
});
222+
223+
it('returns false when limit is not a function', () => {
224+
expect(isRateLimit({ limit: 'nope' })).toBe(false);
225+
});
226+
227+
it('returns false for null and undefined', () => {
228+
expect(isRateLimit(null)).toBe(false);
229+
expect(isRateLimit(undefined)).toBe(false);
230+
});
231+
232+
it('returns false for a JSRPC proxy even though it returns a function for limit', () => {
233+
const jsrpcProxy = new Proxy(
234+
{},
235+
{
236+
get(_target, _prop) {
237+
return () => {};
238+
},
239+
},
240+
);
241+
expect(isRateLimit(jsrpcProxy)).toBe(false);
242+
});
243+
});

0 commit comments

Comments
 (0)