Class: RailsErrorDashboard::Services::RackAttackTracker
- Inherits:
-
Object
- Object
- RailsErrorDashboard::Services::RackAttackTracker
- Defined in:
- lib/rails_error_dashboard/services/rack_attack_tracker.rb
Overview
Buffers Rack::Attack events in a thread-local hash and flushes them to the database asynchronously.
WHY THIS EXISTS (issue #143): Rack::Attack events were previously only recorded as breadcrumbs. Breadcrumbs are harvested exclusively by LogError, so an event was only ever persisted if an unrelated exception happened to be raised later in the same request. A throttled request returns HTTP 429 and raises nothing, so the event was always discarded when ErrorCatcher cleared the buffer. This tracker persists events independently of error capture.
Events are aggregated by (rule, match_type, discriminator, path, method) and counted, rather than stored one row per event — a rate-limit flood is exactly when we must not do one INSERT per request.
SAFETY RULES (HOST_APP_SAFETY.md):
- Zero I/O in the record path (hash lookup + integer increment)
- Never raises — every public method wrapped in rescue
- Thread-local state, no mutex needed
- LRU eviction bounds memory (rotating-IP attacks cannot grow it unbounded)
- Async flush via background job
Constant Summary collapse
- COUNTS_THREAD_KEY =
:red_rack_attack_counts- FLUSH_THREAD_KEY =
:red_rack_attack_last_flush- MAX_RULE_LENGTH =
Field length caps — must match the column limits in the migration so that truncation happens before the value ever reaches the unique upsert index.
250- MAX_DISCRIMINATOR_LENGTH =
191- MAX_PATH_LENGTH =
191- MAX_METHOD_LENGTH =
10- KEY_SEPARATOR =
Separator for the composite buffer key. Chosen because it cannot appear in an HTTP method and is vanishingly unlikely in a rule name or path.
""
Class Method Summary collapse
-
.buffered_counts ⇒ Object
Current buffered counts (inspection / specs).
-
.flush!(sync: false) ⇒ Object
Flush buffered counts to the database (async by default).
-
.parse_key(key) ⇒ Array<String>
Decompose a buffer key back into its parts.
-
.record(rule:, match_type:, discriminator: nil, path: nil, http_method: nil) ⇒ Object
Record a single Rack::Attack event.
-
.reset! ⇒ Object
Clear thread-local state without persisting.
Class Method Details
.buffered_counts ⇒ Object
Current buffered counts (inspection / specs). Non-destructive.
107 108 109 110 111 |
# File 'lib/rails_error_dashboard/services/rack_attack_tracker.rb', line 107 def buffered_counts (Thread.current[COUNTS_THREAD_KEY] || {}).dup rescue => e {} end |
.flush!(sync: false) ⇒ Object
Flush buffered counts to the database (async by default). Clears the thread-local buffer before dispatching so a slow/failed flush cannot double-count on the next call.
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/rails_error_dashboard/services/rack_attack_tracker.rb', line 79 def flush!(sync: false) counts = Thread.current[COUNTS_THREAD_KEY] return if counts.nil? || counts.empty? snapshot = counts.dup counts.clear Thread.current[FLUSH_THREAD_KEY] = Time.now.to_f dispatch_flush(snapshot, sync: sync) nil rescue => e RailsErrorDashboard::Logger.debug( "[RailsErrorDashboard] RackAttackTracker.flush! failed: #{e.class} - #{e.}" ) nil end |
.parse_key(key) ⇒ Array<String>
Decompose a buffer key back into its parts.
115 116 117 |
# File 'lib/rails_error_dashboard/services/rack_attack_tracker.rb', line 115 def parse_key(key) key.to_s.split(KEY_SEPARATOR, 5) end |
.record(rule:, match_type:, discriminator: nil, path: nil, http_method: nil) ⇒ Object
Record a single Rack::Attack event. Called from the AS::Notifications subscriber on every throttle/blocklist/track match.
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/rails_error_dashboard/services/rack_attack_tracker.rb', line 49 def record(rule:, match_type:, discriminator: nil, path: nil, http_method: nil) return unless enabled? key = build_key( truncate(rule, MAX_RULE_LENGTH), match_type.to_s, truncate(discriminator, MAX_DISCRIMINATOR_LENGTH), truncate(path, MAX_PATH_LENGTH), truncate(http_method, MAX_METHOD_LENGTH) ) counts = (Thread.current[COUNTS_THREAD_KEY] ||= {}) counts[key] = (counts[key] || 0) + 1 # LRU eviction — Ruby hashes preserve insertion order, so the first key # is the oldest. Bounds memory under rotating-discriminator attacks. evict_oldest!(counts) if counts.size > max_cache_size maybe_flush! nil rescue => e RailsErrorDashboard::Logger.debug( "[RailsErrorDashboard] RackAttackTracker.record failed: #{e.class} - #{e.}" ) nil end |
.reset! ⇒ Object
Clear thread-local state without persisting. Used by specs and by thread teardown paths.
98 99 100 101 102 103 104 |
# File 'lib/rails_error_dashboard/services/rack_attack_tracker.rb', line 98 def reset! Thread.current[COUNTS_THREAD_KEY] = nil Thread.current[FLUSH_THREAD_KEY] = nil nil rescue => e nil end |