rails_error_dashboard 0.8.2 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c51d8221b7ca00ce0f9242f98f3594fd86028a2c98c464fcac28aac34f6d2a3b
4
- data.tar.gz: 597373253abc68befcf70edab809093c320740fca1a575bcc8cae7fd6ae7e3e5
3
+ metadata.gz: 270ac48ebee51c2f68cab29abdbf1169b7db7d7b860bf1fc5a43883960cdc0c6
4
+ data.tar.gz: 6def5b5a2bbcabffd1116f13607771f6923823dc65eec803e028ba911af85ec9
5
5
  SHA512:
6
- metadata.gz: 2bcae54eb97ce19345f5ca8a92e515c7b44d9957afc985885bda168afddadc24b3640b7fc3dcdfbe5e80637564975be851d7aeb2d2be14fbdfb8b38bf769300c
7
- data.tar.gz: f269598ef3e5c0b4a7761be4bb4ddeeef32f662dd18f6e75dca292f108804b8d73f4d95e0f30e86a550e51478ec66d43431bb722740caf7d0149f98addb9c042
6
+ metadata.gz: fd6984fc8ccb2a68f9543786ffd6157d1a975ed5d446a13e38b3a38b42cc93047117918186b7f3936c2ddc3a53c3609db63073abad5f25d02edba4961e7b1f30
7
+ data.tar.gz: 55ba8d78b0d06176ac80e63919cd29a6ca94423d8880712c8fb82979a17741b5881878ef2351efd89c468fbb3d03c6411c82c5c4668a687f4f441b512c169814
data/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  gem 'rails_error_dashboard'
14
14
  ```
15
15
 
16
- **5-minute setup** · **Works out-of-the-box** · **Postgres, MySQL/Trilogy, SQLite** · **No vendor lock-in**
16
+ **5-minute setup** · **Works out-of-the-box** · **PostgreSQL, MySQL/Trilogy, SQLite — shared or separate database** · **No vendor lock-in**
17
17
 
18
18
  [Full Documentation](https://anjanj.github.io/rails_error_dashboard/) · [Live Demo](https://rails-error-dashboard.anjan.dev) · [RubyGems](https://rubygems.org/gems/rails_error_dashboard)
19
19
 
@@ -602,6 +602,37 @@ end
602
602
 
603
603
  ---
604
604
 
605
+ ## FAQ
606
+
607
+ **Does Rails Error Dashboard support a separate database for errors?**
608
+ Yes. You can store errors in your app's existing database (shared) **or** in a dedicated, isolated database (separate). Set `config.use_separate_database = true` (or `USE_SEPARATE_ERROR_DB=true`) and point it at a separate connection — the engine routes all of its tables through `connects_to`, keeping error data fully isolated from your app data. Both modes are first-class and covered by the [Database Options guide](docs/guides/DATABASE_OPTIONS.md).
609
+
610
+ **Which databases does it work with?**
611
+ SQLite, PostgreSQL, and MySQL/Trilogy — in either shared or separate-database mode.
612
+
613
+ **Is this a self-hosted alternative to Sentry?**
614
+ Yes. It runs entirely inside your own Rails process — no external services, no SDK calling out, no per-event pricing. Error data never leaves your infrastructure.
615
+
616
+ **Does it capture local variables like Sentry?**
617
+ Yes — local **and** instance variables at the moment the exception is raised, via `TracePoint(:raise)`, with sensitive-data filtering and configurable limits. This is opt-in and a capability Sentry charges extra for.
618
+
619
+ **Will a flood of errors take down my app?**
620
+ No. Storm protection (a circuit breaker with adaptive sampling, **ON by default**) makes the gem degrade itself first during error floods — occurrence counts stay exact while it sheds the expensive work. Measured hot-path overhead is ~2.4µs/error.
621
+
622
+ **Does it work with my background jobs?**
623
+ Yes — it auto-detects and supports Sidekiq, SolidQueue, and GoodJob, and can log errors asynchronously through any of them.
624
+
625
+ **Does it work with my authentication?**
626
+ Yes — HTTP Basic Auth out of the box, or a custom `authenticate_with` lambda that integrates with Devise, Warden, or session-based auth.
627
+
628
+ **Can it track more than one app?**
629
+ Yes — multi-app support tracks errors from multiple Rails apps in one dashboard with per-app filtering.
630
+
631
+ **What Rails and Ruby versions are supported?**
632
+ Rails 7.0–8.1 and Ruby 3.2–4.0.
633
+
634
+ ---
635
+
605
636
  ## Documentation
606
637
 
607
638
  ### Getting Started
@@ -484,9 +484,8 @@ module RailsErrorDashboard
484
484
  end
485
485
 
486
486
  def rack_attack_summary
487
- unless RailsErrorDashboard.configuration.enable_rack_attack_tracking &&
488
- RailsErrorDashboard.configuration.enable_breadcrumbs
489
- flash[:alert] = "Rack Attack tracking is not enabled. Enable enable_rack_attack_tracking and enable_breadcrumbs in config/initializers/rails_error_dashboard.rb"
487
+ unless RailsErrorDashboard.configuration.enable_rack_attack_tracking
488
+ flash[:alert] = "Rack Attack tracking is not enabled. Set enable_rack_attack_tracking = true in config/initializers/rails_error_dashboard.rb"
490
489
  redirect_to errors_path(**app_context_params)
491
490
  return
492
491
  end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Job: Persist buffered Rack::Attack event counts to the database.
5
+ #
6
+ # Two usage modes:
7
+ # 1. With a counts hash — dispatched by RackAttackTracker's periodic flush.
8
+ # Zero I/O in the request path; all DB writes happen here.
9
+ # 2. Without arguments — scheduled periodic sweep that flushes the current
10
+ # thread's buffer (useful as a cron safety net for low-traffic apps where
11
+ # the flush interval may not be reached during a request).
12
+ #
13
+ # Example cron (via solid_queue or whenever):
14
+ # every 5.minutes { RailsErrorDashboard::RackAttackFlushJob.perform_later }
15
+ class RackAttackFlushJob < ApplicationJob
16
+ queue_as :default
17
+
18
+ def perform(counts = nil)
19
+ return unless RailsErrorDashboard.configuration.enable_rack_attack_tracking
20
+
21
+ if counts
22
+ # Mode 1: Persist provided snapshot (dispatched from tracker flush)
23
+ Commands::FlushRackAttackEvents.call(counts: counts)
24
+ else
25
+ # Mode 2: Flush current thread's buffer (scheduled cron safety net).
26
+ # sync: true because we are already off the request path.
27
+ Services::RackAttackTracker.flush!(sync: true)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -20,6 +20,12 @@ module RailsErrorDashboard
20
20
  return 0 if retention_days.blank?
21
21
 
22
22
  cutoff = retention_days.days.ago
23
+
24
+ # Rack Attack events live in their own table and expire independently of
25
+ # errors — clean them up BEFORE the early return below, which fires
26
+ # whenever no error logs happen to be expired.
27
+ cleanup_rack_attack_events(cutoff)
28
+
23
29
  expired_scope = ErrorLog.where("occurred_at < ?", cutoff)
24
30
  return 0 if expired_scope.none?
25
31
 
@@ -52,5 +58,29 @@ module RailsErrorDashboard
52
58
  RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Retention cleanup failed: #{e.class} - #{e.message}")
53
59
  0
54
60
  end
61
+
62
+ private
63
+
64
+ # Expire aggregated Rack Attack event rows. Isolated in its own rescue so a
65
+ # failure here (e.g. table not yet migrated) never blocks error cleanup.
66
+ def cleanup_rack_attack_events(cutoff)
67
+ return unless RailsErrorDashboard.configuration.enable_rack_attack_tracking
68
+ return unless RackAttackEvent.table_exists?
69
+
70
+ deleted = 0
71
+ RackAttackEvent.where("period_hour < ?", cutoff).in_batches(of: 1000) do |batch|
72
+ deleted += batch.delete_all
73
+ end
74
+
75
+ if deleted > 0
76
+ RailsErrorDashboard::Logger.info(
77
+ "[RailsErrorDashboard] Retention cleanup: deleted #{deleted} rack attack events"
78
+ )
79
+ end
80
+ rescue => e
81
+ RailsErrorDashboard::Logger.debug(
82
+ "[RailsErrorDashboard] Rack attack retention cleanup failed: #{e.class} - #{e.message}"
83
+ )
84
+ end
55
85
  end
56
86
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Stores aggregated Rack::Attack throttle/blocklist/track events per hourly bucket.
5
+ #
6
+ # Rack::Attack events are NOT errors — a throttled request returns HTTP 429 without
7
+ # raising. They are therefore persisted independently of error_logs rather than as
8
+ # a side-effect of error capture (see issue #143).
9
+ #
10
+ # Rows are aggregated hourly by (rule, match_type, discriminator, path) so that a
11
+ # rate-limit flood collapses to a handful of rows instead of one INSERT per request.
12
+ class RackAttackEvent < ErrorLogsRecord
13
+ self.table_name = "rails_error_dashboard_rack_attack_events"
14
+
15
+ # Event types emitted by Rack::Attack (v5.0+)
16
+ MATCH_TYPES = %w[throttle blocklist track safelist].freeze
17
+
18
+ belongs_to :application, optional: true
19
+
20
+ validates :rule, presence: true
21
+ validates :match_type, presence: true
22
+ validates :period_hour, presence: true
23
+ validates :event_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
24
+
25
+ scope :for_application, ->(app_id) { where(application_id: app_id) }
26
+ scope :since, ->(time) { where("period_hour >= ?", time) }
27
+ scope :recent, -> { order(period_hour: :desc) }
28
+ scope :throttles, -> { where(match_type: "throttle") }
29
+ scope :blocklists, -> { where(match_type: "blocklist") }
30
+
31
+ # Whether this event represents a hard block rather than a rate limit
32
+ def blocked?
33
+ match_type == "blocklist"
34
+ end
35
+ end
36
+ end
@@ -993,7 +993,7 @@ tr[data-red-row-href]:hover .sev-bar { opacity: 1 !important; }
993
993
  <% health_items << { path: job_health_summary_errors_path(nav_params), icon: 'bi-gear-wide-connected', label: 'Jobs' } %>
994
994
  <% health_items << { path: database_health_summary_errors_path(nav_params), icon: 'bi-database', label: 'Database' } %>
995
995
  <% end %>
996
- <% if RailsErrorDashboard.configuration.enable_rack_attack_tracking && RailsErrorDashboard.configuration.enable_breadcrumbs %>
996
+ <% if RailsErrorDashboard.configuration.enable_rack_attack_tracking %>
997
997
  <% health_items << { path: rack_attack_summary_errors_path(nav_params), icon: 'bi-shield-exclamation', label: 'Rate Limits' } %>
998
998
  <% end %>
999
999
  <% if RailsErrorDashboard.configuration.enable_actioncable_tracking && RailsErrorDashboard.configuration.enable_breadcrumbs %>
@@ -25,16 +25,16 @@
25
25
  <i class="bi bi-shield-check display-1 text-success mb-3"></i>
26
26
  <div class="red-empty-state-title">No Rate Limit Events Found</div>
27
27
  <p class="text-muted">
28
- No Rack Attack throttle, blocklist, or track events were detected in error breadcrumbs over the last <%= @days %> days.
28
+ No Rack Attack throttle, blocklist, or track events were recorded over the last <%= @days %> days.
29
29
  </p>
30
30
  <div class="card mx-auto" style="max-width: 500px;">
31
31
  <div class="card-body text-start">
32
32
  <h6>How Rack Attack tracking works:</h6>
33
33
  <ul class="mb-0">
34
- <li>Breadcrumbs must be enabled (<code>enable_breadcrumbs = true</code>)</li>
35
34
  <li>Rack Attack tracking must be enabled (<code>enable_rack_attack_tracking = true</code>)</li>
36
35
  <li>Rack Attack must be installed and configured in your app</li>
37
- <li>Throttle, blocklist, and track events are captured as breadcrumbs during requests that produce errors</li>
36
+ <li>Throttle, blocklist, and track events are recorded whenever a rule matches no error required</li>
37
+ <li>Events are buffered and written every <%= RailsErrorDashboard.configuration.rack_attack_flush_interval %>s, so allow a short delay before they appear</li>
38
38
  </ul>
39
39
  </div>
40
40
  </div>
@@ -86,7 +86,6 @@
86
86
  <th width="80">Count</th>
87
87
  <th width="80">IPs</th>
88
88
  <th>Top Path</th>
89
- <th width="80">Errors</th>
90
89
  <th width="140">Last Seen</th>
91
90
  </tr>
92
91
  </thead>
@@ -105,7 +104,6 @@
105
104
  <td><strong><%= event[:count] %></strong></td>
106
105
  <td><%= event[:unique_ips] %></td>
107
106
  <td><code><%= event[:top_path] %></code></td>
108
- <td><%= event[:error_count] %></td>
109
107
  <td><%= local_time_ago(event[:last_seen]) %></td>
110
108
  </tr>
111
109
  <% end %>
@@ -116,7 +114,7 @@
116
114
  <div class="card-footer border-top d-flex justify-content-between align-items-center">
117
115
  <div>
118
116
  <small class="text-muted">
119
- <i class="bi bi-lightbulb text-warning"></i> Rate limit events are captured when they coincide with errors. High counts may indicate abuse or misconfigured rules.
117
+ <i class="bi bi-lightbulb text-warning"></i> High counts may indicate abuse or misconfigured rules.
120
118
  </small>
121
119
  <small class="ms-3">
122
120
  <a href="https://github.com/rack/rack-attack" target="_blank" rel="noopener" class="text-decoration-none">
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRailsErrorDashboardRackAttackEvents < ActiveRecord::Migration[7.0]
4
+ def change
5
+ # Guard against the squashed schema migration having already created this
6
+ # table — without it, every later migration is silently cancelled.
7
+ return if table_exists?(:rails_error_dashboard_rack_attack_events)
8
+
9
+ create_table :rails_error_dashboard_rack_attack_events do |t|
10
+ t.string :rule, null: false, limit: 250
11
+ t.string :match_type, null: false, limit: 50
12
+ # discriminator and path are capped at 191 (not 250) to keep the unique
13
+ # upsert index within MySQL's 3072-byte utf8mb4 limit. Budget:
14
+ # 250+50+191+191 chars * 4 bytes + 2 length-prefix each = 2736 bytes.
15
+ # See issue #96 — the swallowed_exceptions index blew this limit at 5042.
16
+ t.string :discriminator, limit: 191
17
+ t.string :path, limit: 191
18
+ t.string :http_method, limit: 10
19
+ t.datetime :period_hour, null: false
20
+ t.integer :event_count, null: false, default: 0
21
+ t.datetime :last_seen_at
22
+ t.bigint :application_id
23
+ t.timestamps
24
+ end
25
+
26
+ add_index :rails_error_dashboard_rack_attack_events,
27
+ :period_hour,
28
+ name: "index_rack_attack_events_on_period_hour"
29
+
30
+ add_index :rails_error_dashboard_rack_attack_events,
31
+ [ :application_id, :period_hour ],
32
+ name: "index_rack_attack_events_on_app_and_hour"
33
+
34
+ add_index :rails_error_dashboard_rack_attack_events,
35
+ [ :rule, :period_hour ],
36
+ name: "index_rack_attack_events_on_rule_and_hour"
37
+
38
+ # http_method is intentionally NOT part of the upsert key — it would push
39
+ # the index over the MySQL byte limit and adds no aggregation value.
40
+ add_index :rails_error_dashboard_rack_attack_events,
41
+ [ :rule, :match_type, :discriminator, :path, :period_hour, :application_id ],
42
+ unique: true,
43
+ name: "index_rack_attack_events_upsert_key"
44
+ end
45
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Command: Upsert buffered Rack::Attack event counts into the database.
6
+ #
7
+ # Receives a snapshot hash from RackAttackTracker and merges it into
8
+ # hourly-bucketed rows. Uses find_or_initialize_by + increment for
9
+ # cross-database compatibility (no raw SQL upsert).
10
+ #
11
+ # counts keys: "rule\x1Fmatch_type\x1Fdiscriminator\x1Fpath\x1Fhttp_method"
12
+ class FlushRackAttackEvents
13
+ def self.call(counts:)
14
+ new(counts: counts).call
15
+ end
16
+
17
+ def initialize(counts:)
18
+ @counts = counts || {}
19
+ end
20
+
21
+ def call
22
+ return if @counts.empty?
23
+
24
+ period = Time.current.beginning_of_hour
25
+ app_id = current_application_id
26
+
27
+ @counts.each do |key, count|
28
+ rule, match_type, discriminator, path, http_method =
29
+ Services::RackAttackTracker.parse_key(key)
30
+
31
+ next if rule.blank? || match_type.blank?
32
+
33
+ upsert_event(
34
+ rule: rule,
35
+ match_type: match_type,
36
+ discriminator: discriminator,
37
+ path: path,
38
+ http_method: http_method,
39
+ period: period,
40
+ app_id: app_id,
41
+ count: count
42
+ )
43
+ end
44
+ rescue => e
45
+ RailsErrorDashboard::Logger.debug(
46
+ "[RailsErrorDashboard] FlushRackAttackEvents failed: #{e.class} - #{e.message}"
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def upsert_event(rule:, match_type:, discriminator:, path:, http_method:, period:, app_id:, count:)
53
+ # nil and "" must map to the same row — the unique index treats them as
54
+ # distinct in some adapters, so normalize blanks to nil consistently.
55
+ record = RackAttackEvent.find_or_initialize_by(
56
+ rule: rule,
57
+ match_type: match_type,
58
+ discriminator: discriminator.presence,
59
+ path: path.presence,
60
+ period_hour: period,
61
+ application_id: app_id
62
+ )
63
+
64
+ record.http_method = http_method.presence if record.http_method.blank?
65
+ record.event_count = (record.event_count || 0) + count
66
+ record.last_seen_at = Time.current
67
+ record.save!
68
+ rescue => e
69
+ RailsErrorDashboard::Logger.debug(
70
+ "[RailsErrorDashboard] FlushRackAttackEvents.upsert_event failed for #{rule}: #{e.message}"
71
+ )
72
+ end
73
+
74
+ def current_application_id
75
+ app_name = RailsErrorDashboard.configuration.application_name
76
+ return nil unless app_name.present?
77
+
78
+ Application.find_by(name: app_name)&.id
79
+ rescue => e
80
+ nil
81
+ end
82
+ end
83
+ end
84
+ end
@@ -195,8 +195,11 @@ module RailsErrorDashboard
195
195
  # Code path coverage (diagnostic mode — Ruby 3.2+)
196
196
  attr_accessor :enable_coverage_tracking # Master switch (default: false)
197
197
 
198
- # Rack Attack event tracking (requires enable_breadcrumbs = true)
198
+ # Rack Attack event tracking persists throttle/blocklist/track events to
199
+ # their own table, independent of error capture (breadcrumbs optional).
199
200
  attr_accessor :enable_rack_attack_tracking # Master switch (default: false)
201
+ attr_accessor :rack_attack_max_cache_size # Max buffered keys per thread (default: 1000)
202
+ attr_accessor :rack_attack_flush_interval # Seconds between DB flushes (default: 60)
200
203
 
201
204
  # ActionCable event tracking (requires enable_breadcrumbs = true)
202
205
  attr_accessor :enable_actioncable_tracking # Master switch (default: false)
@@ -400,8 +403,11 @@ module RailsErrorDashboard
400
403
  # Code path coverage defaults - OFF by default (opt-in, Ruby 3.2+)
401
404
  @enable_coverage_tracking = false
402
405
 
403
- # Rack Attack event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
406
+ # Rack Attack event tracking defaults - OFF by default (opt-in).
407
+ # Persists to its own table; does NOT require breadcrumbs.
404
408
  @enable_rack_attack_tracking = false
409
+ @rack_attack_max_cache_size = 1000 # Max buffered keys per thread (LRU eviction)
410
+ @rack_attack_flush_interval = 60 # Seconds between DB flushes
405
411
 
406
412
  # ActionCable event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
407
413
  @enable_actioncable_tracking = false
@@ -574,11 +580,16 @@ module RailsErrorDashboard
574
580
  end
575
581
  end
576
582
 
577
- # Validate rack_attack tracking requires breadcrumbs
578
- if enable_rack_attack_tracking && !enable_breadcrumbs
579
- warnings << "enable_rack_attack_tracking requires enable_breadcrumbs = true. " \
580
- "Rack Attack tracking has been auto-disabled."
581
- @enable_rack_attack_tracking = false
583
+ # Rack Attack tracking no longer requires breadcrumbs — events are persisted
584
+ # to their own table (issue #143). Breadcrumbs only add the event to the
585
+ # activity trail on error detail pages.
586
+ if enable_rack_attack_tracking
587
+ if rack_attack_max_cache_size && rack_attack_max_cache_size < 1
588
+ errors << "rack_attack_max_cache_size must be at least 1 (got: #{rack_attack_max_cache_size})"
589
+ end
590
+ if rack_attack_flush_interval && rack_attack_flush_interval < 1
591
+ errors << "rack_attack_flush_interval must be at least 1 (got: #{rack_attack_flush_interval})"
592
+ end
582
593
  end
583
594
 
584
595
  # Validate actioncable tracking requires breadcrumbs
@@ -80,9 +80,9 @@ module RailsErrorDashboard
80
80
  RailsErrorDashboard::Subscribers::BreadcrumbSubscriber.subscribe!
81
81
  end
82
82
 
83
- # Subscribe to Rack Attack AS::Notifications events (requires breadcrumbs + Rack::Attack)
83
+ # Subscribe to Rack Attack AS::Notifications events (requires Rack::Attack).
84
+ # Breadcrumbs are NOT required — events persist to their own table (issue #143).
84
85
  if RailsErrorDashboard.configuration.enable_rack_attack_tracking &&
85
- RailsErrorDashboard.configuration.enable_breadcrumbs &&
86
86
  defined?(Rack::Attack)
87
87
  RailsErrorDashboard::Subscribers::RackAttackSubscriber.subscribe!
88
88
  end
@@ -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,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
@@ -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.2"
2
+ VERSION = "0.8.3"
3
3
  end
@@ -79,6 +79,7 @@ require "rails_error_dashboard/services/llm_summary"
79
79
  require "rails_error_dashboard/services/variable_serializer"
80
80
  require "rails_error_dashboard/services/local_variable_capturer"
81
81
  require "rails_error_dashboard/services/swallowed_exception_tracker"
82
+ require "rails_error_dashboard/services/rack_attack_tracker"
82
83
  require "rails_error_dashboard/services/crash_capture"
83
84
  require "rails_error_dashboard/services/diagnostic_dump_generator"
84
85
  require "rails_error_dashboard/services/coverage_tracker"
@@ -124,6 +125,7 @@ require "rails_error_dashboard/commands/find_or_create_application"
124
125
  require "rails_error_dashboard/commands/upsert_cascade_pattern"
125
126
  require "rails_error_dashboard/commands/upsert_baseline"
126
127
  require "rails_error_dashboard/commands/flush_swallowed_exceptions"
128
+ require "rails_error_dashboard/commands/flush_rack_attack_events"
127
129
  require "rails_error_dashboard/queries/errors_list"
128
130
  require "rails_error_dashboard/queries/dashboard_stats"
129
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.2
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,6 +260,7 @@ 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
@@ -277,6 +280,7 @@ files:
277
280
  - app/models/rails_error_dashboard/error_log.rb
278
281
  - app/models/rails_error_dashboard/error_logs_record.rb
279
282
  - app/models/rails_error_dashboard/error_occurrence.rb
283
+ - app/models/rails_error_dashboard/rack_attack_event.rb
280
284
  - app/models/rails_error_dashboard/storm_event.rb
281
285
  - app/models/rails_error_dashboard/swallowed_exception.rb
282
286
  - app/views/layouts/rails_error_dashboard.html.erb
@@ -364,6 +368,7 @@ files:
364
368
  - db/migrate/20260326000001_add_issue_tracking_to_error_logs.rb
365
369
  - db/migrate/20260503000001_backfill_resolved_status.rb
366
370
  - db/migrate/20260613000001_create_storm_events.rb
371
+ - db/migrate/20260730000001_create_rails_error_dashboard_rack_attack_events.rb
367
372
  - lib/generators/rails_error_dashboard/install/install_generator.rb
368
373
  - lib/generators/rails_error_dashboard/install/templates/README
369
374
  - lib/generators/rails_error_dashboard/install/templates/initializer.rb
@@ -381,6 +386,7 @@ files:
381
386
  - lib/rails_error_dashboard/commands/create_issue.rb
382
387
  - lib/rails_error_dashboard/commands/find_or_create_application.rb
383
388
  - lib/rails_error_dashboard/commands/find_or_increment_error.rb
389
+ - lib/rails_error_dashboard/commands/flush_rack_attack_events.rb
384
390
  - lib/rails_error_dashboard/commands/flush_storm_counts.rb
385
391
  - lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb
386
392
  - lib/rails_error_dashboard/commands/increment_cascade_detection.rb
@@ -484,6 +490,7 @@ files:
484
490
  - lib/rails_error_dashboard/services/pearson_correlation.rb
485
491
  - lib/rails_error_dashboard/services/platform_detector.rb
486
492
  - lib/rails_error_dashboard/services/priority_score_calculator.rb
493
+ - lib/rails_error_dashboard/services/rack_attack_tracker.rb
487
494
  - lib/rails_error_dashboard/services/rspec_generator.rb
488
495
  - lib/rails_error_dashboard/services/sensitive_data_filter.rb
489
496
  - lib/rails_error_dashboard/services/severity_classifier.rb
@@ -523,7 +530,7 @@ metadata:
523
530
  funding_uri: https://github.com/sponsors/AnjanJ
524
531
  post_install_message: |
525
532
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
526
- RED (Rails Error Dashboard) v0.8.2
533
+ RED (Rails Error Dashboard) v0.8.3
527
534
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
528
535
 
529
536
  First install:
@@ -561,5 +568,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
561
568
  requirements: []
562
569
  rubygems_version: 3.6.9
563
570
  specification_version: 4
564
- 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.
565
573
  test_files: []