Class: RailsErrorDashboard::Services::RackAttackTracker

Inherits:
Object
  • Object
show all
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

Class Method Details

.buffered_countsObject

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.message}"
  )
  nil
end

.parse_key(key) ⇒ Array<String>

Decompose a buffer key back into its parts.

Returns:

  • (Array<String>)

    [rule, match_type, discriminator, path, http_method]



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.

Parameters:

  • rule (String)

    matched rule name (env)

  • match_type (String)

    "throttle" | "blocklist" | "track"

  • discriminator (String) (defaults to: nil)

    rate-limit key (usually IP or user id)

  • path (String) (defaults to: nil)

    request path

  • http_method (String) (defaults to: nil)

    request method



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.message}"
  )
  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