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.
- checksums.yaml +4 -4
- data/README.md +54 -1
- data/app/controllers/rails_error_dashboard/application_controller.rb +5 -0
- data/app/controllers/rails_error_dashboard/errors_controller.rb +14 -3
- data/app/jobs/rails_error_dashboard/rack_attack_flush_job.rb +31 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +30 -0
- data/app/jobs/rails_error_dashboard/storm_flush_job.rb +19 -0
- data/app/jobs/rails_error_dashboard/storm_notification_job.rb +74 -0
- data/app/models/rails_error_dashboard/rack_attack_event.rb +36 -0
- data/app/models/rails_error_dashboard/storm_event.rb +34 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +22 -1
- data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +4 -6
- data/app/views/rails_error_dashboard/errors/storms.html.erb +91 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260306000002_add_instance_variables_to_error_logs.rb +7 -1
- data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +4 -0
- data/db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb +4 -0
- data/db/migrate/20260613000001_create_storm_events.rb +28 -0
- data/db/migrate/20260730000001_create_rails_error_dashboard_rack_attack_events.rb +45 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +36 -0
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +84 -0
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +188 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +70 -12
- data/lib/rails_error_dashboard/configuration.rb +78 -7
- data/lib/rails_error_dashboard/engine.rb +2 -2
- data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +55 -44
- data/lib/rails_error_dashboard/queries/storm_history.rb +39 -0
- data/lib/rails_error_dashboard/services/rack_attack_tracker.rb +194 -0
- data/lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb +195 -0
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +100 -0
- data/lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb +123 -0
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +258 -0
- data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +12 -0
- data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +16 -2
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +8 -0
- metadata +37 -18
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module RailsErrorDashboard
|
|
4
4
|
module Queries
|
|
5
|
-
# Query: Aggregate Rack Attack events from
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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 =
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
crumbs = parse_breadcrumbs(error_log.breadcrumbs)
|
|
39
|
-
next if crumbs.empty?
|
|
45
|
+
grouped = {}
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
r[:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|