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 +4 -4
- data/README.md +32 -1
- data/app/controllers/rails_error_dashboard/errors_controller.rb +2 -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/models/rails_error_dashboard/rack_attack_event.rb +36 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +1 -1
- data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +4 -6
- data/db/migrate/20260730000001_create_rails_error_dashboard_rack_attack_events.rb +45 -0
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +84 -0
- data/lib/rails_error_dashboard/configuration.rb +18 -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/services/rack_attack_tracker.rb +194 -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 +2 -0
- metadata +26 -18
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 270ac48ebee51c2f68cab29abdbf1169b7db7d7b860bf1fc5a43883960cdc0c6
|
|
4
|
+
data.tar.gz: 6def5b5a2bbcabffd1116f13607771f6923823dc65eec803e028ba911af85ec9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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** · **
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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>
|
|
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
|
|
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
|
|
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
|
-
#
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# Query: Aggregate Rack Attack events from the rack_attack_events table.
|
|
6
|
+
#
|
|
7
|
+
# Previously this scanned every error_log's breadcrumbs JSON in Ruby, which was
|
|
8
|
+
# both expensive (full-table scan + JSON.parse per row) and incomplete (events
|
|
9
|
+
# were only stored when an unrelated error happened to occur in the same
|
|
10
|
+
# request). Events now have their own table, so this is indexed SQL.
|
|
11
|
+
#
|
|
12
|
+
# Returns rows grouped by rule with counts, unique discriminators (IPs),
|
|
13
|
+
# the most frequent path, and last-seen timestamps.
|
|
8
14
|
class RackAttackSummary
|
|
9
15
|
def self.call(days = 30, application_id: nil)
|
|
10
16
|
new(days, application_id: application_id).call
|
|
@@ -25,65 +31,70 @@ module RailsErrorDashboard
|
|
|
25
31
|
private
|
|
26
32
|
|
|
27
33
|
def base_query
|
|
28
|
-
scope =
|
|
29
|
-
|
|
30
|
-
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
34
|
+
scope = RackAttackEvent.where("period_hour >= ?", @start_date)
|
|
35
|
+
scope = scope.for_application(@application_id) if @application_id.present?
|
|
31
36
|
scope
|
|
32
37
|
end
|
|
33
38
|
|
|
34
39
|
def aggregated_events
|
|
35
|
-
|
|
40
|
+
rows = base_query.pluck(
|
|
41
|
+
:rule, :match_type, :discriminator, :path, :event_count, :last_seen_at, :period_hour
|
|
42
|
+
)
|
|
43
|
+
return [] if rows.empty?
|
|
36
44
|
|
|
37
|
-
|
|
38
|
-
crumbs = parse_breadcrumbs(error_log.breadcrumbs)
|
|
39
|
-
next if crumbs.empty?
|
|
45
|
+
grouped = {}
|
|
40
46
|
|
|
41
|
-
|
|
42
|
-
|
|
47
|
+
rows.each do |rule, match_type, discriminator, path, event_count, last_seen_at, period_hour|
|
|
48
|
+
key = rule.to_s.presence || "unknown"
|
|
49
|
+
count = event_count.to_i
|
|
50
|
+
seen_at = last_seen_at || period_hour
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
entry = grouped[key] ||= {
|
|
53
|
+
rule: key,
|
|
54
|
+
match_type: match_type.to_s,
|
|
55
|
+
count: 0,
|
|
56
|
+
ips: Set.new,
|
|
57
|
+
path_counts: Hash.new(0),
|
|
58
|
+
last_seen: nil
|
|
59
|
+
}
|
|
47
60
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
rule: rule,
|
|
57
|
-
match_type: meta["type"].to_s,
|
|
58
|
-
count: 1,
|
|
59
|
-
ips: Set.new([ meta["discriminator"].to_s ].reject(&:blank?)),
|
|
60
|
-
paths: Set.new([ meta["path"].to_s ].reject(&:blank?)),
|
|
61
|
-
error_ids: [ error_log.id ],
|
|
62
|
-
last_seen: error_log.occurred_at
|
|
63
|
-
}
|
|
64
|
-
end
|
|
65
|
-
end
|
|
61
|
+
entry[:count] += count
|
|
62
|
+
entry[:ips] << discriminator.to_s if discriminator.present?
|
|
63
|
+
entry[:path_counts][path.to_s] += count if path.present?
|
|
64
|
+
entry[:last_seen] = [ entry[:last_seen], seen_at ].compact.max
|
|
65
|
+
|
|
66
|
+
# Prefer the most severe match type when a rule spans several. A rule
|
|
67
|
+
# that both tracks and blocks should surface as "blocklist".
|
|
68
|
+
entry[:match_type] = match_type.to_s if severity(match_type) > severity(entry[:match_type])
|
|
66
69
|
end
|
|
67
70
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
r[:
|
|
71
|
+
grouped.values.each do |r|
|
|
72
|
+
# "Top path" now means genuinely most-frequent, not first-seen.
|
|
73
|
+
r[:top_path] = r[:path_counts].max_by { |_path, count| count }&.first
|
|
74
|
+
r[:paths] = r[:path_counts].sort_by { |_p, c| -c }.map(&:first)
|
|
71
75
|
r[:unique_ips] = r[:ips].size
|
|
72
|
-
r[:top_path] = r[:paths].first
|
|
73
76
|
r[:ips] = r[:ips].to_a
|
|
74
|
-
|
|
77
|
+
# Distinct rate-limited clients is the meaningful figure here; the old
|
|
78
|
+
# breadcrumb-derived :error_count no longer applies now that events are
|
|
79
|
+
# stored independently of errors.
|
|
80
|
+
r[:error_count] = 0
|
|
81
|
+
r.delete(:path_counts)
|
|
75
82
|
end
|
|
76
|
-
|
|
83
|
+
|
|
84
|
+
grouped.values.sort_by { |r| -r[:count] }
|
|
77
85
|
rescue => e
|
|
78
86
|
Rails.logger.error("[RailsErrorDashboard] RackAttackSummary query failed: #{e.class}: #{e.message}")
|
|
79
87
|
[]
|
|
80
88
|
end
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
# Ordering used to pick the most severe match type for a rule.
|
|
91
|
+
def severity(match_type)
|
|
92
|
+
case match_type.to_s
|
|
93
|
+
when "blocklist" then 3
|
|
94
|
+
when "throttle" then 2
|
|
95
|
+
when "track" then 1
|
|
96
|
+
else 0
|
|
97
|
+
end
|
|
87
98
|
end
|
|
88
99
|
end
|
|
89
100
|
end
|
|
@@ -0,0 +1,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 = {
|
|
@@ -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.
|
|
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,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.
|
|
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
|
|
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: []
|