feat(cloudflare): Instrument Cloudflare rate limiter bindings#22035
Conversation
Automatically wraps limit() calls on Cloudflare rate limiter bindings in a span, mirroring the existing R2/Queue/D1 binding instrumentation. The rate-limited outcome is recorded via a span attribute rather than an error status, and the rate limit key is not recorded to avoid leaking PII.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 53a0a03. Configure here.
There was a problem hiding this comment.
Pull request overview
Adds first-class tracing support for Cloudflare Workers RateLimit bindings in @sentry/cloudflare. The implementation follows the existing env-binding model (D1 / Queue / R2) by detecting the binding on env access, proxy-wrapping it, and creating a span around each limit() call while recording the outcome as a span attribute (without capturing the key to avoid PII).
Changes:
- Add
isRateLimitduck-typing to detect RateLimit bindings (limitmethod + not JSRPC). - Instrument
envaccess to wrap detected RateLimit bindings and cache the wrapped proxy. - Introduce
instrumentRateLimitwhich creates aratelimitspan perlimit()call and recordscloudflare.rate_limit.success.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| packages/cloudflare/src/utils/isBinding.ts | Adds isRateLimit duck-type guard for RateLimit bindings. |
| packages/cloudflare/src/instrumentations/worker/instrumentRateLimit.ts | New instrumentation proxy that wraps limit() with a span and records the success outcome. |
| packages/cloudflare/src/instrumentations/worker/instrumentEnv.ts | Wires RateLimit detection into env proxying + caching alongside existing binding instrumentation. |
| packages/cloudflare/test/utils/isBinding.test.ts | Adds unit coverage for isRateLimit behavior (including JSRPC proxy exclusion). |
| packages/cloudflare/test/instrumentations/worker/instrumentRateLimit.test.ts | Adds unit tests for span creation/attributes, forwarding behavior, and avoiding key/PII capture. |
| packages/cloudflare/test/instrumentations/instrumentEnv.test.ts | Adds tests for env detection, wrapping, forwarding, and caching of RateLimit bindings. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Adds an integration suite that exercises a real rate limiter binding through wrangler and asserts the emitted ratelimit span and its attributes, matching the coverage of the R2 and Queue binding instrumentations.
| import type { RateLimit } from '@cloudflare/workers-types'; | ||
| import * as SentryCore from '@sentry/core'; | ||
| import { 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', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); | ||
|
|
There was a problem hiding this comment.
Like copilot mentioned you could either do this, or move/copy startSpanSpy in every test individually as well.
There was a problem hiding this comment.
Moved the spy creation into beforeEach with vi.restoreAllMocks() in afterEach, so each test gets a fresh spy.
|
👋 @mydea, @JPeer264, @andreiborza — Please review this PR when you get a chance! |
| return startSpan( | ||
| { | ||
| op: OP, | ||
| name: `rate_limit ${bindingName}`, |
There was a problem hiding this comment.
q: Did you cross-check with Cloudflare's own spans if these are the same there? We try to be as close to span naming as possible to what Cloudflare produces.
There was a problem hiding this comment.
Cross-checked: Cloudflare doesn't emit a native span for the rate limiter binding. It's not among their auto-traced bindings (KV/R2/DO), and their docs point to HTTP 429s or Analytics Engine for rate-limit observability rather than a span. So the op and cloudflare.rate_limit.* attributes had no Cloudflare-native equivalent to match. I've removed them and kept just the span plus the standard auto.faas.cloudflare.rate_limit origin.
| export function instrumentRateLimit<T extends RateLimit>(rateLimit: T, bindingName: string): T { | ||
| return new Proxy(rateLimit, { | ||
| get(target, prop, receiver) { | ||
| if (prop === 'limit') { |
There was a problem hiding this comment.
m: It would be nice if you could switch to a fail fast approach and start with the following:
if (prop !== 'limit') {
return Reflect.get(target, prop, receiver);
}There was a problem hiding this comment.
Done — the handler now returns early: if (prop !== 'limit') return Reflect.get(target, prop, receiver);.
| op: OP, | ||
| name: `rate_limit ${bindingName}`, | ||
| attributes: { | ||
| 'cloudflare.rate_limit.binding': bindingName, |
There was a problem hiding this comment.
m: Before we add this we need to add it in our semantic conventions (I could add it). However, before this will be moved to semantic conventions, is this attribute in the original Cloudflare span as well? If it is not I don't think we should add it here for now
There was a problem hiding this comment.
Removed. It's not present in Cloudflare's native span (details in the cross-check thread), so it's out for now. Happy to re-add cloudflare.rate_limit.* if/when it's added to the semantic conventions.
| import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; | ||
|
|
||
| const ORIGIN = 'auto.faas.cloudflare.rate_limit'; | ||
| const OP = 'ratelimit'; |
There was a problem hiding this comment.
m: ratelimit is not part of our official span operations (yet). I have to clarify if we actually need that - but for now please remove the OP entirely. We can always add this later here and in the docs later on as a feature.
There was a problem hiding this comment.
Removed the op entirely.
| }, | ||
| async span => { | ||
| const outcome = await Reflect.apply(original, target, [options]); | ||
| span.setAttribute('cloudflare.rate_limit.success', outcome.success); |
There was a problem hiding this comment.
m: Same as the others, is this attribute added in Cloudflare's spans?
There was a problem hiding this comment.
Removed for the same reason (not in Cloudflare's native span). The rate-limited (success: false) outcome is now covered by the integration test via the response instead of a span attribute.
| const spans = findSpans(envelope, 'rate_limit MY_RATE_LIMITER'); | ||
| expect(spans).toHaveLength(1); | ||
| const data = spanData(spans[0]!); | ||
| expect({ |
There was a problem hiding this comment.
m: Please use the following syntax (I saw it slipped through in the R2 tests, I'll remove that there in a PR):
const event = envelopeItem(envelope);
expect(event.spans).toEqual([
...
])You can check out the D1 tests, these should match the expected assertions.
There was a problem hiding this comment.
Switched to the D1-style assertion (const event = envelopeItem(envelope); expect(event.spans).toEqual([...])).
| return span.data as Record<string, unknown>; | ||
| } | ||
|
|
||
| it('emits a ratelimit span with the binding name and success outcome', async ({ signal }) => { |
There was a problem hiding this comment.
m: It'd be nice if there would be a case to check if the .success is also false. Right now we only check agains true, but we would never know if this would be working correctly for the ratelimit to not be active/hitting
There was a problem hiding this comment.
Added a /ratelimit/blocked case: the binding's limit is set to 1, so the second limit() call within the period is rate limited. The test asserts the response is { success: false } and that both calls are still instrumented. Since the success attribute was removed, the rate-limited outcome is verified via the response.
| import type { RateLimit } from '@cloudflare/workers-types'; | ||
| import * as SentryCore from '@sentry/core'; | ||
| import { 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', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| const startSpanSpy = vi.spyOn(SentryCore, 'startSpan'); | ||
|
|
There was a problem hiding this comment.
Like copilot mentioned you could either do this, or move/copy startSpanSpy in every test individually as well.
Remove the non-standard ratelimit span op and the cloudflare.rate_limit.* attributes (keeping the standard origin), fail-fast in the Proxy handler, and align the integration test with the D1 assertion style plus a rate-limited case.
| @@ -0,0 +1,31 @@ | |||
| import type { RateLimit, RateLimitOptions, RateLimitOutcome } from '@cloudflare/workers-types'; | |||
| import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan } from '@sentry/core'; | |||
| return function (this: unknown, options: RateLimitOptions): Promise<RateLimitOutcome> { | ||
| return startSpan( | ||
| { | ||
| name: `rate_limit ${bindingName}`, | ||
| attributes: { | ||
| [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, | ||
| }, | ||
| }, | ||
| () => Reflect.apply(original, target, [options]), | ||
| ); | ||
| }; |
| 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 () => { |
| function findRateLimitSpans(envelope: Envelope): Array<Record<string, unknown>> { | ||
| if (envelopeItemType(envelope) !== 'transaction') return []; | ||
| const spans = (envelopeItem(envelope).spans as Array<Record<string, unknown>>) || []; | ||
| return spans.filter(s => s.origin === 'auto.faas.cloudflare.rate_limit'); | ||
| } |
| .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), | ||
| }, | ||
| ]); | ||
| }) |
| .expect((envelope: Envelope) => { | ||
| expect(envelopeItemType(envelope)).toBe('transaction'); | ||
| // Both `limit()` calls on the blocked endpoint are instrumented. | ||
| expect(findRateLimitSpans(envelope)).toHaveLength(2); | ||
| }) |
|
👋 @mydea, @JPeer264, @andreiborza — Please review this PR when you get a chance! |
JPeer264
left a comment
There was a problem hiding this comment.
LGTM. Thanks a lot for your contributions.
This PR adds the external contributor to the CHANGELOG.md file, so that they are credited for their contribution. See #22035 Co-authored-by: JPeer264 <10677263+JPeer264@users.noreply.github.com>

Adds automatic tracing for Cloudflare Workers rate limiter bindings, mirroring the existing R2/Queue/D1 binding instrumentation. When a
RateLimitbinding is accessed onenv, itslimit()calls are wrapped in a span.Details
instrumentRateLimitwraps the binding in aProxyand starts a span namedrate_limit <binding>aroundlimit(), with the standardauto.faas.cloudflare.rate_limitorigin.limitduck-type inisBinding, wired intoinstrumentEnvafter the more specific Queue/R2/D1 checks so those win when a binding also happens to exposelimit.keyis intentionally not recorded, since it commonly contains user-identifying data (e.g. an IP address or user id).opor customcloudflare.rate_limit.*attributes are set for now. These can be added later if/when they land in Sentry's semantic conventions.success: false) call.Fixes #20871