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
@@ -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 = {
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.8.1"
2
+ VERSION = "0.8.3"
3
3
  end
@@ -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.1
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.0
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.0
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 for solo founders, indie hackers, and small teams. Exception
230
- monitoring with beautiful dashboard UI, multi-channel notifications (Slack, Email,
231
- Discord, PagerDuty), platform detection (iOS/Android/Web/API), advanced analytics,
232
- workflow management, and cause chain capture. A self-hosted Sentry alternative with
233
- 5-minute setup that works out-of-the-box. Production error monitoring for Rails
234
- 7.0-8.1. BETA: API may change before v1.0.0. Live demo: https://rails-error-dashboard.anjan.dev
235
- (gandalf/youshallnotpass)'
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.1
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 and exception monitoring for Rails. Free, forever.
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: []