rails_error_dashboard 0.3.1 → 0.4.0

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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +160 -861
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +89 -0
  4. data/app/jobs/rails_error_dashboard/swallowed_exception_flush_job.rb +32 -0
  5. data/app/models/rails_error_dashboard/diagnostic_dump.rb +14 -0
  6. data/app/models/rails_error_dashboard/swallowed_exception.rb +38 -0
  7. data/app/views/layouts/rails_error_dashboard.html.erb +21 -0
  8. data/app/views/rails_error_dashboard/errors/_instance_variables.html.erb +55 -0
  9. data/app/views/rails_error_dashboard/errors/_local_variables.html.erb +46 -0
  10. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +182 -0
  11. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +133 -0
  12. data/app/views/rails_error_dashboard/errors/show.html.erb +4 -0
  13. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +126 -0
  14. data/config/routes.rb +4 -0
  15. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +33 -0
  16. data/db/migrate/20260306000001_add_local_variables_to_error_logs.rb +13 -0
  17. data/db/migrate/20260306000002_add_instance_variables_to_error_logs.rb +7 -0
  18. data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +34 -0
  19. data/db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb +17 -0
  20. data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
  21. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +47 -0
  22. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +103 -0
  23. data/lib/rails_error_dashboard/commands/log_error.rb +68 -0
  24. data/lib/rails_error_dashboard/configuration.rb +122 -0
  25. data/lib/rails_error_dashboard/engine.rb +24 -0
  26. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +32 -11
  27. data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +90 -0
  28. data/lib/rails_error_dashboard/queries/swallowed_exception_summary.rb +97 -0
  29. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +12 -0
  30. data/lib/rails_error_dashboard/services/crash_capture.rb +234 -0
  31. data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +98 -0
  32. data/lib/rails_error_dashboard/services/local_variable_capturer.rb +207 -0
  33. data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +277 -0
  34. data/lib/rails_error_dashboard/services/variable_serializer.rb +326 -0
  35. data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +94 -0
  36. data/lib/rails_error_dashboard/version.rb +1 -1
  37. data/lib/rails_error_dashboard.rb +9 -0
  38. data/lib/tasks/error_dashboard.rake +34 -0
  39. metadata +23 -2
@@ -348,6 +348,95 @@ module RailsErrorDashboard
348
348
  @pagy, @entries = pagy(:offset, all_entries, limit: params[:per_page] || 25)
349
349
  end
350
350
 
351
+ def swallowed_exceptions
352
+ unless RailsErrorDashboard.configuration.detect_swallowed_exceptions
353
+ # On Ruby < 3.3, validate! auto-disables this feature — tell the user why
354
+ if RUBY_VERSION < "3.3"
355
+ flash[:alert] = "Swallowed exception detection requires Ruby 3.3+ (you have #{RUBY_VERSION}). Upgrade Ruby to use this feature."
356
+ else
357
+ flash[:alert] = "Swallowed exception detection is not enabled. Enable it in config/initializers/rails_error_dashboard.rb"
358
+ end
359
+ redirect_to errors_path
360
+ return
361
+ end
362
+
363
+ days = (params[:days] || 30).to_i
364
+ @days = days
365
+ result = Queries::SwallowedExceptionSummary.call(days, application_id: @current_application_id)
366
+ all_entries = result[:entries]
367
+
368
+ # Summary stats (computed before pagination)
369
+ @unique_count = all_entries.size
370
+ @total_rescue_count = all_entries.sum { |e| e[:rescue_count] }
371
+ @total_raise_count = all_entries.sum { |e| e[:raise_count] }
372
+
373
+ @pagy, @entries = pagy(:offset, all_entries, limit: params[:per_page] || 25)
374
+ end
375
+
376
+ def rack_attack_summary
377
+ unless RailsErrorDashboard.configuration.enable_rack_attack_tracking &&
378
+ RailsErrorDashboard.configuration.enable_breadcrumbs
379
+ flash[:alert] = "Rack Attack tracking is not enabled. Enable enable_rack_attack_tracking and enable_breadcrumbs in config/initializers/rails_error_dashboard.rb"
380
+ redirect_to errors_path
381
+ return
382
+ end
383
+
384
+ days = (params[:days] || 30).to_i
385
+ @days = days
386
+ result = Queries::RackAttackSummary.call(days, application_id: @current_application_id)
387
+ all_events = result[:events]
388
+
389
+ # Summary stats (computed before pagination)
390
+ @unique_rules = all_events.size
391
+ @total_events = all_events.sum { |e| e[:count] }
392
+ @unique_ips = all_events.flat_map { |e| e[:ips] }.uniq.size
393
+
394
+ @pagy, @events = pagy(:offset, all_events, limit: params[:per_page] || 25)
395
+ end
396
+
397
+ def diagnostic_dumps
398
+ unless RailsErrorDashboard.configuration.enable_diagnostic_dump
399
+ flash[:alert] = "Diagnostic dumps are not enabled. Enable them in config/initializers/rails_error_dashboard.rb"
400
+ redirect_to errors_path
401
+ return
402
+ end
403
+
404
+ scope = DiagnosticDump.recent
405
+ scope = scope.where(application_id: @current_application_id) if @current_application_id.present?
406
+ @total_dumps = scope.count
407
+
408
+ @pagy, @dumps = pagy(:offset, scope, limit: params[:per_page] || 25)
409
+ end
410
+
411
+ def create_diagnostic_dump
412
+ unless RailsErrorDashboard.configuration.enable_diagnostic_dump
413
+ flash[:alert] = "Diagnostic dumps are not enabled."
414
+ redirect_to errors_path
415
+ return
416
+ end
417
+
418
+ dump = Services::DiagnosticDumpGenerator.call
419
+
420
+ app_name = RailsErrorDashboard.configuration.application_name ||
421
+ ENV["APPLICATION_NAME"] ||
422
+ (defined?(Rails) && Rails.application.class.module_parent_name) ||
423
+ "Unknown"
424
+ app = Commands::FindOrCreateApplication.call(app_name)
425
+
426
+ DiagnosticDump.create!(
427
+ application_id: app.id,
428
+ dump_data: dump.to_json,
429
+ captured_at: Time.current,
430
+ note: params[:note].presence
431
+ )
432
+
433
+ flash[:notice] = "Diagnostic dump captured successfully."
434
+ redirect_to diagnostic_dumps_errors_path
435
+ rescue => e
436
+ flash[:alert] = "Failed to capture diagnostic dump: #{e.message}"
437
+ redirect_to diagnostic_dumps_errors_path
438
+ end
439
+
351
440
  def settings
352
441
  @config = RailsErrorDashboard.configuration
353
442
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Job: Persist swallowed exception counters to the database.
5
+ #
6
+ # Two usage modes:
7
+ # 1. With arguments (raise_counts, rescue_counts) — dispatched by the TracePoint
8
+ # periodic flush. 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 counters (useful as a cron safety net).
11
+ #
12
+ # Example cron (via solid_queue or whenever):
13
+ # every 5.minutes { RailsErrorDashboard::SwallowedExceptionFlushJob.perform_later }
14
+ class SwallowedExceptionFlushJob < ApplicationJob
15
+ queue_as :default
16
+
17
+ def perform(raise_counts = nil, rescue_counts = nil)
18
+ return unless RailsErrorDashboard.configuration.detect_swallowed_exceptions
19
+
20
+ if raise_counts && rescue_counts
21
+ # Mode 1: Persist provided snapshots (dispatched from TracePoint flush)
22
+ Commands::FlushSwallowedExceptions.call(
23
+ raise_counts: raise_counts,
24
+ rescue_counts: rescue_counts
25
+ )
26
+ else
27
+ # Mode 2: Flush current thread's counters (scheduled cron safety net)
28
+ Services::SwallowedExceptionTracker.flush!
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ class DiagnosticDump < ErrorLogsRecord
5
+ self.table_name = "rails_error_dashboard_diagnostic_dumps"
6
+
7
+ belongs_to :application
8
+
9
+ validates :captured_at, presence: true
10
+ validates :dump_data, presence: true
11
+
12
+ scope :recent, -> { order(captured_at: :desc) }
13
+ end
14
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Stores aggregated counts of raised-then-rescued exceptions per hourly bucket.
5
+ #
6
+ # Swallowed exceptions are raised but silently rescued, never reaching the error
7
+ # dashboard. This table tracks raise/rescue counts keyed by exception class,
8
+ # raise location, rescue location, and hourly period.
9
+ #
10
+ # A high rescue_count/raise_count ratio indicates exceptions being swallowed.
11
+ class SwallowedException < ErrorLogsRecord
12
+ self.table_name = "rails_error_dashboard_swallowed_exceptions"
13
+
14
+ belongs_to :application, optional: true
15
+
16
+ validates :exception_class, presence: true
17
+ validates :raise_location, presence: true
18
+ validates :period_hour, presence: true
19
+ validates :raise_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
20
+ validates :rescue_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
21
+
22
+ scope :for_application, ->(app_id) { where(application_id: app_id) }
23
+ scope :since, ->(time) { where("period_hour >= ?", time) }
24
+ scope :recent, -> { order(period_hour: :desc) }
25
+
26
+ # Rescue ratio: fraction of raises that were rescued (0.0 to 1.0)
27
+ def rescue_ratio
28
+ return 0.0 if raise_count.zero?
29
+ rescue_count.to_f / raise_count
30
+ end
31
+
32
+ # Whether this exception is considered "swallowed" (rescue ratio >= threshold)
33
+ def swallowed?(threshold: nil)
34
+ threshold ||= RailsErrorDashboard.configuration.swallowed_exception_threshold
35
+ rescue_ratio >= threshold
36
+ end
37
+ end
38
+ end
@@ -1657,6 +1657,13 @@ body.dark-mode .sidebar-section-body { background: var(--ctp-mantle); }
1657
1657
  <% end %>
1658
1658
  </li>
1659
1659
  <% end %>
1660
+ <% if RailsErrorDashboard.configuration.enable_rack_attack_tracking && RailsErrorDashboard.configuration.enable_breadcrumbs %>
1661
+ <li class="nav-item">
1662
+ <%= link_to rack_attack_summary_errors_path(nav_params), class: "nav-link #{request.path == rack_attack_summary_errors_path ? 'active' : ''}" do %>
1663
+ <i class="bi bi-shield-exclamation"></i> Rate Limits
1664
+ <% end %>
1665
+ </li>
1666
+ <% end %>
1660
1667
  <% if RailsErrorDashboard.configuration.enable_system_health %>
1661
1668
  <li class="nav-item">
1662
1669
  <%= link_to job_health_summary_errors_path(nav_params), class: "nav-link #{request.path == job_health_summary_errors_path ? 'active' : ''}" do %>
@@ -1669,6 +1676,20 @@ body.dark-mode .sidebar-section-body { background: var(--ctp-mantle); }
1669
1676
  <% end %>
1670
1677
  </li>
1671
1678
  <% end %>
1679
+ <% if RailsErrorDashboard.configuration.detect_swallowed_exceptions %>
1680
+ <li class="nav-item">
1681
+ <%= link_to swallowed_exceptions_errors_path(nav_params), class: "nav-link #{request.path == swallowed_exceptions_errors_path ? 'active' : ''}" do %>
1682
+ <i class="bi bi-eye-slash"></i> Swallowed
1683
+ <% end %>
1684
+ </li>
1685
+ <% end %>
1686
+ <% if RailsErrorDashboard.configuration.enable_diagnostic_dump %>
1687
+ <li class="nav-item">
1688
+ <%= link_to diagnostic_dumps_errors_path(nav_params), class: "nav-link #{request.path == diagnostic_dumps_errors_path ? 'active' : ''}" do %>
1689
+ <i class="bi bi-clipboard-pulse"></i> Diagnostics
1690
+ <% end %>
1691
+ </li>
1692
+ <% end %>
1672
1693
  </ul>
1673
1694
 
1674
1695
  <h6 class="mt-4">QUICK FILTERS</h6>
@@ -0,0 +1,55 @@
1
+ <!-- Instance Variables (captured from receiver object at raise time) -->
2
+ <% if RailsErrorDashboard.configuration.enable_instance_variables && error.class.column_names.include?("instance_variables") && error.read_attribute(:instance_variables).present? %>
3
+ <% instance_vars = JSON.parse(error.read_attribute(:instance_variables)) rescue {} %>
4
+ <% self_class = instance_vars.delete("_self_class") %>
5
+ <% if instance_vars.any? %>
6
+ <div class="card mb-4" id="section-instance-variables">
7
+ <div class="card-header bg-white">
8
+ <h5 class="mb-0">
9
+ <i class="bi bi-box"></i> Instance Variables
10
+ <span class="badge bg-secondary"><%= instance_vars.size %> captured</span>
11
+ </h5>
12
+ <small class="text-muted">
13
+ Instance variables from
14
+ <% if self_class && self_class["value"] %>
15
+ <code><%= self_class["value"] %></code>
16
+ <% else %>
17
+ the object
18
+ <% end %>
19
+ where the exception was raised
20
+ </small>
21
+ </div>
22
+ <div class="card-body p-0">
23
+ <div class="table-responsive">
24
+ <table class="table table-sm table-hover mb-0">
25
+ <thead class="table-light">
26
+ <tr>
27
+ <th width="200">Variable</th>
28
+ <th width="120">Type</th>
29
+ <th>Value</th>
30
+ </tr>
31
+ </thead>
32
+ <tbody>
33
+ <% instance_vars.each do |name, info| %>
34
+ <tr>
35
+ <td><code><%= name %></code></td>
36
+ <td><small class="text-muted"><%= info["type"] %></small></td>
37
+ <td>
38
+ <% if info["filtered"] %>
39
+ <span class="badge bg-warning text-dark">[FILTERED]</span>
40
+ <% else %>
41
+ <code style="word-break: break-all; font-size: 0.85em;"><%= truncate(info["value"].to_s, length: 500) %></code>
42
+ <% if info["truncated"] %>
43
+ <span class="badge bg-info text-dark ms-1" style="font-size: 0.7em;">truncated</span>
44
+ <% end %>
45
+ <% end %>
46
+ </td>
47
+ </tr>
48
+ <% end %>
49
+ </tbody>
50
+ </table>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ <% end %>
55
+ <% end %>
@@ -0,0 +1,46 @@
1
+ <!-- Local Variables (captured via TracePoint) -->
2
+ <% if RailsErrorDashboard.configuration.enable_local_variables && error.respond_to?(:local_variables) && error.local_variables.present? %>
3
+ <% local_vars = JSON.parse(error.local_variables) rescue {} %>
4
+ <% if local_vars.any? %>
5
+ <div class="card mb-4" id="section-local-variables">
6
+ <div class="card-header bg-white">
7
+ <h5 class="mb-0">
8
+ <i class="bi bi-braces"></i> Local Variables
9
+ <span class="badge bg-secondary"><%= local_vars.size %> captured</span>
10
+ </h5>
11
+ <small class="text-muted">Variables from the stack frame where the exception was raised</small>
12
+ </div>
13
+ <div class="card-body p-0">
14
+ <div class="table-responsive">
15
+ <table class="table table-sm table-hover mb-0">
16
+ <thead class="table-light">
17
+ <tr>
18
+ <th width="200">Variable</th>
19
+ <th width="120">Type</th>
20
+ <th>Value</th>
21
+ </tr>
22
+ </thead>
23
+ <tbody>
24
+ <% local_vars.each do |name, info| %>
25
+ <tr>
26
+ <td><code><%= name %></code></td>
27
+ <td><small class="text-muted"><%= info["type"] %></small></td>
28
+ <td>
29
+ <% if info["filtered"] %>
30
+ <span class="badge bg-warning text-dark">[FILTERED]</span>
31
+ <% else %>
32
+ <code style="word-break: break-all; font-size: 0.85em;"><%= truncate(info["value"].to_s, length: 500) %></code>
33
+ <% if info["truncated"] %>
34
+ <span class="badge bg-info text-dark ms-1" style="font-size: 0.7em;">truncated</span>
35
+ <% end %>
36
+ <% end %>
37
+ </td>
38
+ </tr>
39
+ <% end %>
40
+ </tbody>
41
+ </table>
42
+ </div>
43
+ </div>
44
+ </div>
45
+ <% end %>
46
+ <% end %>
@@ -0,0 +1,182 @@
1
+ <% content_for :page_title, "Diagnostic Dumps" %>
2
+
3
+ <div class="container-fluid py-4">
4
+ <div class="d-flex justify-content-between align-items-center mb-4">
5
+ <h1 class="h3 mb-0">
6
+ <i class="bi bi-clipboard-pulse me-2"></i>
7
+ Diagnostic Dumps
8
+ </h1>
9
+
10
+ <%= button_to create_diagnostic_dump_errors_path, method: :post, class: "btn btn-primary btn-sm" do %>
11
+ <i class="bi bi-camera me-1"></i> Capture Dump
12
+ <% end %>
13
+ </div>
14
+
15
+ <% if @total_dumps == 0 %>
16
+ <div class="text-center py-5">
17
+ <i class="bi bi-clipboard-pulse display-1 text-muted mb-3"></i>
18
+ <h4 class="text-muted">No Diagnostic Dumps Yet</h4>
19
+ <p class="text-muted">
20
+ Capture an on-demand snapshot of your system state for debugging.
21
+ </p>
22
+ <div class="card mx-auto" style="max-width: 500px;">
23
+ <div class="card-body text-start">
24
+ <h6>How diagnostic dumps work:</h6>
25
+ <ul class="mb-0">
26
+ <li>Click <strong>Capture Dump</strong> above or run <code>rails error_dashboard:diagnostic_dump</code></li>
27
+ <li>Captures: environment, GC stats, threads, connection pool, memory, job queue</li>
28
+ <li>Each dump is saved to the database for historical comparison</li>
29
+ <li>Use dumps to investigate production issues or verify system health</li>
30
+ </ul>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ <% else %>
35
+ <div class="row mb-4">
36
+ <div class="col-md-4">
37
+ <div class="card text-center">
38
+ <div class="card-body">
39
+ <div class="display-6 text-info"><%= @total_dumps %></div>
40
+ <small class="text-muted">Total Dumps</small>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ <div class="col-md-4">
45
+ <div class="card text-center">
46
+ <div class="card-body">
47
+ <%
48
+ latest = @dumps.first
49
+ latest_data = begin; JSON.parse(latest.dump_data); rescue; {}; end if latest
50
+ thread_count = latest_data&.dig("system_health", "thread_count")
51
+ %>
52
+ <div class="display-6 text-secondary"><%= thread_count || "N/A" %></div>
53
+ <small class="text-muted">Threads (Latest)</small>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ <div class="col-md-4">
58
+ <div class="card text-center">
59
+ <div class="card-body">
60
+ <%
61
+ memory_mb = latest_data&.dig("system_health", "process_memory_mb")
62
+ %>
63
+ <div class="display-6 text-secondary"><%= memory_mb ? "#{memory_mb} MB" : "N/A" %></div>
64
+ <small class="text-muted">Memory (Latest)</small>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="card mb-4">
71
+ <div class="card-header bg-white d-flex justify-content-between align-items-center">
72
+ <h5 class="mb-0">
73
+ <i class="bi bi-clipboard-pulse text-info me-2"></i>
74
+ Dump History
75
+ <span class="badge bg-info"><%= @total_dumps %></span>
76
+ </h5>
77
+ <small class="text-muted"><%== @pagy.info_tag %></small>
78
+ </div>
79
+ <div class="card-body p-0">
80
+ <% @dumps.each do |dump| %>
81
+ <%
82
+ data = begin; JSON.parse(dump.dump_data); rescue; {}; end
83
+ env = data["environment"] || {}
84
+ health = data["system_health"] || {}
85
+ gc = data["gc"] || {}
86
+ threads = data["threads"] || []
87
+ %>
88
+ <div class="border-bottom p-3">
89
+ <div class="d-flex justify-content-between align-items-start mb-2">
90
+ <div>
91
+ <strong>
92
+ <i class="bi bi-clock me-1"></i>
93
+ <%= dump.captured_at.strftime("%Y-%m-%d %H:%M:%S %Z") %>
94
+ </strong>
95
+ <span class="text-muted ms-2">PID: <%= data["pid"] %></span>
96
+ <% if data["uptime_seconds"] %>
97
+ <span class="text-muted ms-2">
98
+ Uptime: <%= (data["uptime_seconds"].to_i / 60) %>m
99
+ </span>
100
+ <% end %>
101
+ <% if dump.note.present? %>
102
+ <span class="badge bg-secondary ms-2"><%= dump.note %></span>
103
+ <% end %>
104
+ </div>
105
+ <button class="btn btn-outline-secondary btn-sm"
106
+ type="button"
107
+ data-bs-toggle="collapse"
108
+ data-bs-target="#dump-<%= dump.id %>"
109
+ aria-expanded="false">
110
+ <i class="bi bi-chevron-down"></i> Details
111
+ </button>
112
+ </div>
113
+
114
+ <div class="row">
115
+ <div class="col-md-3">
116
+ <small class="text-muted d-block">Environment</small>
117
+ <small>
118
+ Ruby <%= env["ruby_version"] || "?" %> /
119
+ Rails <%= env["rails_version"] || "?" %>
120
+ <% if env["server"] && env["server"] != "unknown" %>
121
+ / <%= env["server"].capitalize %>
122
+ <% end %>
123
+ </small>
124
+ </div>
125
+ <div class="col-md-3">
126
+ <small class="text-muted d-block">System Health</small>
127
+ <small>
128
+ <% mem = health["process_memory_mb"] %>
129
+ <% tc = health["thread_count"] %>
130
+ <% pool = health["connection_pool"] %>
131
+ <%= mem ? "#{mem} MB" : "N/A" %> /
132
+ <%= tc || "?" %> threads
133
+ <% if pool %>
134
+ / Pool: <%= pool["busy"] %>/<%= pool["size"] %>
135
+ <% end %>
136
+ </small>
137
+ </div>
138
+ <div class="col-md-3">
139
+ <small class="text-muted d-block">GC</small>
140
+ <small>
141
+ <% gc_health = health["gc"] || {} %>
142
+ Live: <%= gc_health["heap_live_slots"]&.to_s(:delimited) rescue gc_health["heap_live_slots"] || "?" %> /
143
+ Major GC: <%= gc_health["major_gc_count"] || "?" %>
144
+ </small>
145
+ </div>
146
+ <div class="col-md-3">
147
+ <small class="text-muted d-block">Job Queue</small>
148
+ <small>
149
+ <% jq = health["job_queue"] %>
150
+ <% if jq %>
151
+ <%= jq["adapter"] %>
152
+ <% if jq["failed"] || jq["errored"] %>
153
+ — failed: <%= jq["failed"] || jq["errored"] %>
154
+ <% end %>
155
+ <% else %>
156
+ N/A
157
+ <% end %>
158
+ </small>
159
+ </div>
160
+ </div>
161
+
162
+ <div class="collapse mt-3" id="dump-<%= dump.id %>">
163
+ <pre class="bg-light p-3 rounded" style="max-height: 400px; overflow-y: auto; font-size: 0.8rem;"><%= JSON.pretty_generate(data) rescue dump.dump_data %></pre>
164
+ </div>
165
+ </div>
166
+ <% end %>
167
+ </div>
168
+ <div class="card-footer bg-white border-top d-flex justify-content-between align-items-center">
169
+ <div>
170
+ <small class="text-muted">
171
+ <i class="bi bi-lightbulb text-warning"></i>
172
+ You can also capture dumps via: <code>rails error_dashboard:diagnostic_dump</code>
173
+ <span class="ms-2">Add a note: <code>NOTE="deploy check" rails error_dashboard:diagnostic_dump</code></span>
174
+ </small>
175
+ </div>
176
+ <div>
177
+ <%== @pagy.series_nav(:bootstrap) if @pagy.pages > 1 %>
178
+ </div>
179
+ </div>
180
+ </div>
181
+ <% end %>
182
+ </div>
@@ -0,0 +1,133 @@
1
+ <% content_for :page_title, "Rate Limit Events" %>
2
+
3
+ <div class="container-fluid py-4">
4
+ <div class="d-flex justify-content-between align-items-center mb-4">
5
+ <h1 class="h3 mb-0">
6
+ <i class="bi bi-shield-exclamation me-2"></i>
7
+ Rate Limit Events
8
+ </h1>
9
+
10
+ <div class="btn-group" role="group">
11
+ <%= link_to rack_attack_summary_errors_path(days: 7), class: "btn btn-sm #{@days == 7 ? 'btn-primary' : 'btn-outline-primary'}" do %>
12
+ 7 Days
13
+ <% end %>
14
+ <%= link_to rack_attack_summary_errors_path(days: 30), class: "btn btn-sm #{@days == 30 ? 'btn-primary' : 'btn-outline-primary'}" do %>
15
+ 30 Days
16
+ <% end %>
17
+ <%= link_to rack_attack_summary_errors_path(days: 90), class: "btn btn-sm #{@days == 90 ? 'btn-primary' : 'btn-outline-primary'}" do %>
18
+ 90 Days
19
+ <% end %>
20
+ </div>
21
+ </div>
22
+
23
+ <% if @unique_rules == 0 %>
24
+ <div class="text-center py-5">
25
+ <i class="bi bi-shield-check display-1 text-success mb-3"></i>
26
+ <h4 class="text-muted">No Rate Limit Events Found</h4>
27
+ <p class="text-muted">
28
+ No Rack Attack throttle, blocklist, or track events were detected in error breadcrumbs over the last <%= @days %> days.
29
+ </p>
30
+ <div class="card mx-auto" style="max-width: 500px;">
31
+ <div class="card-body text-start">
32
+ <h6>How Rack Attack tracking works:</h6>
33
+ <ul class="mb-0">
34
+ <li>Breadcrumbs must be enabled (<code>enable_breadcrumbs = true</code>)</li>
35
+ <li>Rack Attack tracking must be enabled (<code>enable_rack_attack_tracking = true</code>)</li>
36
+ <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>
38
+ </ul>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ <% else %>
43
+ <div class="row mb-4">
44
+ <div class="col-md-4">
45
+ <div class="card text-center">
46
+ <div class="card-body">
47
+ <div class="display-6 text-warning"><%= @unique_rules %></div>
48
+ <small class="text-muted">Unique Rules</small>
49
+ </div>
50
+ </div>
51
+ </div>
52
+ <div class="col-md-4">
53
+ <div class="card text-center">
54
+ <div class="card-body">
55
+ <div class="display-6 text-danger"><%= @total_events %></div>
56
+ <small class="text-muted">Total Events</small>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ <div class="col-md-4">
61
+ <div class="card text-center">
62
+ <div class="card-body">
63
+ <div class="display-6 text-info"><%= @unique_ips %></div>
64
+ <small class="text-muted">Unique IPs</small>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="card mb-4">
71
+ <div class="card-header bg-white d-flex justify-content-between align-items-center">
72
+ <h5 class="mb-0">
73
+ <i class="bi bi-shield-exclamation text-warning me-2"></i>
74
+ Rate Limit Events by Rule
75
+ <span class="badge bg-warning text-dark"><%= @unique_rules %></span>
76
+ </h5>
77
+ <small class="text-muted"><%== @pagy.info_tag %></small>
78
+ </div>
79
+ <div class="card-body p-0">
80
+ <div class="table-responsive">
81
+ <table class="table table-hover mb-0">
82
+ <thead class="table-light">
83
+ <tr>
84
+ <th>Rule</th>
85
+ <th width="100">Type</th>
86
+ <th width="80">Count</th>
87
+ <th width="80">IPs</th>
88
+ <th>Top Path</th>
89
+ <th width="80">Errors</th>
90
+ <th width="140">Last Seen</th>
91
+ </tr>
92
+ </thead>
93
+ <tbody>
94
+ <% @events.each do |event| %>
95
+ <tr>
96
+ <td><code><%= event[:rule] %></code></td>
97
+ <td>
98
+ <% type_color = case event[:match_type]
99
+ when "blocklist" then "danger"
100
+ when "throttle" then "warning"
101
+ else "secondary"
102
+ end %>
103
+ <span class="badge bg-<%= type_color %>"><%= event[:match_type] %></span>
104
+ </td>
105
+ <td><strong><%= event[:count] %></strong></td>
106
+ <td><%= event[:unique_ips] %></td>
107
+ <td><code><%= event[:top_path] %></code></td>
108
+ <td><%= event[:error_count] %></td>
109
+ <td><%= local_time_ago(event[:last_seen]) %></td>
110
+ </tr>
111
+ <% end %>
112
+ </tbody>
113
+ </table>
114
+ </div>
115
+ </div>
116
+ <div class="card-footer bg-white border-top d-flex justify-content-between align-items-center">
117
+ <div>
118
+ <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.
120
+ </small>
121
+ <small class="ms-3">
122
+ <a href="https://github.com/rack/rack-attack" target="_blank" rel="noopener" class="text-decoration-none">
123
+ <i class="bi bi-book"></i> Rack Attack Docs <i class="bi bi-box-arrow-up-right" style="font-size: 0.7em;"></i>
124
+ </a>
125
+ </small>
126
+ </div>
127
+ <div>
128
+ <%== @pagy.series_nav(:bootstrap) if @pagy.pages > 1 %>
129
+ </div>
130
+ </div>
131
+ </div>
132
+ <% end %>
133
+ </div>
@@ -59,6 +59,10 @@
59
59
  <div class="col-md-8">
60
60
  <%= render "error_info", error: @error %>
61
61
 
62
+ <%= render "local_variables", error: @error %>
63
+
64
+ <%= render "instance_variables", error: @error %>
65
+
62
66
  <%= render "request_context", error: @error %>
63
67
 
64
68
  <%= render "breadcrumbs_group", error: @error %>