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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
module StormProtection
|
|
6
|
+
# Layer 1: per-fingerprint rate limiting with graceful degradation.
|
|
7
|
+
#
|
|
8
|
+
# Each fingerprint gets a 60-second window. Within the window:
|
|
9
|
+
# - first N events → :full (everything captured)
|
|
10
|
+
# - past N, every Mth event → :lite (row captured, context shed)
|
|
11
|
+
# - everything else → :count_only (in-memory count, flushed later)
|
|
12
|
+
#
|
|
13
|
+
# The first event of every window is ALWAYS at least :lite, so a
|
|
14
|
+
# melting-down fingerprint still has a fresh exemplar each minute —
|
|
15
|
+
# deterministic, unlike rand-based sampling.
|
|
16
|
+
#
|
|
17
|
+
# Calm-mode adaptive context sampling rides the same entries: after K
|
|
18
|
+
# full-context captures per fingerprint per day, context is captured
|
|
19
|
+
# only every Mth time (an error firing 1000×/day doesn't need 1000
|
|
20
|
+
# breadcrumb trails). Occurrence rows are unaffected in calm mode.
|
|
21
|
+
#
|
|
22
|
+
# Concurrency: entries are mutable structs in a Concurrent::Map with
|
|
23
|
+
# plain (unlocked) field increments — races can miscount by a handful
|
|
24
|
+
# of events, which is acceptable for rate limiting. No mutex anywhere.
|
|
25
|
+
#
|
|
26
|
+
# Memory: the map is bounded. Once full, unseen fingerprints are NOT
|
|
27
|
+
# tracked and decide as :full — in calm weather that's harmless; in a
|
|
28
|
+
# storm of unique fingerprints the global breaker (Layer 2) takes over.
|
|
29
|
+
class FingerprintBuckets
|
|
30
|
+
WINDOW_SECONDS = 60
|
|
31
|
+
DAY_SECONDS = 86_400
|
|
32
|
+
|
|
33
|
+
Entry = Struct.new(:window_start, :window_count, :day_start, :day_full_context_count)
|
|
34
|
+
|
|
35
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
36
|
+
@clock = clock
|
|
37
|
+
reset!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reset!
|
|
41
|
+
@entries = Concurrent::Map.new
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Decide capture fidelity for one event of this fingerprint.
|
|
45
|
+
# @return [Symbol] :full, :lite, or :count_only
|
|
46
|
+
def decide(gate_key)
|
|
47
|
+
now = @clock.call
|
|
48
|
+
entry = fetch_entry(gate_key, now)
|
|
49
|
+
return :full unless entry # map full — Layer 2 owns the storm case
|
|
50
|
+
|
|
51
|
+
roll_windows(entry, now)
|
|
52
|
+
entry.window_count += 1
|
|
53
|
+
n = entry.window_count
|
|
54
|
+
|
|
55
|
+
if n <= full_per_minute
|
|
56
|
+
decide_calm_context(entry)
|
|
57
|
+
elsif n == full_per_minute + 1 || ((n - full_per_minute) % keep_every).zero?
|
|
58
|
+
:lite
|
|
59
|
+
else
|
|
60
|
+
:count_only
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def fetch_entry(gate_key, now)
|
|
67
|
+
existing = @entries[gate_key]
|
|
68
|
+
return existing if existing
|
|
69
|
+
|
|
70
|
+
# Bounded: never insert past the cap (size check is approximate
|
|
71
|
+
# under concurrency — a few entries over the cap is fine)
|
|
72
|
+
return nil if @entries.size >= max_tracked
|
|
73
|
+
|
|
74
|
+
@entries.compute_if_absent(gate_key) { Entry.new(now, 0, now, 0) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def roll_windows(entry, now)
|
|
78
|
+
if now - entry.window_start >= WINDOW_SECONDS
|
|
79
|
+
entry.window_start = now
|
|
80
|
+
entry.window_count = 0
|
|
81
|
+
end
|
|
82
|
+
if now - entry.day_start >= DAY_SECONDS
|
|
83
|
+
entry.day_start = now
|
|
84
|
+
entry.day_full_context_count = 0
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Under the per-minute cap: full context unless this fingerprint has
|
|
89
|
+
# already produced plenty of full-context captures today.
|
|
90
|
+
def decide_calm_context(entry)
|
|
91
|
+
entry.day_full_context_count += 1
|
|
92
|
+
k = entry.day_full_context_count
|
|
93
|
+
|
|
94
|
+
if k <= context_threshold_per_day || (k % context_keep_every).zero?
|
|
95
|
+
:full
|
|
96
|
+
else
|
|
97
|
+
:lite
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def full_per_minute
|
|
102
|
+
RailsErrorDashboard.configuration.storm_fingerprint_full_per_minute.to_i
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def keep_every
|
|
106
|
+
[ RailsErrorDashboard.configuration.storm_occurrence_sample_keep_every.to_i, 1 ].max
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def context_threshold_per_day
|
|
110
|
+
RailsErrorDashboard.configuration.context_sampling_threshold_per_day.to_i
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def context_keep_every
|
|
114
|
+
[ RailsErrorDashboard.configuration.context_sampling_keep_every.to_i, 1 ].max
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def max_tracked
|
|
118
|
+
RailsErrorDashboard.configuration.storm_max_tracked_fingerprints.to_i
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
module StormProtection
|
|
6
|
+
# Facade for the storm-protection hot path. One call per capture attempt:
|
|
7
|
+
#
|
|
8
|
+
# Gate.admit!(exception, context) # => :full | :lite | :count_only
|
|
9
|
+
#
|
|
10
|
+
# :full — capture everything (the normal path)
|
|
11
|
+
# :lite — capture the error + occurrence row, shed context
|
|
12
|
+
# (breadcrumbs / system health / locals / ivars)
|
|
13
|
+
# :count_only — nothing stored now; counted in memory, reconciled
|
|
14
|
+
# onto ErrorLog.occurrence_count by the flush job
|
|
15
|
+
#
|
|
16
|
+
# Safety contract (mirrors SwallowedExceptionTracker):
|
|
17
|
+
# - FAILS OPEN: any internal error → :full. Protection must never be
|
|
18
|
+
# the thing that loses an error.
|
|
19
|
+
# - Zero I/O on the hot path. The only DB-adjacent work is enqueueing
|
|
20
|
+
# the flush job at most once per flush interval.
|
|
21
|
+
# - Budget: digest + atomic increment + comparisons (~µs). Benchmarked.
|
|
22
|
+
# - Per-process state; Puma workers each run their own breaker. No
|
|
23
|
+
# thread-locals — shared atomics, so no Thread.current cleanup needed.
|
|
24
|
+
#
|
|
25
|
+
# IMPORTANT ordering: callers must run ExceptionFilter (ignore list +
|
|
26
|
+
# static sampling) BEFORE this gate — ignored exceptions must never
|
|
27
|
+
# count toward storm state or be reconciled into ErrorLogs.
|
|
28
|
+
class Gate
|
|
29
|
+
class << self
|
|
30
|
+
def admit!(exception, context = {})
|
|
31
|
+
return :full unless enabled?
|
|
32
|
+
|
|
33
|
+
state = breaker.record!
|
|
34
|
+
maybe_storm_notification(state)
|
|
35
|
+
|
|
36
|
+
decision = decide(state, exception, context)
|
|
37
|
+
maybe_flush!
|
|
38
|
+
decision
|
|
39
|
+
rescue => e
|
|
40
|
+
RailsErrorDashboard::Logger.error(
|
|
41
|
+
"[RailsErrorDashboard] StormProtection failed open: #{e.class} - #{e.message}"
|
|
42
|
+
)
|
|
43
|
+
:full
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# While the breaker is not closed, per-error notifications are
|
|
47
|
+
# suppressed (a single storm notification replaces them).
|
|
48
|
+
def notifications_suppressed?
|
|
49
|
+
enabled? && breaker.state != :closed
|
|
50
|
+
rescue
|
|
51
|
+
false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Always-on cap for auto-created issues (a storm of NEW critical
|
|
55
|
+
# fingerprints must not open 500 GitHub/Linear issues). Token
|
|
56
|
+
# bucket: N per rolling window, per process. Each call consumes a
|
|
57
|
+
# token — call only when actually about to create an issue.
|
|
58
|
+
def issue_creation_allowed?
|
|
59
|
+
return true unless enabled?
|
|
60
|
+
|
|
61
|
+
now = monotonic_now
|
|
62
|
+
window = RailsErrorDashboard.configuration.auto_issue_rate_limit_window_minutes.to_i * 60
|
|
63
|
+
limit = RailsErrorDashboard.configuration.auto_issue_rate_limit_count.to_i
|
|
64
|
+
|
|
65
|
+
@issue_window_start ||= now
|
|
66
|
+
@issue_window_count ||= Concurrent::AtomicFixnum.new(0)
|
|
67
|
+
|
|
68
|
+
if now - @issue_window_start >= window
|
|
69
|
+
@issue_window_start = now
|
|
70
|
+
@issue_window_count = Concurrent::AtomicFixnum.new(0)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@issue_window_count.increment <= limit
|
|
74
|
+
rescue
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def state
|
|
79
|
+
enabled? ? breaker.state : :closed
|
|
80
|
+
rescue
|
|
81
|
+
:closed
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Exposed for the flush job (episode metadata for storm_events).
|
|
85
|
+
def breaker
|
|
86
|
+
@breaker ||= CircuitBreaker.new
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def count_buffer
|
|
90
|
+
@count_buffer ||= CountBuffer.new
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def fingerprint_buckets
|
|
94
|
+
@fingerprint_buckets ||= FingerprintBuckets.new
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Test hook + fork hygiene: fresh state, no leftover episodes.
|
|
98
|
+
def reset!
|
|
99
|
+
@breaker = nil
|
|
100
|
+
@count_buffer = nil
|
|
101
|
+
@fingerprint_buckets = nil
|
|
102
|
+
@probe_counter = nil
|
|
103
|
+
@issue_window_start = nil
|
|
104
|
+
@issue_window_count = nil
|
|
105
|
+
@last_flush = nil
|
|
106
|
+
@storm_notified_episode = nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def enabled?
|
|
112
|
+
RailsErrorDashboard.configuration.enable_storm_protection
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# All identity computation happens here, once, in locals — no
|
|
116
|
+
# shared mutable caches (thread safety by construction).
|
|
117
|
+
def decide(state, exception, context)
|
|
118
|
+
case state
|
|
119
|
+
when :open
|
|
120
|
+
count!(exception, context)
|
|
121
|
+
:count_only
|
|
122
|
+
when :half_open
|
|
123
|
+
# Probe: a trickle of :lite captures tells us whether the storm
|
|
124
|
+
# has actually subsided; everything else stays counted.
|
|
125
|
+
if (probe_counter.increment % 10).zero?
|
|
126
|
+
:lite
|
|
127
|
+
else
|
|
128
|
+
count!(exception, context)
|
|
129
|
+
:count_only
|
|
130
|
+
end
|
|
131
|
+
when :shedding
|
|
132
|
+
# Never :full while shedding — context capture is request-thread
|
|
133
|
+
# CPU we can't afford. Buckets still apply their per-fingerprint
|
|
134
|
+
# row sampling underneath.
|
|
135
|
+
parts = gate_parts(exception, context)
|
|
136
|
+
if fingerprint_buckets.decide(gate_key(parts)) == :count_only
|
|
137
|
+
count_buffer.record(gate_key(parts), parts)
|
|
138
|
+
:count_only
|
|
139
|
+
else
|
|
140
|
+
:lite
|
|
141
|
+
end
|
|
142
|
+
else # :closed
|
|
143
|
+
parts = gate_parts(exception, context)
|
|
144
|
+
decision = fingerprint_buckets.decide(gate_key(parts))
|
|
145
|
+
count_buffer.record(gate_key(parts), parts) if decision == :count_only
|
|
146
|
+
decision
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def count!(exception, context)
|
|
151
|
+
parts = gate_parts(exception, context)
|
|
152
|
+
count_buffer.record(gate_key(parts), parts)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Cheap in-process bucketing key. Deliberately NOT the canonical
|
|
156
|
+
# error_hash (that needs application_id = DB); the flush job
|
|
157
|
+
# recomputes the canonical hash from the stored parts.
|
|
158
|
+
def gate_key(parts)
|
|
159
|
+
parts[:gate_key] ||= parts[:custom_hash] || Digest::SHA256.hexdigest(
|
|
160
|
+
"#{parts[:error_class]}|#{ErrorHashGenerator.normalize_message(parts[:message])}|" \
|
|
161
|
+
"#{parts[:first_app_frame]}|#{parts[:controller_name]}|#{parts[:action_name]}"
|
|
162
|
+
)[0..15]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def gate_parts(exception, context)
|
|
166
|
+
{
|
|
167
|
+
error_class: exception.class.name,
|
|
168
|
+
message: exception.message.to_s[0, 500],
|
|
169
|
+
first_app_frame: ErrorHashGenerator.extract_app_frame_from_locations(exception) ||
|
|
170
|
+
ErrorHashGenerator.extract_app_frame(exception.backtrace),
|
|
171
|
+
controller_name: context[:controller_name]&.to_s,
|
|
172
|
+
action_name: context[:action_name]&.to_s,
|
|
173
|
+
custom_hash: custom_hash_for(exception, context)
|
|
174
|
+
}
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# When a custom fingerprint lambda is configured the canonical hash
|
|
178
|
+
# doesn't include application_id, so the gate can compute it exactly
|
|
179
|
+
# — the flush job then reconciles by hash directly.
|
|
180
|
+
def custom_hash_for(exception, context)
|
|
181
|
+
return nil unless RailsErrorDashboard.configuration.custom_fingerprint
|
|
182
|
+
|
|
183
|
+
result = RailsErrorDashboard.configuration.custom_fingerprint.call(exception, context)
|
|
184
|
+
return nil unless result.is_a?(String) && !result.empty?
|
|
185
|
+
|
|
186
|
+
Digest::SHA256.hexdigest(result)[0..15]
|
|
187
|
+
rescue
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def probe_counter
|
|
192
|
+
@probe_counter ||= Concurrent::AtomicFixnum.new(0)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Piggyback flush (SwallowedExceptionTracker pattern): cheap
|
|
196
|
+
# timestamp check per admit; enqueue at most once per interval.
|
|
197
|
+
def maybe_flush!
|
|
198
|
+
now = monotonic_now
|
|
199
|
+
interval = RailsErrorDashboard.configuration.storm_flush_interval_seconds.to_i
|
|
200
|
+
return if now - (@last_flush ||= now) < interval
|
|
201
|
+
return unless count_buffer.any? || breaker.episode_snapshot
|
|
202
|
+
|
|
203
|
+
@last_flush = now
|
|
204
|
+
snapshot = count_buffer.snapshot!
|
|
205
|
+
episode = breaker.episode_snapshot
|
|
206
|
+
breaker.clear_closed_episode!
|
|
207
|
+
|
|
208
|
+
StormFlushJob.perform_later(
|
|
209
|
+
entries: snapshot[:entries],
|
|
210
|
+
overflow: snapshot[:overflow],
|
|
211
|
+
episode: serialize_episode(episode)
|
|
212
|
+
)
|
|
213
|
+
rescue => e
|
|
214
|
+
RailsErrorDashboard::Logger.error(
|
|
215
|
+
"[RailsErrorDashboard] Storm flush enqueue failed: #{e.class} - #{e.message}"
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def serialize_episode(episode)
|
|
220
|
+
return nil unless episode
|
|
221
|
+
|
|
222
|
+
{
|
|
223
|
+
"started_at" => episode[:started_at]&.iso8601,
|
|
224
|
+
"ended_at" => episode[:ended_at]&.iso8601,
|
|
225
|
+
"peak_rate_per_minute" => episode[:peak_rate_per_minute],
|
|
226
|
+
"reached_open" => episode[:reached_open]
|
|
227
|
+
}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# One notification per storm episode, on the first transition out
|
|
231
|
+
# of :closed.
|
|
232
|
+
def maybe_storm_notification(state)
|
|
233
|
+
return if state == :closed
|
|
234
|
+
return unless RailsErrorDashboard.configuration.storm_notification
|
|
235
|
+
|
|
236
|
+
episode = breaker.episode_snapshot
|
|
237
|
+
return unless episode
|
|
238
|
+
return if @storm_notified_episode == episode[:started_at]
|
|
239
|
+
|
|
240
|
+
@storm_notified_episode = episode[:started_at]
|
|
241
|
+
StormNotificationJob.perform_later(
|
|
242
|
+
started_at: episode[:started_at].iso8601,
|
|
243
|
+
state: state.to_s
|
|
244
|
+
)
|
|
245
|
+
rescue => e
|
|
246
|
+
RailsErrorDashboard::Logger.error(
|
|
247
|
+
"[RailsErrorDashboard] Storm notification enqueue failed: #{e.class} - #{e.message}"
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def monotonic_now
|
|
252
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -13,6 +13,18 @@ module RailsErrorDashboard
|
|
|
13
13
|
# Called when a new error is first logged
|
|
14
14
|
def on_error_logged(error_log)
|
|
15
15
|
return unless should_auto_create?(error_log)
|
|
16
|
+
|
|
17
|
+
# Always-on rate cap (default 5 per 10 min): a storm of NEW distinct
|
|
18
|
+
# fingerprints must not open hundreds of issues. Checked LAST so a
|
|
19
|
+
# token is only consumed when we were actually about to create.
|
|
20
|
+
unless Services::StormProtection::Gate.issue_creation_allowed?
|
|
21
|
+
RailsErrorDashboard::Logger.warn(
|
|
22
|
+
"[RailsErrorDashboard] Auto-issue creation rate limit reached — " \
|
|
23
|
+
"skipping issue for ErrorLog ##{error_log.id} (#{error_log.error_type})"
|
|
24
|
+
)
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
16
28
|
dashboard_url = Services::NotificationHelpers.dashboard_url(error_log)
|
|
17
29
|
CreateIssueJob.perform_later(error_log.id, dashboard_url: dashboard_url)
|
|
18
30
|
rescue => e
|
|
@@ -63,8 +63,6 @@ module RailsErrorDashboard
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def handle_rack_attack(event, event_name)
|
|
66
|
-
return unless Services::BreadcrumbCollector.current_buffer
|
|
67
|
-
|
|
68
66
|
request = event.payload[:request]
|
|
69
67
|
return unless request
|
|
70
68
|
|
|
@@ -76,6 +74,22 @@ module RailsErrorDashboard
|
|
|
76
74
|
path = request.respond_to?(:path) ? request.path.to_s : ""
|
|
77
75
|
method = request.respond_to?(:request_method) ? request.request_method.to_s : ""
|
|
78
76
|
|
|
77
|
+
# Persist independently of error capture. A throttled request returns
|
|
78
|
+
# HTTP 429 and raises nothing, so it would otherwise never reach the
|
|
79
|
+
# database — breadcrumbs are only harvested by LogError (issue #143).
|
|
80
|
+
Services::RackAttackTracker.record(
|
|
81
|
+
rule: rule,
|
|
82
|
+
match_type: match_type,
|
|
83
|
+
discriminator: discriminator,
|
|
84
|
+
path: path,
|
|
85
|
+
http_method: method
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Also record a breadcrumb so the event still shows up in the activity
|
|
89
|
+
# trail on the error detail page when an error DOES occur in the same
|
|
90
|
+
# request. Requires an active request-scoped buffer.
|
|
91
|
+
return unless Services::BreadcrumbCollector.current_buffer
|
|
92
|
+
|
|
79
93
|
message = "#{match_type}: #{rule} (#{discriminator}) #{method} #{path}"
|
|
80
94
|
|
|
81
95
|
metadata = {
|
|
@@ -69,17 +69,23 @@ require "rails_error_dashboard/services/github_issue_client"
|
|
|
69
69
|
require "rails_error_dashboard/services/gitlab_issue_client"
|
|
70
70
|
require "rails_error_dashboard/services/codeberg_issue_client"
|
|
71
71
|
require "rails_error_dashboard/services/linear_issue_client"
|
|
72
|
+
require "rails_error_dashboard/services/storm_protection/circuit_breaker"
|
|
73
|
+
require "rails_error_dashboard/services/storm_protection/fingerprint_buckets"
|
|
74
|
+
require "rails_error_dashboard/services/storm_protection/count_buffer"
|
|
75
|
+
require "rails_error_dashboard/services/storm_protection/gate"
|
|
72
76
|
require "rails_error_dashboard/services/database_health_inspector"
|
|
73
77
|
require "rails_error_dashboard/services/cache_analyzer"
|
|
74
78
|
require "rails_error_dashboard/services/llm_summary"
|
|
75
79
|
require "rails_error_dashboard/services/variable_serializer"
|
|
76
80
|
require "rails_error_dashboard/services/local_variable_capturer"
|
|
77
81
|
require "rails_error_dashboard/services/swallowed_exception_tracker"
|
|
82
|
+
require "rails_error_dashboard/services/rack_attack_tracker"
|
|
78
83
|
require "rails_error_dashboard/services/crash_capture"
|
|
79
84
|
require "rails_error_dashboard/services/diagnostic_dump_generator"
|
|
80
85
|
require "rails_error_dashboard/services/coverage_tracker"
|
|
81
86
|
require "rails_error_dashboard/services/digest_builder"
|
|
82
87
|
require "rails_error_dashboard/queries/user_impact_summary"
|
|
88
|
+
require "rails_error_dashboard/queries/storm_history"
|
|
83
89
|
require "rails_error_dashboard/subscribers/breadcrumb_subscriber"
|
|
84
90
|
require "rails_error_dashboard/subscribers/rack_attack_subscriber"
|
|
85
91
|
require "rails_error_dashboard/subscribers/action_cable_subscriber"
|
|
@@ -97,6 +103,7 @@ require "rails_error_dashboard/commands/log_error"
|
|
|
97
103
|
require "rails_error_dashboard/commands/resolve_error"
|
|
98
104
|
require "rails_error_dashboard/commands/create_issue"
|
|
99
105
|
require "rails_error_dashboard/commands/link_existing_issue"
|
|
106
|
+
require "rails_error_dashboard/commands/flush_storm_counts"
|
|
100
107
|
require "rails_error_dashboard/services/issue_body_formatter"
|
|
101
108
|
require "rails_error_dashboard/commands/batch_resolve_errors"
|
|
102
109
|
require "rails_error_dashboard/commands/batch_delete_errors"
|
|
@@ -118,6 +125,7 @@ require "rails_error_dashboard/commands/find_or_create_application"
|
|
|
118
125
|
require "rails_error_dashboard/commands/upsert_cascade_pattern"
|
|
119
126
|
require "rails_error_dashboard/commands/upsert_baseline"
|
|
120
127
|
require "rails_error_dashboard/commands/flush_swallowed_exceptions"
|
|
128
|
+
require "rails_error_dashboard/commands/flush_rack_attack_events"
|
|
121
129
|
require "rails_error_dashboard/queries/errors_list"
|
|
122
130
|
require "rails_error_dashboard/queries/dashboard_stats"
|
|
123
131
|
require "rails_error_dashboard/queries/analytics_stats"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_error_dashboard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.8.
|
|
4
|
+
version: 0.8.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anjan Jagirdar
|
|
@@ -57,20 +57,14 @@ dependencies:
|
|
|
57
57
|
requirements:
|
|
58
58
|
- - "~>"
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: 1.3
|
|
61
|
-
- - "<"
|
|
62
|
-
- !ruby/object:Gem::Version
|
|
63
|
-
version: 1.3.7
|
|
60
|
+
version: '1.3'
|
|
64
61
|
type: :runtime
|
|
65
62
|
prerelease: false
|
|
66
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
67
64
|
requirements:
|
|
68
65
|
- - "~>"
|
|
69
66
|
- !ruby/object:Gem::Version
|
|
70
|
-
version: 1.3
|
|
71
|
-
- - "<"
|
|
72
|
-
- !ruby/object:Gem::Version
|
|
73
|
-
version: 1.3.7
|
|
67
|
+
version: '1.3'
|
|
74
68
|
- !ruby/object:Gem::Dependency
|
|
75
69
|
name: rspec-rails
|
|
76
70
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -226,13 +220,21 @@ dependencies:
|
|
|
226
220
|
- !ruby/object:Gem::Version
|
|
227
221
|
version: '0.15'
|
|
228
222
|
description: 'Own your errors. Own your stack. A fully open-source, self-hosted error
|
|
229
|
-
tracking Rails engine
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
223
|
+
tracking Rails engine — a free Sentry alternative that runs entirely inside your
|
|
224
|
+
own process, with no external services and zero recurring cost. Captures what SaaS
|
|
225
|
+
tools charge extra for: local and instance variables at the moment of failure (via
|
|
226
|
+
TracePoint), exception cause chains, swallowed-exception detection, breadcrumbs,
|
|
227
|
+
and system-health snapshots (GC, memory, threads, connection pool, Puma). Plus N+1
|
|
228
|
+
query detection, storm protection (a circuit breaker that shields your app from
|
|
229
|
+
error floods, ON by default), multi-app support, error sampling, and async logging
|
|
230
|
+
via Sidekiq, SolidQueue, or GoodJob. Runs on SQLite, PostgreSQL, or MySQL/Trilogy
|
|
231
|
+
— in your app''s existing database or an isolated separate error database. Beautiful
|
|
232
|
+
dashboard UI (dark/light), multi-channel notifications (Slack, Email, Discord, PagerDuty,
|
|
233
|
+
webhooks), workflow management, advanced analytics, platform detection (iOS/Android/Web/API),
|
|
234
|
+
and two-way issue sync with GitHub, GitLab, Codeberg, and Linear. Also: LLM observability,
|
|
235
|
+
AI-powered debugging help, and OpenTelemetry span export. 5-minute setup, works
|
|
236
|
+
out-of-the-box. Rails 7.0-8.1, Ruby 3.2-4.0. BETA: API may change before v1.0.0.
|
|
237
|
+
Live demo: https://rails-error-dashboard.anjan.dev (gandalf/youshallnotpass)'
|
|
236
238
|
email:
|
|
237
239
|
- anjan.jagirdar@gmail.com
|
|
238
240
|
executables: []
|
|
@@ -258,10 +260,13 @@ files:
|
|
|
258
260
|
- app/jobs/rails_error_dashboard/discord_error_notification_job.rb
|
|
259
261
|
- app/jobs/rails_error_dashboard/email_error_notification_job.rb
|
|
260
262
|
- app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb
|
|
263
|
+
- app/jobs/rails_error_dashboard/rack_attack_flush_job.rb
|
|
261
264
|
- app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb
|
|
262
265
|
- app/jobs/rails_error_dashboard/retention_cleanup_job.rb
|
|
263
266
|
- app/jobs/rails_error_dashboard/scheduled_digest_job.rb
|
|
264
267
|
- app/jobs/rails_error_dashboard/slack_error_notification_job.rb
|
|
268
|
+
- app/jobs/rails_error_dashboard/storm_flush_job.rb
|
|
269
|
+
- app/jobs/rails_error_dashboard/storm_notification_job.rb
|
|
265
270
|
- app/jobs/rails_error_dashboard/swallowed_exception_flush_job.rb
|
|
266
271
|
- app/jobs/rails_error_dashboard/webhook_error_notification_job.rb
|
|
267
272
|
- app/mailers/rails_error_dashboard/application_mailer.rb
|
|
@@ -275,6 +280,8 @@ files:
|
|
|
275
280
|
- app/models/rails_error_dashboard/error_log.rb
|
|
276
281
|
- app/models/rails_error_dashboard/error_logs_record.rb
|
|
277
282
|
- app/models/rails_error_dashboard/error_occurrence.rb
|
|
283
|
+
- app/models/rails_error_dashboard/rack_attack_event.rb
|
|
284
|
+
- app/models/rails_error_dashboard/storm_event.rb
|
|
278
285
|
- app/models/rails_error_dashboard/swallowed_exception.rb
|
|
279
286
|
- app/views/layouts/rails_error_dashboard.html.erb
|
|
280
287
|
- app/views/rails_error_dashboard/digest_mailer/digest_summary.html.erb
|
|
@@ -321,6 +328,7 @@ files:
|
|
|
321
328
|
- app/views/rails_error_dashboard/errors/settings.html.erb
|
|
322
329
|
- app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb
|
|
323
330
|
- app/views/rails_error_dashboard/errors/show.html.erb
|
|
331
|
+
- app/views/rails_error_dashboard/errors/storms.html.erb
|
|
324
332
|
- app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb
|
|
325
333
|
- app/views/rails_error_dashboard/errors/user_impact.html.erb
|
|
326
334
|
- config/routes.rb
|
|
@@ -359,6 +367,8 @@ files:
|
|
|
359
367
|
- db/migrate/20260325000001_fix_swallowed_exceptions_index_for_mysql.rb
|
|
360
368
|
- db/migrate/20260326000001_add_issue_tracking_to_error_logs.rb
|
|
361
369
|
- db/migrate/20260503000001_backfill_resolved_status.rb
|
|
370
|
+
- db/migrate/20260613000001_create_storm_events.rb
|
|
371
|
+
- db/migrate/20260730000001_create_rails_error_dashboard_rack_attack_events.rb
|
|
362
372
|
- lib/generators/rails_error_dashboard/install/install_generator.rb
|
|
363
373
|
- lib/generators/rails_error_dashboard/install/templates/README
|
|
364
374
|
- lib/generators/rails_error_dashboard/install/templates/initializer.rb
|
|
@@ -376,6 +386,8 @@ files:
|
|
|
376
386
|
- lib/rails_error_dashboard/commands/create_issue.rb
|
|
377
387
|
- lib/rails_error_dashboard/commands/find_or_create_application.rb
|
|
378
388
|
- lib/rails_error_dashboard/commands/find_or_increment_error.rb
|
|
389
|
+
- lib/rails_error_dashboard/commands/flush_rack_attack_events.rb
|
|
390
|
+
- lib/rails_error_dashboard/commands/flush_storm_counts.rb
|
|
379
391
|
- lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb
|
|
380
392
|
- lib/rails_error_dashboard/commands/increment_cascade_detection.rb
|
|
381
393
|
- lib/rails_error_dashboard/commands/link_existing_issue.rb
|
|
@@ -431,6 +443,7 @@ files:
|
|
|
431
443
|
- lib/rails_error_dashboard/queries/recurring_issues.rb
|
|
432
444
|
- lib/rails_error_dashboard/queries/release_timeline.rb
|
|
433
445
|
- lib/rails_error_dashboard/queries/similar_errors.rb
|
|
446
|
+
- lib/rails_error_dashboard/queries/storm_history.rb
|
|
434
447
|
- lib/rails_error_dashboard/queries/swallowed_exception_summary.rb
|
|
435
448
|
- lib/rails_error_dashboard/queries/user_impact_summary.rb
|
|
436
449
|
- lib/rails_error_dashboard/services/analytics_cache_manager.rb
|
|
@@ -477,6 +490,7 @@ files:
|
|
|
477
490
|
- lib/rails_error_dashboard/services/pearson_correlation.rb
|
|
478
491
|
- lib/rails_error_dashboard/services/platform_detector.rb
|
|
479
492
|
- lib/rails_error_dashboard/services/priority_score_calculator.rb
|
|
493
|
+
- lib/rails_error_dashboard/services/rack_attack_tracker.rb
|
|
480
494
|
- lib/rails_error_dashboard/services/rspec_generator.rb
|
|
481
495
|
- lib/rails_error_dashboard/services/sensitive_data_filter.rb
|
|
482
496
|
- lib/rails_error_dashboard/services/severity_classifier.rb
|
|
@@ -484,6 +498,10 @@ files:
|
|
|
484
498
|
- lib/rails_error_dashboard/services/slack_payload_builder.rb
|
|
485
499
|
- lib/rails_error_dashboard/services/source_code_reader.rb
|
|
486
500
|
- lib/rails_error_dashboard/services/statistical_classifier.rb
|
|
501
|
+
- lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb
|
|
502
|
+
- lib/rails_error_dashboard/services/storm_protection/count_buffer.rb
|
|
503
|
+
- lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb
|
|
504
|
+
- lib/rails_error_dashboard/services/storm_protection/gate.rb
|
|
487
505
|
- lib/rails_error_dashboard/services/swallowed_exception_tracker.rb
|
|
488
506
|
- lib/rails_error_dashboard/services/system_health_snapshot.rb
|
|
489
507
|
- lib/rails_error_dashboard/services/variable_serializer.rb
|
|
@@ -512,7 +530,7 @@ metadata:
|
|
|
512
530
|
funding_uri: https://github.com/sponsors/AnjanJ
|
|
513
531
|
post_install_message: |
|
|
514
532
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
515
|
-
RED (Rails Error Dashboard) v0.8.
|
|
533
|
+
RED (Rails Error Dashboard) v0.8.3
|
|
516
534
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
517
535
|
|
|
518
536
|
First install:
|
|
@@ -550,5 +568,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
550
568
|
requirements: []
|
|
551
569
|
rubygems_version: 3.6.9
|
|
552
570
|
specification_version: 4
|
|
553
|
-
summary: Self-hosted error tracking
|
|
571
|
+
summary: Self-hosted error tracking for Rails — local variables, system health, separate
|
|
572
|
+
or shared database. A free, open-source Sentry alternative.
|
|
554
573
|
test_files: []
|