feat(observability): rate-limit rejections reach Sentry and PostHog, not just the container log - #246
Merged
Merged
Conversation
…not just the container log Prod ran 3377 rate-limit rejections in seven days (2690 from one IP running DealsbuckCouponBot/1.0 against /api/coupons) and produced zero Sentry issues and zero analytics events: every rejection was a console.warn inside the web container. The guard worked; nobody outside an SSH session could know it had to. Every rejection now leaves a Sentry breadcrumb, and sustained abuse from one client escalates ONCE per client per hour to a Sentry event plus a server-side `api_abuse_detected` PostHog event. Throttled on purpose — 2690 events are noise, one event carrying rejections: 2690 is a decision. The analytics distinct id is a truncated SHA-256 of the IP, never the raw address. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Prod, last seven days,
grep '\[ratelimit\]'insidecompose-input-bluetooth-application-qvdfbq-web-1:A competitor's coupon bot spent a week walking our 182,598-row catalog and getting throttled — and
every single one of those 3377 rejections was a
console.warninside the container and nothingelse. No Sentry issue, no analytics event, nothing anyone could alert on or query. The guard in
rateLimit.tsdid its job perfectly; the fact that it had to do its job never left the box.That is the failure mode this repo's own rules call out: Sentry silence meant "no reporter", not
"no abuse". Found during the
/paid-user-watchfleet run (2026-09-16).What changes
New
src/lib/abuseSignal.ts, called fromrateLimit.ts's existinglogAbuse(the log line isunchanged):
abuse context. Cheap, no network.
per window to a
Sentry.captureMessage(surface: rate-limit-abuse, carrying the IP, therejection count, the paths and the UA) plus a server-side
api_abuse_detectedPostHog event.rejections_in_window: 2690is a decision. The window resets, so a bot that comes back tomorrowreports again.
ip:<truncated sha256>as the distinct id, never the raw address. Sentry — our ownops surface, where acting on a scraper means writing an edge rule for that exact IP — does get it.
voidat the call site, documented): the 429 must not wait ontelemetry, and the reporter reports its own failures rather than throwing into the request path.
Tests
tests/unit/abuse-signal.test.ts— 5 cases: breadcrumbs below the threshold without escalating,escalates exactly once at the threshold and stays quiet for the next 25 rejections, sends the hashed
id and never the raw IP, counts two clients separately, and never throws when the analytics capture
fails.
Local gates:
tsc --noEmitclean ·oxlintclean ·prettier --checkclean ·knipclean ·343 tests green across the 26 unit files that touch
rateLimit.Follow-up (not in this PR)
The app-level throttle holds but does not stop a determined catalog walk (120 req/min × 50 rows).
A Cloudflare rule for
DealsbuckCouponBot/1.0and that IP range is an owner item — this PR is whatmakes the case for it visible without an SSH session.
🤖 Generated with Claude Code