diff --git a/dev-packages/cloudflare-integration-tests/suites/ratelimit/index.ts b/dev-packages/cloudflare-integration-tests/suites/ratelimit/index.ts new file mode 100644 index 000000000000..89f4810b26dd --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/ratelimit/index.ts @@ -0,0 +1,37 @@ +import type { RateLimit } from '@cloudflare/workers-types'; +import * as Sentry from '@sentry/cloudflare'; + +interface Env { + SENTRY_DSN: string; + MY_RATE_LIMITER: RateLimit; +} + +function json(data: unknown): Response { + return new Response(JSON.stringify(data), { headers: { 'content-type': 'application/json' } }); +} + +export default Sentry.withSentry( + (env: Env) => ({ + dsn: env.SENTRY_DSN, + tracesSampleRate: 1, + }), + { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === '/ratelimit/allowed') { + const outcome = await env.MY_RATE_LIMITER.limit({ key: 'allowed-key' }); + return json(outcome); + } + + if (url.pathname === '/ratelimit/blocked') { + // The binding's limit is 1, so the second call within the period is rate limited. + await env.MY_RATE_LIMITER.limit({ key: 'blocked-key' }); + const outcome = await env.MY_RATE_LIMITER.limit({ key: 'blocked-key' }); + return json(outcome); + } + + return new Response('not found', { status: 404 }); + }, + } as ExportedHandler, +); diff --git a/dev-packages/cloudflare-integration-tests/suites/ratelimit/test.ts b/dev-packages/cloudflare-integration-tests/suites/ratelimit/test.ts new file mode 100644 index 000000000000..30653c6e943d --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/ratelimit/test.ts @@ -0,0 +1,63 @@ +import type { Envelope } from '@sentry/core'; +import { expect, it } from 'vitest'; +import { createRunner } from '../../runner'; + +function envelopeItemType(envelope: Envelope): string | undefined { + return envelope[1][0]?.[0]?.type as string | undefined; +} + +function envelopeItem(envelope: Envelope): Record { + return envelope[1][0]![1] as Record; +} + +function findRateLimitSpans(envelope: Envelope): Array> { + if (envelopeItemType(envelope) !== 'transaction') return []; + const spans = (envelopeItem(envelope).spans as Array>) || []; + return spans.filter( + s => (s.data as Record | undefined)?.['sentry.origin'] === 'auto.faas.cloudflare.rate_limit', + ); +} + +it('instruments an allowed rate limiter call automatically via env', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect((envelope: Envelope) => { + expect(envelopeItemType(envelope)).toBe('transaction'); + const event = envelopeItem(envelope); + + expect(event.spans).toEqual([ + { + data: { + 'sentry.origin': 'auto.faas.cloudflare.rate_limit', + }, + description: 'rate_limit MY_RATE_LIMITER', + origin: 'auto.faas.cloudflare.rate_limit', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + }, + ]); + }) + .start(signal); + + const response = await runner.makeRequest('get', '/ratelimit/allowed'); + expect(response).toEqual({ success: true }); + await runner.completed(); +}); + +it('instruments a rate-limited call automatically via env', async ({ signal }) => { + const runner = createRunner(__dirname) + .ignore('event') + .expect((envelope: Envelope) => { + expect(envelopeItemType(envelope)).toBe('transaction'); + // Both `limit()` calls on the blocked endpoint are instrumented. + expect(findRateLimitSpans(envelope)).toHaveLength(2); + }) + .start(signal); + + const response = await runner.makeRequest('get', '/ratelimit/blocked'); + expect(response).toEqual({ success: false }); + await runner.completed(); +}); diff --git a/dev-packages/cloudflare-integration-tests/suites/ratelimit/wrangler.jsonc b/dev-packages/cloudflare-integration-tests/suites/ratelimit/wrangler.jsonc new file mode 100644 index 000000000000..97ed092cdc9b --- /dev/null +++ b/dev-packages/cloudflare-integration-tests/suites/ratelimit/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "name": "worker-name", + "compatibility_date": "2025-06-17", + "main": "index.ts", + "compatibility_flags": ["nodejs_als"], + "ratelimits": [ + { + "name": "MY_RATE_LIMITER", + "namespace_id": "1001", + "simple": { "limit": 1, "period": 60 }, + }, + ], +} diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts index 5899ee66ada5..4f8eb0aa3142 100644 --- a/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts +++ b/packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts @@ -1,6 +1,13 @@ import { isObjectLike } from '@sentry/core'; import type { CloudflareOptions } from '../../client'; -import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue, isR2Bucket } from '../../utils/isBinding'; +import { + isD1Database, + isDurableObjectNamespace, + isJSRPC, + isQueue, + isR2Bucket, + isRateLimit, +} from '../../utils/isBinding'; import { instrumentD1 } from './instrumentD1'; import { appendRpcMeta } from '../../utils/rpcMeta'; import { getEffectiveRpcPropagation } from '../../utils/rpcOptions'; @@ -8,6 +15,7 @@ import { instrumentDurableObjectNamespace, STUB_NON_RPC_METHODS } from '../instr import { instrumentFetcher } from './instrumentFetcher'; import { instrumentQueueProducer } from './instrumentQueueProducer'; import { instrumentR2Bucket } from './instrumentR2'; +import { instrumentRateLimit } from './instrumentRateLimit'; function isProxyable(item: unknown): item is object { return isObjectLike(item) || typeof item === 'function'; @@ -24,6 +32,7 @@ const instrumentedBindings = new WeakMap(); * - Service bindings / JSRPC proxies * - Queue producers (via `send` + `sendBatch` duck-typing) * - R2 Buckets (via `head` + `put` + `createMultipartUpload` duck-typing) + * - Rate limiters (via `limit` duck-typing) * * @param env - The Cloudflare env object to instrument * @param options - Optional CloudflareOptions to control RPC trace propagation @@ -69,6 +78,13 @@ export function instrumentEnv>(env: Env, opt return instrumented; } + if (isRateLimit(item)) { + const bindingName = typeof prop === 'string' ? prop : String(prop); + const instrumented = instrumentRateLimit(item, bindingName); + instrumentedBindings.set(item, instrumented); + return instrumented; + } + if (!rpcPropagation) { return item; } diff --git a/packages/cloudflare/src/instrumentations/worker/instrumentRateLimit.ts b/packages/cloudflare/src/instrumentations/worker/instrumentRateLimit.ts new file mode 100644 index 000000000000..0b05a99f3518 --- /dev/null +++ b/packages/cloudflare/src/instrumentations/worker/instrumentRateLimit.ts @@ -0,0 +1,31 @@ +import type { RateLimit, RateLimitOptions, RateLimitOutcome } from '@cloudflare/workers-types'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; + +const ORIGIN = 'auto.faas.cloudflare.rate_limit'; + +/** + * Wraps a Cloudflare rate limiter binding to create a span on each `limit()` call. + */ +export function instrumentRateLimit(rateLimit: T, bindingName: string): T { + return new Proxy(rateLimit, { + get(target, prop, receiver) { + if (prop !== 'limit') { + return Reflect.get(target, prop, receiver); + } + + const original = Reflect.get(target, prop, receiver) as RateLimit['limit']; + + return function (this: unknown, options: RateLimitOptions): Promise { + return startSpan( + { + name: `rate_limit ${bindingName}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, + }, + }, + () => Reflect.apply(original, target, [options]), + ); + }; + }, + }); +} diff --git a/packages/cloudflare/src/utils/isBinding.ts b/packages/cloudflare/src/utils/isBinding.ts index 88832f375f21..c8f6387f3c07 100644 --- a/packages/cloudflare/src/utils/isBinding.ts +++ b/packages/cloudflare/src/utils/isBinding.ts @@ -31,7 +31,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import type { D1Database, DurableObjectNamespace, Queue, R2Bucket } from '@cloudflare/workers-types'; +import type { D1Database, DurableObjectNamespace, Queue, R2Bucket, RateLimit } from '@cloudflare/workers-types'; /** * Checks if a value is a JSRPC proxy (service binding). @@ -95,3 +95,14 @@ export function isR2Bucket(item: unknown): item is R2Bucket { typeof item.createMultipartUpload === 'function' ); } + +/** + * Duck-type check for RateLimit bindings. + * RateLimit only exposes a single `limit` method. Because that is a fairly + * common method name, this check is intentionally run after the more specific + * binding checks (Queue, R2, D1) in `instrumentEnv`, so those win when a binding + * also happens to expose `limit`. + */ +export function isRateLimit(item: unknown): item is RateLimit { + return item != null && isNotJSRPC(item) && typeof item.limit === 'function'; +} diff --git a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts index 9309284abb41..40c575898de0 100644 --- a/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts +++ b/packages/cloudflare/test/instrumentations/instrumentEnv.test.ts @@ -256,6 +256,34 @@ describe('instrumentEnv', () => { expect(instrumentDurableObjectNamespace).toHaveBeenCalledWith(doNamespace); }); + it('wraps RateLimit bindings in a proxy and forwards calls', async () => { + const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + const limit = vi.fn().mockResolvedValue({ success: true }); + const rateLimiter = { limit }; + const env = { MY_RATE_LIMITER: rateLimiter }; + const instrumented = instrumentEnv(env); + + const wrapped = instrumented.MY_RATE_LIMITER as typeof rateLimiter; + // Wrapped binding is a Proxy, not the original reference + expect(wrapped).not.toBe(rateLimiter); + + const outcome = await wrapped.limit({ key: 'user-123' }); + expect(outcome).toEqual({ success: true }); + expect(limit).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'rate_limit MY_RATE_LIMITER' }), + expect.any(Function), + ); + }); + + it('caches the wrapped RateLimit binding across repeated access', () => { + const rateLimiter = { limit: vi.fn() }; + const env = { MY_RATE_LIMITER: rateLimiter }; + const instrumented = instrumentEnv(env); + + expect(instrumented.MY_RATE_LIMITER).toBe(instrumented.MY_RATE_LIMITER); + }); + describe('mTLS Fetcher bindings', () => { function createMtlsFetcherProxy(mockFetch: ReturnType) { return new Proxy( diff --git a/packages/cloudflare/test/instrumentations/worker/instrumentRateLimit.test.ts b/packages/cloudflare/test/instrumentations/worker/instrumentRateLimit.test.ts new file mode 100644 index 000000000000..61062355da77 --- /dev/null +++ b/packages/cloudflare/test/instrumentations/worker/instrumentRateLimit.test.ts @@ -0,0 +1,76 @@ +import type { RateLimit } from '@cloudflare/workers-types'; +import * as SentryCore from '@sentry/core'; +import type { MockInstance } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { instrumentRateLimit } from '../../../src/instrumentations/worker/instrumentRateLimit'; + +function createMockRateLimit(success = true): RateLimit { + return { + limit: vi.fn().mockResolvedValue({ success }), + } as unknown as RateLimit; +} + +describe('instrumentRateLimit', () => { + let startSpanSpy: MockInstance; + + beforeEach(() => { + startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('limit', () => { + test('forwards the call and returns the outcome', async () => { + const rateLimit = createMockRateLimit(true); + const wrapped = instrumentRateLimit(rateLimit, 'MY_RATE_LIMITER'); + + const outcome = await wrapped.limit({ key: 'user-123' }); + + expect(outcome).toEqual({ success: true }); + expect(rateLimit.limit).toHaveBeenCalledTimes(1); + expect(rateLimit.limit).toHaveBeenCalledWith({ key: 'user-123' }); + }); + + test('returns an unsuccessful (rate-limited) outcome unchanged', async () => { + const wrapped = instrumentRateLimit(createMockRateLimit(false), 'MY_RATE_LIMITER'); + + const outcome = await wrapped.limit({ key: 'user-123' }); + + expect(outcome).toEqual({ success: false }); + }); + + test('starts a span with the binding name and origin', async () => { + const wrapped = instrumentRateLimit(createMockRateLimit(true), 'MY_RATE_LIMITER'); + await wrapped.limit({ key: 'user-123' }); + + expect(startSpanSpy).toHaveBeenCalledTimes(1); + expect(startSpanSpy).toHaveBeenLastCalledWith( + { + name: 'rate_limit MY_RATE_LIMITER', + attributes: { + 'sentry.origin': 'auto.faas.cloudflare.rate_limit', + }, + }, + expect.any(Function), + ); + }); + + test('does not record the rate limit key (avoids leaking PII)', async () => { + const wrapped = instrumentRateLimit(createMockRateLimit(true), 'MY_RATE_LIMITER'); + await wrapped.limit({ key: 'super-secret-user-id' }); + + expect(JSON.stringify(startSpanSpy.mock.calls[0]![0])).not.toContain('super-secret-user-id'); + }); + }); + + test('forwards unknown property accesses transparently', () => { + const rateLimit = Object.assign(createMockRateLimit(), { + customMethod: vi.fn().mockReturnValue('hi'), + }) as unknown as RateLimit & { customMethod: () => string }; + const wrapped = instrumentRateLimit(rateLimit, 'MY_RATE_LIMITER') as RateLimit & { customMethod: () => string }; + + expect(wrapped.customMethod()).toBe('hi'); + }); +}); diff --git a/packages/cloudflare/test/utils/isBinding.test.ts b/packages/cloudflare/test/utils/isBinding.test.ts index 28d44fd9936d..3bfba571363b 100644 --- a/packages/cloudflare/test/utils/isBinding.test.ts +++ b/packages/cloudflare/test/utils/isBinding.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue } from '../../src/utils/isBinding'; +import { isD1Database, isDurableObjectNamespace, isJSRPC, isQueue, isRateLimit } from '../../src/utils/isBinding'; describe('isJSRPC', () => { it('returns false for a plain object', () => { @@ -210,3 +210,34 @@ describe('isD1Database', () => { expect(isD1Database(jsrpcProxy)).toBe(false); }); }); + +describe('isRateLimit', () => { + it('returns true for an object with a limit method', () => { + expect(isRateLimit({ limit: async () => ({ success: true }) })).toBe(true); + }); + + it('returns false when limit is missing', () => { + expect(isRateLimit({ foo: 'bar' })).toBe(false); + }); + + it('returns false when limit is not a function', () => { + expect(isRateLimit({ limit: 'nope' })).toBe(false); + }); + + it('returns false for null and undefined', () => { + expect(isRateLimit(null)).toBe(false); + expect(isRateLimit(undefined)).toBe(false); + }); + + it('returns false for a JSRPC proxy even though it returns a function for limit', () => { + const jsrpcProxy = new Proxy( + {}, + { + get(_target, _prop) { + return () => {}; + }, + }, + ); + expect(isRateLimit(jsrpcProxy)).toBe(false); + }); +});