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- MAX_USER_AGENT_LENGTH =
191- OVERFLOW_RULE =
Reserved rule/match_type used to account for counts dropped by LRU eviction. Without this the evicted count vanishes silently and the dashboard under-reports with no indication anything was lost — the same problem StormProtection::CountBuffer solves with an overflow counter.
"__overflow__"- OVERFLOW_MATCH_TYPE =
"overflow"- 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).
-
.flush_all_threads! ⇒ Object
Flush every live thread's buffer, not just the caller's.
-
.parse_key(key) ⇒ Array<String>
Decompose a buffer key back into its parts.
-
.record(rule:, match_type:, discriminator: nil, path: nil, http_method: nil, user_agent: 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.
163 164 165 166 167 |
# File 'lib/rails_error_dashboard/services/rack_attack_tracker.rb', line 163 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.
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/rails_error_dashboard/services/rack_attack_tracker.rb', line 95 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 |
.flush_all_threads! ⇒ Object
Flush every live thread's buffer, not just the caller's.
WHY: flush! only ever sees Thread.current. Buffers live on the Puma threads that served the requests, so at shutdown (and from a background job) the caller's own buffer is empty while the real counts sit on threads nobody is asking. Without this, everything buffered at SIGTERM is lost, and a rule that matches once and then sees no further traffic on that thread is never persisted at all.
Thread#[] reads another thread's fiber-locals directly, so no thread registry is needed — the same approach SwallowedExceptionTracker uses. sync: true because callers are already off the request path.
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/rails_error_dashboard/services/rack_attack_tracker.rb', line 124 def flush_all_threads! Thread.list.each do |thread| # Rescue per thread, not just around the whole loop: one thread # whose write fails must not strand the buffers of every thread # after it in the list. begin counts = thread[COUNTS_THREAD_KEY] next if counts.nil? || counts.empty? snapshot = counts.dup counts.clear thread[FLUSH_THREAD_KEY] = nil dispatch_flush(snapshot, sync: true) rescue => e RailsErrorDashboard::Logger.debug( "[RailsErrorDashboard] RackAttackTracker.flush_all_threads! skipped a thread: #{e.class} - #{e.}" ) end end nil rescue => e RailsErrorDashboard::Logger.debug( "[RailsErrorDashboard] RackAttackTracker.flush_all_threads! failed: #{e.class} - #{e.}" ) nil end |
.parse_key(key) ⇒ Array<String>
Decompose a buffer key back into its parts.
The limit must match the field count exactly. With a limit of 5 the user agent would be glued onto http_method instead of standing alone. split also drops trailing empty fields without the limit, so a key whose user agent is blank must still yield six elements.
177 178 179 180 |
# File 'lib/rails_error_dashboard/services/rack_attack_tracker.rb', line 177 def parse_key(key) parts = key.to_s.split(KEY_SEPARATOR, 6) parts.fill("", parts.length, 6 - parts.length) end |
.record(rule:, match_type:, discriminator: nil, path: nil, http_method: nil, user_agent: nil) ⇒ Object
Record a single Rack::Attack event. Called from the AS::Notifications subscriber on every throttle/blocklist/track match.
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
# File 'lib/rails_error_dashboard/services/rack_attack_tracker.rb', line 58 def record(rule:, match_type:, discriminator: nil, path: nil, http_method: nil, user_agent: 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), truncate(user_agent, MAX_USER_AGENT_LENGTH) ) counts = (Thread.current[COUNTS_THREAD_KEY] ||= {}) counts[key] = (counts[key] || 0) + 1 # LRU eviction — bounds memory under rotating-discriminator attacks. # Loops because the overflow bucket occupies a slot of its own once # created, so a single eviction may not bring the map back under cap. # evict_oldest! returns false once only the overflow key is left, which # guarantees termination even if max_cache_size is misconfigured to 0. while counts.size > max_cache_size break unless evict_oldest!(counts) end 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.
154 155 156 157 158 159 160 |
# File 'lib/rails_error_dashboard/services/rack_attack_tracker.rb', line 154 def reset! Thread.current[COUNTS_THREAD_KEY] = nil Thread.current[FLUSH_THREAD_KEY] = nil nil rescue => e nil end |