rails_error_dashboard 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +54 -1
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +5 -0
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +14 -3
  5. data/app/jobs/rails_error_dashboard/rack_attack_flush_job.rb +31 -0
  6. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +30 -0
  7. data/app/jobs/rails_error_dashboard/storm_flush_job.rb +19 -0
  8. data/app/jobs/rails_error_dashboard/storm_notification_job.rb +74 -0
  9. data/app/models/rails_error_dashboard/rack_attack_event.rb +36 -0
  10. data/app/models/rails_error_dashboard/storm_event.rb +34 -0
  11. data/app/views/layouts/rails_error_dashboard.html.erb +22 -1
  12. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +4 -6
  13. data/app/views/rails_error_dashboard/errors/storms.html.erb +91 -0
  14. data/config/routes.rb +1 -0
  15. data/db/migrate/20260306000002_add_instance_variables_to_error_logs.rb +7 -1
  16. data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +4 -0
  17. data/db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb +4 -0
  18. data/db/migrate/20260613000001_create_storm_events.rb +28 -0
  19. data/db/migrate/20260730000001_create_rails_error_dashboard_rack_attack_events.rb +45 -0
  20. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +36 -0
  21. data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +84 -0
  22. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +188 -0
  23. data/lib/rails_error_dashboard/commands/log_error.rb +70 -12
  24. data/lib/rails_error_dashboard/configuration.rb +78 -7
  25. data/lib/rails_error_dashboard/engine.rb +2 -2
  26. data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +55 -44
  27. data/lib/rails_error_dashboard/queries/storm_history.rb +39 -0
  28. data/lib/rails_error_dashboard/services/rack_attack_tracker.rb +194 -0
  29. data/lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb +195 -0
  30. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +100 -0
  31. data/lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb +123 -0
  32. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +258 -0
  33. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +12 -0
  34. data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +16 -2
  35. data/lib/rails_error_dashboard/version.rb +1 -1
  36. data/lib/rails_error_dashboard.rb +8 -0
  37. metadata +37 -18
@@ -2,9 +2,15 @@
2
2
 
3
3
  module RailsErrorDashboard
4
4
  module Queries
5
- # Query: Aggregate Rack Attack events from breadcrumbs across all errors
6
- # Scans error_logs breadcrumbs JSON, filters for "rack_attack" category crumbs,
7
- # and groups by rule name with counts, unique IPs, paths, and error associations.
5
+ # Query: Aggregate Rack Attack events from the rack_attack_events table.
6
+ #
7
+ # Previously this scanned every error_log's breadcrumbs JSON in Ruby, which was
8
+ # both expensive (full-table scan + JSON.parse per row) and incomplete (events
9
+ # were only stored when an unrelated error happened to occur in the same
10
+ # request). Events now have their own table, so this is indexed SQL.
11
+ #
12
+ # Returns rows grouped by rule with counts, unique discriminators (IPs),
13
+ # the most frequent path, and last-seen timestamps.
8
14
  class RackAttackSummary
9
15
  def self.call(days = 30, application_id: nil)
10
16
  new(days, application_id: application_id).call
@@ -25,65 +31,70 @@ module RailsErrorDashboard
25
31
  private
26
32
 
27
33
  def base_query
28
- scope = ErrorLog.where("occurred_at >= ?", @start_date)
29
- .where.not(breadcrumbs: nil)
30
- scope = scope.where(application_id: @application_id) if @application_id.present?
34
+ scope = RackAttackEvent.where("period_hour >= ?", @start_date)
35
+ scope = scope.for_application(@application_id) if @application_id.present?
31
36
  scope
32
37
  end
33
38
 
34
39
  def aggregated_events
35
- results = {}
40
+ rows = base_query.pluck(
41
+ :rule, :match_type, :discriminator, :path, :event_count, :last_seen_at, :period_hour
42
+ )
43
+ return [] if rows.empty?
36
44
 
37
- base_query.select(:id, :breadcrumbs, :occurred_at).find_each(batch_size: 500) do |error_log|
38
- crumbs = parse_breadcrumbs(error_log.breadcrumbs)
39
- next if crumbs.empty?
45
+ grouped = {}
40
46
 
41
- rack_attack_crumbs = crumbs.select { |c| c["c"] == "rack_attack" }
42
- next if rack_attack_crumbs.empty?
47
+ rows.each do |rule, match_type, discriminator, path, event_count, last_seen_at, period_hour|
48
+ key = rule.to_s.presence || "unknown"
49
+ count = event_count.to_i
50
+ seen_at = last_seen_at || period_hour
43
51
 
44
- rack_attack_crumbs.each do |crumb|
45
- meta = crumb["meta"] || {}
46
- rule = meta["rule"].to_s.presence || "unknown"
52
+ entry = grouped[key] ||= {
53
+ rule: key,
54
+ match_type: match_type.to_s,
55
+ count: 0,
56
+ ips: Set.new,
57
+ path_counts: Hash.new(0),
58
+ last_seen: nil
59
+ }
47
60
 
48
- if results[rule]
49
- results[rule][:count] += 1
50
- results[rule][:ips] << meta["discriminator"].to_s if meta["discriminator"].present?
51
- results[rule][:paths] << meta["path"].to_s if meta["path"].present?
52
- results[rule][:error_ids] << error_log.id
53
- results[rule][:last_seen] = [ results[rule][:last_seen], error_log.occurred_at ].compact.max
54
- else
55
- results[rule] = {
56
- rule: rule,
57
- match_type: meta["type"].to_s,
58
- count: 1,
59
- ips: Set.new([ meta["discriminator"].to_s ].reject(&:blank?)),
60
- paths: Set.new([ meta["path"].to_s ].reject(&:blank?)),
61
- error_ids: [ error_log.id ],
62
- last_seen: error_log.occurred_at
63
- }
64
- end
65
- end
61
+ entry[:count] += count
62
+ entry[:ips] << discriminator.to_s if discriminator.present?
63
+ entry[:path_counts][path.to_s] += count if path.present?
64
+ entry[:last_seen] = [ entry[:last_seen], seen_at ].compact.max
65
+
66
+ # Prefer the most severe match type when a rule spans several. A rule
67
+ # that both tracks and blocks should surface as "blocklist".
68
+ entry[:match_type] = match_type.to_s if severity(match_type) > severity(entry[:match_type])
66
69
  end
67
70
 
68
- results.values.each do |r|
69
- r[:error_ids] = r[:error_ids].uniq
70
- r[:error_count] = r[:error_ids].size
71
+ grouped.values.each do |r|
72
+ # "Top path" now means genuinely most-frequent, not first-seen.
73
+ r[:top_path] = r[:path_counts].max_by { |_path, count| count }&.first
74
+ r[:paths] = r[:path_counts].sort_by { |_p, c| -c }.map(&:first)
71
75
  r[:unique_ips] = r[:ips].size
72
- r[:top_path] = r[:paths].first
73
76
  r[:ips] = r[:ips].to_a
74
- r[:paths] = r[:paths].to_a
77
+ # Distinct rate-limited clients is the meaningful figure here; the old
78
+ # breadcrumb-derived :error_count no longer applies now that events are
79
+ # stored independently of errors.
80
+ r[:error_count] = 0
81
+ r.delete(:path_counts)
75
82
  end
76
- results.values.sort_by { |r| -r[:count] }
83
+
84
+ grouped.values.sort_by { |r| -r[:count] }
77
85
  rescue => e
78
86
  Rails.logger.error("[RailsErrorDashboard] RackAttackSummary query failed: #{e.class}: #{e.message}")
79
87
  []
80
88
  end
81
89
 
82
- def parse_breadcrumbs(raw)
83
- return [] if raw.blank?
84
- JSON.parse(raw)
85
- rescue JSON::ParserError
86
- []
90
+ # Ordering used to pick the most severe match type for a rule.
91
+ def severity(match_type)
92
+ case match_type.to_s
93
+ when "blocklist" then 3
94
+ when "throttle" then 2
95
+ when "track" then 1
96
+ else 0
97
+ end
87
98
  end
88
99
  end
89
100
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # Query: Storm protection episode history + active-storm lookup.
6
+ #
7
+ # Read-only. Powers the /errors/storms page and the layout banner.
8
+ class StormHistory
9
+ RECENT_BANNER_WINDOW = 24.hours
10
+
11
+ def self.call(limit: 50)
12
+ return { active: nil, recent: nil, events: [] } unless StormEvent.table_exists?
13
+
14
+ {
15
+ active: StormEvent.active.recent_first.first,
16
+ recent: StormEvent.ended_within(RECENT_BANNER_WINDOW).recent_first.first,
17
+ events: StormEvent.recent_first.limit(limit).to_a
18
+ }
19
+ rescue => e
20
+ RailsErrorDashboard::Logger.error(
21
+ "[RailsErrorDashboard] StormHistory query failed: #{e.message}"
22
+ )
23
+ { active: nil, recent: nil, events: [] }
24
+ end
25
+
26
+ # Cheap banner lookup for the layout — one indexed query on the happy
27
+ # path (no active storm), two when a banner is showing.
28
+ def self.banner_event
29
+ return nil unless RailsErrorDashboard.configuration.enable_storm_protection
30
+ return nil unless StormEvent.table_exists?
31
+
32
+ StormEvent.active.recent_first.first ||
33
+ StormEvent.ended_within(RECENT_BANNER_WINDOW).recent_first.first
34
+ rescue
35
+ nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Buffers Rack::Attack events in a thread-local hash and flushes them to the
6
+ # database asynchronously.
7
+ #
8
+ # WHY THIS EXISTS (issue #143): Rack::Attack events were previously only
9
+ # recorded as breadcrumbs. Breadcrumbs are harvested exclusively by LogError,
10
+ # so an event was only ever persisted if an unrelated exception happened to be
11
+ # raised later in the same request. A throttled request returns HTTP 429 and
12
+ # raises nothing, so the event was always discarded when ErrorCatcher cleared
13
+ # the buffer. This tracker persists events independently of error capture.
14
+ #
15
+ # Events are aggregated by (rule, match_type, discriminator, path, method) and
16
+ # counted, rather than stored one row per event — a rate-limit flood is exactly
17
+ # when we must not do one INSERT per request.
18
+ #
19
+ # SAFETY RULES (HOST_APP_SAFETY.md):
20
+ # - Zero I/O in the record path (hash lookup + integer increment)
21
+ # - Never raises — every public method wrapped in rescue
22
+ # - Thread-local state, no mutex needed
23
+ # - LRU eviction bounds memory (rotating-IP attacks cannot grow it unbounded)
24
+ # - Async flush via background job
25
+ class RackAttackTracker
26
+ COUNTS_THREAD_KEY = :red_rack_attack_counts
27
+ FLUSH_THREAD_KEY = :red_rack_attack_last_flush
28
+
29
+ # Field length caps — must match the column limits in the migration so that
30
+ # truncation happens before the value ever reaches the unique upsert index.
31
+ MAX_RULE_LENGTH = 250
32
+ MAX_DISCRIMINATOR_LENGTH = 191
33
+ MAX_PATH_LENGTH = 191
34
+ MAX_METHOD_LENGTH = 10
35
+
36
+ # Separator for the composite buffer key. Chosen because it cannot appear in
37
+ # an HTTP method and is vanishingly unlikely in a rule name or path.
38
+ KEY_SEPARATOR = ""
39
+
40
+ class << self
41
+ # Record a single Rack::Attack event. Called from the AS::Notifications
42
+ # subscriber on every throttle/blocklist/track match.
43
+ #
44
+ # @param rule [String] matched rule name (env["rack.attack.matched"])
45
+ # @param match_type [String] "throttle" | "blocklist" | "track"
46
+ # @param discriminator [String] rate-limit key (usually IP or user id)
47
+ # @param path [String] request path
48
+ # @param http_method [String] request method
49
+ def record(rule:, match_type:, discriminator: nil, path: nil, http_method: nil)
50
+ return unless enabled?
51
+
52
+ key = build_key(
53
+ truncate(rule, MAX_RULE_LENGTH),
54
+ match_type.to_s,
55
+ truncate(discriminator, MAX_DISCRIMINATOR_LENGTH),
56
+ truncate(path, MAX_PATH_LENGTH),
57
+ truncate(http_method, MAX_METHOD_LENGTH)
58
+ )
59
+
60
+ counts = (Thread.current[COUNTS_THREAD_KEY] ||= {})
61
+ counts[key] = (counts[key] || 0) + 1
62
+
63
+ # LRU eviction — Ruby hashes preserve insertion order, so the first key
64
+ # is the oldest. Bounds memory under rotating-discriminator attacks.
65
+ evict_oldest!(counts) if counts.size > max_cache_size
66
+
67
+ maybe_flush!
68
+ nil
69
+ rescue => e
70
+ RailsErrorDashboard::Logger.debug(
71
+ "[RailsErrorDashboard] RackAttackTracker.record failed: #{e.class} - #{e.message}"
72
+ )
73
+ nil
74
+ end
75
+
76
+ # Flush buffered counts to the database (async by default).
77
+ # Clears the thread-local buffer before dispatching so a slow/failed
78
+ # flush cannot double-count on the next call.
79
+ def flush!(sync: false)
80
+ counts = Thread.current[COUNTS_THREAD_KEY]
81
+ return if counts.nil? || counts.empty?
82
+
83
+ snapshot = counts.dup
84
+ counts.clear
85
+ Thread.current[FLUSH_THREAD_KEY] = Time.now.to_f
86
+
87
+ dispatch_flush(snapshot, sync: sync)
88
+ nil
89
+ rescue => e
90
+ RailsErrorDashboard::Logger.debug(
91
+ "[RailsErrorDashboard] RackAttackTracker.flush! failed: #{e.class} - #{e.message}"
92
+ )
93
+ nil
94
+ end
95
+
96
+ # Clear thread-local state without persisting. Used by specs and by
97
+ # thread teardown paths.
98
+ def reset!
99
+ Thread.current[COUNTS_THREAD_KEY] = nil
100
+ Thread.current[FLUSH_THREAD_KEY] = nil
101
+ nil
102
+ rescue => e
103
+ nil
104
+ end
105
+
106
+ # Current buffered counts (inspection / specs). Non-destructive.
107
+ def buffered_counts
108
+ (Thread.current[COUNTS_THREAD_KEY] || {}).dup
109
+ rescue => e
110
+ {}
111
+ end
112
+
113
+ # Decompose a buffer key back into its parts.
114
+ # @return [Array<String>] [rule, match_type, discriminator, path, http_method]
115
+ def parse_key(key)
116
+ key.to_s.split(KEY_SEPARATOR, 5)
117
+ end
118
+
119
+ private
120
+
121
+ def enabled?
122
+ RailsErrorDashboard.configuration.enable_rack_attack_tracking
123
+ rescue => e
124
+ false
125
+ end
126
+
127
+ def build_key(*parts)
128
+ parts.map(&:to_s).join(KEY_SEPARATOR)
129
+ end
130
+
131
+ def evict_oldest!(hash)
132
+ oldest_key = hash.each_key.first
133
+ hash.delete(oldest_key) if oldest_key
134
+ end
135
+
136
+ # Cheap periodic flush check — a float subtraction, no I/O.
137
+ def maybe_flush!
138
+ now = Time.now.to_f
139
+ last_flush = Thread.current[FLUSH_THREAD_KEY] ||= now
140
+ return unless (now - last_flush) >= flush_interval
141
+
142
+ flush!
143
+ end
144
+
145
+ # Dispatch asynchronously so the request path never waits on the DB.
146
+ # Falls back to a synchronous write if enqueueing fails (e.g. no queue
147
+ # backend configured) — losing the events would be worse, and this only
148
+ # happens on the flush interval, not per event.
149
+ def dispatch_flush(snapshot, sync: false)
150
+ return if snapshot.empty?
151
+
152
+ if sync
153
+ Commands::FlushRackAttackEvents.call(counts: snapshot)
154
+ else
155
+ RackAttackFlushJob.perform_later(snapshot)
156
+ end
157
+ rescue => e
158
+ RailsErrorDashboard::Logger.debug(
159
+ "[RailsErrorDashboard] RackAttackTracker.dispatch_flush enqueue failed, " \
160
+ "falling back to sync: #{e.class} - #{e.message}"
161
+ )
162
+ begin
163
+ Commands::FlushRackAttackEvents.call(counts: snapshot)
164
+ rescue => inner
165
+ RailsErrorDashboard::Logger.debug(
166
+ "[RailsErrorDashboard] RackAttackTracker sync fallback failed: #{inner.class} - #{inner.message}"
167
+ )
168
+ end
169
+ end
170
+
171
+ def max_cache_size
172
+ RailsErrorDashboard.configuration.rack_attack_max_cache_size || 1000
173
+ rescue => e
174
+ 1000
175
+ end
176
+
177
+ def flush_interval
178
+ RailsErrorDashboard.configuration.rack_attack_flush_interval || 60
179
+ rescue => e
180
+ 60
181
+ end
182
+
183
+ # Truncate to the column limit and strip the key separator. A rule name
184
+ # or path containing KEY_SEPARATOR would otherwise shift every field on
185
+ # parse — silently writing the discriminator into the path column.
186
+ def truncate(str, max)
187
+ s = str.to_s
188
+ s = s.delete(KEY_SEPARATOR) if s.include?(KEY_SEPARATOR)
189
+ s.length > max ? s[0, max] : s
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ module StormProtection
6
+ # Per-process circuit breaker for the error capture path.
7
+ #
8
+ # Counts capture attempts in fixed 10-second buckets and transitions
9
+ # between states based on the completed bucket's rate:
10
+ #
11
+ # :closed — normal operation, per-fingerprint buckets decide fidelity
12
+ # :shedding — elevated rate: context shed, notifications suppressed
13
+ # :open — storm: count-only mode, zero per-event I/O
14
+ # :half_open — post-cooldown probe: small sample admitted, watching rate
15
+ #
16
+ # Hysteresis: opens FAST (a single hot bucket, or mid-bucket fast-trip),
17
+ # closes SLOW (two consecutive calm buckets) to prevent flapping.
18
+ #
19
+ # Concurrency: the hot path is one AtomicFixnum increment plus a float
20
+ # comparison. The mutex is taken only on bucket roll (once per 10s) and
21
+ # for state transitions — never per event.
22
+ class CircuitBreaker
23
+ BUCKET_SECONDS = 10
24
+ CALM_BUCKETS_TO_CLOSE = 2
25
+
26
+ attr_reader :state
27
+
28
+ # @param clock [#call] returns monotonic seconds; injectable for tests
29
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
30
+ @clock = clock
31
+ @mutex = Mutex.new
32
+ reset!
33
+ end
34
+
35
+ def reset!
36
+ @mutex.synchronize do
37
+ @state = :closed
38
+ @bucket_start = @clock.call
39
+ @bucket_count = Concurrent::AtomicFixnum.new(0)
40
+ @calm_buckets = 0
41
+ @opened_at = nil
42
+ @episode = nil
43
+ end
44
+ end
45
+
46
+ # Count one capture attempt and return the state that should govern it.
47
+ # Called on EVERY capture — must stay allocation-free on the fast path.
48
+ def record!
49
+ now = @clock.call
50
+ roll!(now) if now - @bucket_start >= BUCKET_SECONDS
51
+
52
+ count = @bucket_count.increment
53
+
54
+ # Fast-trip: don't wait for the bucket to complete if it's already
55
+ # over the open threshold — at 50k errors/min a full 10s bucket
56
+ # would let ~8k events through before reacting.
57
+ if count >= open_threshold * BUCKET_SECONDS && @state != :open
58
+ trip_open!(now, count)
59
+ end
60
+
61
+ @state
62
+ end
63
+
64
+ # Episode metadata for the honesty layer (storm_events row).
65
+ # @return [Hash, nil] nil when no episode is active or recently closed
66
+ def episode_snapshot
67
+ @mutex.synchronize { @episode&.dup }
68
+ end
69
+
70
+ # Forget a closed episode once it has been persisted by the flush job.
71
+ def clear_closed_episode!
72
+ @mutex.synchronize do
73
+ @episode = nil if @episode && @episode[:ended_at]
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def roll!(now)
80
+ @mutex.synchronize do
81
+ elapsed = now - @bucket_start
82
+ return if elapsed < BUCKET_SECONDS # another thread already rolled
83
+
84
+ rate = @bucket_count.value / elapsed.to_f
85
+ @bucket_start = now
86
+ @bucket_count = Concurrent::AtomicFixnum.new(0)
87
+ transition!(rate, now)
88
+ end
89
+ end
90
+
91
+ # Transition table — runs inside @mutex, once per bucket roll.
92
+ # track_peak runs AFTER the case: a transition out of :closed creates
93
+ # the episode, and the triggering bucket's rate must be its first peak.
94
+ def transition!(rate, now)
95
+ case @state
96
+ when :closed
97
+ if rate >= open_threshold
98
+ open!(now)
99
+ elsif rate >= shedding_threshold
100
+ enter!(:shedding, now)
101
+ end
102
+ when :shedding
103
+ if rate >= open_threshold
104
+ open!(now)
105
+ elsif rate < shedding_threshold / 2.0
106
+ calm_step!(now)
107
+ else
108
+ @calm_buckets = 0
109
+ end
110
+ when :open
111
+ if now - @opened_at >= cooldown_seconds && rate < shedding_threshold
112
+ @state = :half_open
113
+ @calm_buckets = 0
114
+ end
115
+ when :half_open
116
+ if rate >= shedding_threshold
117
+ open!(now)
118
+ else
119
+ calm_step!(now)
120
+ end
121
+ end
122
+
123
+ track_peak(rate)
124
+ end
125
+
126
+ def calm_step!(now)
127
+ @calm_buckets += 1
128
+ close!(now) if @calm_buckets >= CALM_BUCKETS_TO_CLOSE
129
+ end
130
+
131
+ def enter!(new_state, _now)
132
+ begin_episode! if @state == :closed
133
+ @state = new_state
134
+ @calm_buckets = 0
135
+ end
136
+
137
+ def open!(now)
138
+ begin_episode! if @state == :closed
139
+ @state = :open
140
+ @opened_at = now
141
+ @calm_buckets = 0
142
+ @episode[:reached_open] = true if @episode
143
+ end
144
+
145
+ # Mid-bucket fast trip — takes the mutex (rare: at most once per storm onset).
146
+ def trip_open!(now, count)
147
+ @mutex.synchronize do
148
+ return if @state == :open
149
+
150
+ begin_episode! if @state == :closed
151
+ track_peak(count / [ now - @bucket_start, 1.0 ].max)
152
+ @state = :open
153
+ @opened_at = now
154
+ @calm_buckets = 0
155
+ @episode[:reached_open] = true if @episode
156
+ end
157
+ end
158
+
159
+ def close!(_now)
160
+ @state = :closed
161
+ @calm_buckets = 0
162
+ @episode[:ended_at] = Time.current if @episode
163
+ end
164
+
165
+ def begin_episode!
166
+ @episode = {
167
+ started_at: Time.current,
168
+ ended_at: nil,
169
+ peak_rate_per_minute: 0,
170
+ reached_open: false
171
+ }
172
+ end
173
+
174
+ def track_peak(rate_per_second)
175
+ return unless @episode
176
+
177
+ per_minute = (rate_per_second * 60).round
178
+ @episode[:peak_rate_per_minute] = per_minute if per_minute > @episode[:peak_rate_per_minute]
179
+ end
180
+
181
+ def shedding_threshold
182
+ RailsErrorDashboard.configuration.storm_shedding_threshold_per_second.to_f
183
+ end
184
+
185
+ def open_threshold
186
+ RailsErrorDashboard.configuration.storm_open_threshold_per_second.to_f
187
+ end
188
+
189
+ def cooldown_seconds
190
+ RailsErrorDashboard.configuration.storm_cooldown_seconds.to_i
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ module StormProtection
6
+ # In-memory accumulator for events that are counted but not stored
7
+ # per-event (Layer 1 overflow and the breaker's count-only mode).
8
+ #
9
+ # Stores exact counts plus just enough identity to reconcile onto the
10
+ # right ErrorLog at flush time: the flush command recomputes the
11
+ # canonical error_hash from these parts (with application resolved in
12
+ # the background job, where DB access is allowed) and issues a single
13
+ # `occurrence_count = occurrence_count + N` UPDATE per fingerprint.
14
+ # Fingerprints first seen during count-only mode get a minimal ErrorLog
15
+ # created from the stored exemplar. Counting is exact — no extrapolation.
16
+ #
17
+ # Memory: bounded map; beyond the cap events land in a single overflow
18
+ # counter (still exact in total, anonymous in identity).
19
+ #
20
+ # Concurrency: snapshot! atomically swaps the whole map out via
21
+ # AtomicReference, so flushing never races with recording.
22
+ class CountBuffer
23
+ Entry = Struct.new(
24
+ :error_class, :message, :first_app_frame,
25
+ :controller_name, :action_name, :custom_hash,
26
+ :count, :first_seen_at, :last_seen_at
27
+ )
28
+
29
+ def initialize
30
+ reset!
31
+ end
32
+
33
+ def reset!
34
+ @map_ref = Concurrent::AtomicReference.new(Concurrent::Map.new)
35
+ @overflow = Concurrent::AtomicFixnum.new(0)
36
+ end
37
+
38
+ # Record one counted-not-stored event.
39
+ # @param gate_key [String] cheap in-process bucketing key
40
+ # @param parts [Hash] identity parts captured at the gate
41
+ def record(gate_key, parts)
42
+ map = @map_ref.get
43
+ entry = map[gate_key]
44
+
45
+ unless entry
46
+ if map.size >= max_tracked
47
+ @overflow.increment
48
+ return
49
+ end
50
+ entry = map.compute_if_absent(gate_key) do
51
+ Entry.new(
52
+ parts[:error_class], parts[:message], parts[:first_app_frame],
53
+ parts[:controller_name], parts[:action_name], parts[:custom_hash],
54
+ Concurrent::AtomicFixnum.new(0), Time.current, Time.current
55
+ )
56
+ end
57
+ end
58
+
59
+ entry.count.increment
60
+ entry.last_seen_at = Time.current
61
+ end
62
+
63
+ def any?
64
+ @overflow.value.positive? || !@map_ref.get.empty?
65
+ end
66
+
67
+ # Atomically swap the buffer out and return serializable entry hashes.
68
+ # @return [Hash] { entries: Array<Hash>, overflow: Integer }
69
+ def snapshot!
70
+ old_map = @map_ref.get_and_set(Concurrent::Map.new)
71
+ overflow = @overflow.value
72
+ @overflow.update { |v| v - overflow }
73
+
74
+ entries = []
75
+ old_map.each_pair do |_key, entry|
76
+ entries << {
77
+ "error_class" => entry.error_class,
78
+ "message" => entry.message,
79
+ "first_app_frame" => entry.first_app_frame,
80
+ "controller_name" => entry.controller_name,
81
+ "action_name" => entry.action_name,
82
+ "custom_hash" => entry.custom_hash,
83
+ "count" => entry.count.value,
84
+ "first_seen_at" => entry.first_seen_at.iso8601,
85
+ "last_seen_at" => entry.last_seen_at.iso8601
86
+ }
87
+ end
88
+
89
+ { entries: entries, overflow: overflow }
90
+ end
91
+
92
+ private
93
+
94
+ def max_tracked
95
+ RailsErrorDashboard.configuration.storm_max_tracked_fingerprints.to_i
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end