rails_error_dashboard 0.3.1 → 0.4.1

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +236 -841
  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/_sidebar_metadata.html.erb +48 -0
  11. data/app/views/rails_error_dashboard/errors/diagnostic_dumps.html.erb +182 -0
  12. data/app/views/rails_error_dashboard/errors/rack_attack_summary.html.erb +133 -0
  13. data/app/views/rails_error_dashboard/errors/show.html.erb +4 -0
  14. data/app/views/rails_error_dashboard/errors/swallowed_exceptions.html.erb +126 -0
  15. data/config/routes.rb +4 -0
  16. data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +33 -0
  17. data/db/migrate/20260306000001_add_local_variables_to_error_logs.rb +13 -0
  18. data/db/migrate/20260306000002_add_instance_variables_to_error_logs.rb +7 -0
  19. data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +34 -0
  20. data/db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb +17 -0
  21. data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
  22. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +47 -0
  23. data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +103 -0
  24. data/lib/rails_error_dashboard/commands/log_error.rb +68 -0
  25. data/lib/rails_error_dashboard/configuration.rb +122 -0
  26. data/lib/rails_error_dashboard/engine.rb +24 -0
  27. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +32 -11
  28. data/lib/rails_error_dashboard/queries/rack_attack_summary.rb +90 -0
  29. data/lib/rails_error_dashboard/queries/swallowed_exception_summary.rb +97 -0
  30. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +12 -0
  31. data/lib/rails_error_dashboard/services/crash_capture.rb +234 -0
  32. data/lib/rails_error_dashboard/services/diagnostic_dump_generator.rb +98 -0
  33. data/lib/rails_error_dashboard/services/local_variable_capturer.rb +207 -0
  34. data/lib/rails_error_dashboard/services/swallowed_exception_tracker.rb +277 -0
  35. data/lib/rails_error_dashboard/services/system_health_snapshot.rb +33 -0
  36. data/lib/rails_error_dashboard/services/variable_serializer.rb +326 -0
  37. data/lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb +94 -0
  38. data/lib/rails_error_dashboard/version.rb +1 -1
  39. data/lib/rails_error_dashboard.rb +9 -0
  40. data/lib/tasks/error_dashboard.rake +34 -0
  41. metadata +23 -2
@@ -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 %>
@@ -0,0 +1,126 @@
1
+ <% content_for :page_title, "Swallowed Exceptions" %>
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-eye-slash me-2"></i>
7
+ Swallowed Exceptions
8
+ </h1>
9
+
10
+ <div class="btn-group" role="group">
11
+ <%= link_to swallowed_exceptions_errors_path(days: 7), class: "btn btn-sm #{@days == 7 ? 'btn-primary' : 'btn-outline-primary'}" do %>
12
+ 7 Days
13
+ <% end %>
14
+ <%= link_to swallowed_exceptions_errors_path(days: 30), class: "btn btn-sm #{@days == 30 ? 'btn-primary' : 'btn-outline-primary'}" do %>
15
+ 30 Days
16
+ <% end %>
17
+ <%= link_to swallowed_exceptions_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_count == 0 %>
24
+ <div class="text-center py-5">
25
+ <i class="bi bi-check-circle display-1 text-success mb-3"></i>
26
+ <h4 class="text-muted">No Swallowed Exceptions Detected</h4>
27
+ <p class="text-muted">
28
+ No exceptions with a rescue ratio above <%= (RailsErrorDashboard.configuration.swallowed_exception_threshold * 100).round %>% were detected in 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 swallowed exceptions are detected:</h6>
33
+ <ul class="mb-0">
34
+ <li>TracePoint(:raise) counts every exception raised</li>
35
+ <li>TracePoint(:rescue) counts every exception rescued</li>
36
+ <li>High rescue/raise ratio = likely swallowed (silently rescued)</li>
37
+ <li>Requires Ruby 3.3+ and <code>detect_swallowed_exceptions = true</code></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-danger"><%= @unique_count %></div>
48
+ <small class="text-muted">Swallowed Patterns</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-warning"><%= number_with_delimiter(@total_rescue_count) %></div>
56
+ <small class="text-muted">Total Rescues</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"><%= number_with_delimiter(@total_raise_count) %></div>
64
+ <small class="text-muted">Total Raises</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-eye-slash text-danger me-2"></i>
74
+ Swallowed Exception Patterns
75
+ <span class="badge bg-danger"><%= @unique_count %></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>Exception Class</th>
85
+ <th width="250">Raise Location</th>
86
+ <th width="250">Rescue Location</th>
87
+ <th width="100">Raises</th>
88
+ <th width="100">Rescues</th>
89
+ <th width="100">Ratio</th>
90
+ <th width="140">Last Seen</th>
91
+ </tr>
92
+ </thead>
93
+ <tbody>
94
+ <% @entries.each do |entry| %>
95
+ <tr>
96
+ <td><code style="font-size: 0.85em;"><%= entry[:exception_class] %></code></td>
97
+ <td><small class="text-muted text-break"><%= entry[:raise_location] %></small></td>
98
+ <td><small class="text-muted text-break"><%= entry[:rescue_location] || "Unknown" %></small></td>
99
+ <td><span class="badge bg-info text-dark"><%= number_with_delimiter(entry[:raise_count]) %></span></td>
100
+ <td><span class="badge bg-warning text-dark"><%= number_with_delimiter(entry[:rescue_count]) %></span></td>
101
+ <td>
102
+ <% ratio_pct = (entry[:rescue_ratio] * 100).round(1) %>
103
+ <span class="badge <%= ratio_pct >= 99 ? 'bg-danger' : ratio_pct >= 95 ? 'bg-warning text-dark' : 'bg-secondary' %>">
104
+ <%= ratio_pct %>%
105
+ </span>
106
+ </td>
107
+ <td><%= local_time_ago(entry[:last_seen]) %></td>
108
+ </tr>
109
+ <% end %>
110
+ </tbody>
111
+ </table>
112
+ </div>
113
+ </div>
114
+ <div class="card-footer bg-white border-top d-flex justify-content-between align-items-center">
115
+ <div>
116
+ <small class="text-muted">
117
+ <i class="bi bi-lightbulb text-warning"></i> Swallowed exceptions are raised then silently rescued. They may hide real bugs.
118
+ </small>
119
+ </div>
120
+ <div>
121
+ <%== @pagy.series_nav(:bootstrap) if @pagy.pages > 1 %>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ <% end %>
126
+ </div>
data/config/routes.rb CHANGED
@@ -27,6 +27,10 @@ RailsErrorDashboard::Engine.routes.draw do
27
27
  get :cache_health_summary
28
28
  get :job_health_summary
29
29
  get :database_health_summary
30
+ get :swallowed_exceptions
31
+ get :rack_attack_summary
32
+ get :diagnostic_dumps
33
+ post :create_diagnostic_dump
30
34
  post :batch_action
31
35
  end
32
36
  end
@@ -89,6 +89,12 @@ class CreateRailsErrorDashboardCompleteSchema < ActiveRecord::Migration[7.0]
89
89
  # System health snapshot (from 20260304000001)
90
90
  t.text :system_health
91
91
 
92
+ # Local variable capture (from 20260306000001)
93
+ t.text :local_variables
94
+
95
+ # Instance variable capture (from 20260306000002)
96
+ t.text :instance_variables
97
+
92
98
  t.timestamps
93
99
  end
94
100
 
@@ -191,6 +197,32 @@ class CreateRailsErrorDashboardCompleteSchema < ActiveRecord::Migration[7.0]
191
197
  add_index :rails_error_dashboard_error_comments, :error_log_id
192
198
  add_index :rails_error_dashboard_error_comments, [ :error_log_id, :created_at ], name: "index_error_comments_on_error_and_time"
193
199
 
200
+ # Create swallowed_exceptions table (from 20260306000003)
201
+ create_table :rails_error_dashboard_swallowed_exceptions do |t|
202
+ t.string :exception_class, null: false
203
+ t.string :raise_location, null: false, limit: 500
204
+ t.string :rescue_location, limit: 500
205
+ t.datetime :period_hour, null: false
206
+ t.integer :raise_count, null: false, default: 0
207
+ t.integer :rescue_count, null: false, default: 0
208
+ t.datetime :last_seen_at
209
+ t.bigint :application_id
210
+ t.timestamps
211
+ end
212
+ add_index :rails_error_dashboard_swallowed_exceptions,
213
+ [ :exception_class, :period_hour ],
214
+ name: "index_swallowed_exceptions_on_class_and_hour"
215
+ add_index :rails_error_dashboard_swallowed_exceptions,
216
+ :period_hour,
217
+ name: "index_swallowed_exceptions_on_period_hour"
218
+ add_index :rails_error_dashboard_swallowed_exceptions,
219
+ [ :application_id, :period_hour ],
220
+ name: "index_swallowed_exceptions_on_app_and_hour"
221
+ add_index :rails_error_dashboard_swallowed_exceptions,
222
+ [ :exception_class, :raise_location, :rescue_location, :period_hour, :application_id ],
223
+ unique: true,
224
+ name: "index_swallowed_exceptions_upsert_key"
225
+
194
226
  # PostgreSQL-specific indexes (BRIN + functional for time-series optimization)
195
227
  if ActiveRecord::Base.connection.adapter_name.downcase == "postgresql"
196
228
  execute <<-SQL
@@ -221,6 +253,7 @@ class CreateRailsErrorDashboardCompleteSchema < ActiveRecord::Migration[7.0]
221
253
  end
222
254
 
223
255
  def down
256
+ drop_table :rails_error_dashboard_swallowed_exceptions, if_exists: true
224
257
  drop_table :rails_error_dashboard_error_comments, if_exists: true
225
258
  drop_table :rails_error_dashboard_cascade_patterns, if_exists: true
226
259
  drop_table :rails_error_dashboard_error_baselines, if_exists: true
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddLocalVariablesToErrorLogs < ActiveRecord::Migration[7.0]
4
+ def up
5
+ return if column_exists?(:rails_error_dashboard_error_logs, :local_variables)
6
+
7
+ add_column :rails_error_dashboard_error_logs, :local_variables, :text
8
+ end
9
+
10
+ def down
11
+ remove_column :rails_error_dashboard_error_logs, :local_variables if column_exists?(:rails_error_dashboard_error_logs, :local_variables)
12
+ end
13
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddInstanceVariablesToErrorLogs < ActiveRecord::Migration[7.0]
4
+ def change
5
+ add_column :rails_error_dashboard_error_logs, :instance_variables, :text
6
+ end
7
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRailsErrorDashboardSwallowedExceptions < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :rails_error_dashboard_swallowed_exceptions do |t|
6
+ t.string :exception_class, null: false
7
+ t.string :raise_location, null: false, limit: 500
8
+ t.string :rescue_location, limit: 500
9
+ t.datetime :period_hour, null: false
10
+ t.integer :raise_count, null: false, default: 0
11
+ t.integer :rescue_count, null: false, default: 0
12
+ t.datetime :last_seen_at
13
+ t.bigint :application_id
14
+ t.timestamps
15
+ end
16
+
17
+ add_index :rails_error_dashboard_swallowed_exceptions,
18
+ [ :exception_class, :period_hour ],
19
+ name: "index_swallowed_exceptions_on_class_and_hour"
20
+
21
+ add_index :rails_error_dashboard_swallowed_exceptions,
22
+ :period_hour,
23
+ name: "index_swallowed_exceptions_on_period_hour"
24
+
25
+ add_index :rails_error_dashboard_swallowed_exceptions,
26
+ [ :application_id, :period_hour ],
27
+ name: "index_swallowed_exceptions_on_app_and_hour"
28
+
29
+ add_index :rails_error_dashboard_swallowed_exceptions,
30
+ [ :exception_class, :raise_location, :rescue_location, :period_hour, :application_id ],
31
+ unique: true,
32
+ name: "index_swallowed_exceptions_upsert_key"
33
+ end
34
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateRailsErrorDashboardDiagnosticDumps < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :rails_error_dashboard_diagnostic_dumps do |t|
6
+ t.references :application, null: false,
7
+ foreign_key: { to_table: :rails_error_dashboard_applications }
8
+ t.text :dump_data, null: false
9
+ t.string :note
10
+ t.datetime :captured_at, null: false
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :rails_error_dashboard_diagnostic_dumps, :captured_at,
15
+ name: "index_diagnostic_dumps_on_captured_at"
16
+ end
17
+ end
@@ -32,6 +32,9 @@ module RailsErrorDashboard
32
32
  class_option :git_blame, type: :boolean, default: false, desc: "Enable git blame integration (NEW!)"
33
33
  class_option :breadcrumbs, type: :boolean, default: false, desc: "Enable breadcrumbs (request activity trail)"
34
34
  class_option :system_health, type: :boolean, default: false, desc: "Enable system health snapshot at error time"
35
+ class_option :swallowed_exceptions, type: :boolean, default: false, desc: "Enable swallowed exception detection (Ruby 3.3+)"
36
+ class_option :crash_capture, type: :boolean, default: false, desc: "Enable process crash capture via at_exit hook"
37
+ class_option :diagnostic_dump, type: :boolean, default: false, desc: "Enable on-demand diagnostic dump"
35
38
 
36
39
  def welcome_message
37
40
  say "\n"
@@ -174,6 +177,24 @@ module RailsErrorDashboard
174
177
  name: "System Health Snapshot (NEW!)",
175
178
  description: "Capture GC, memory, threads, connection pool at error time",
176
179
  category: "Developer Tools"
180
+ },
181
+ {
182
+ key: :swallowed_exceptions,
183
+ name: "Swallowed Exception Detection (Ruby 3.3+)",
184
+ description: "Detect exceptions being silently rescued via TracePoint(:rescue)",
185
+ category: "Developer Tools"
186
+ },
187
+ {
188
+ key: :crash_capture,
189
+ name: "Process Crash Capture",
190
+ description: "Capture fatal crashes via at_exit hook (written to disk, imported on next boot)",
191
+ category: "Developer Tools"
192
+ },
193
+ {
194
+ key: :diagnostic_dump,
195
+ name: "Diagnostic Dump",
196
+ description: "On-demand system state snapshot (rake task + dashboard page)",
197
+ category: "Developer Tools"
177
198
  }
178
199
  ]
179
200
 
@@ -195,6 +216,11 @@ module RailsErrorDashboard
195
216
  say " ✗ Disabled", :white
196
217
  end
197
218
  end
219
+
220
+ if @selected_features[feature[:key]] && feature[:key] == :swallowed_exceptions && RUBY_VERSION < "3.3"
221
+ say " ⚠ Warning: This feature requires Ruby 3.3+ (you have #{RUBY_VERSION})", :yellow
222
+ say " The feature will be included in your config but won't activate until you upgrade Ruby.", :white
223
+ end
198
224
  end
199
225
 
200
226
  say "\n"
@@ -303,6 +329,9 @@ module RailsErrorDashboard
303
329
  @enable_git_blame = @selected_features&.dig(:git_blame) || options[:git_blame]
304
330
  @enable_breadcrumbs = @selected_features&.dig(:breadcrumbs) || options[:breadcrumbs]
305
331
  @enable_system_health = @selected_features&.dig(:system_health) || options[:system_health]
332
+ @enable_swallowed_exceptions = @selected_features&.dig(:swallowed_exceptions) || options[:swallowed_exceptions]
333
+ @enable_crash_capture = @selected_features&.dig(:crash_capture) || options[:crash_capture]
334
+ @enable_diagnostic_dump = @selected_features&.dig(:diagnostic_dump) || options[:diagnostic_dump]
306
335
 
307
336
  template "initializer.rb", "config/initializers/rails_error_dashboard.rb"
308
337
  end
@@ -405,6 +434,9 @@ module RailsErrorDashboard
405
434
  developer_tools_features << "Git Blame" if @enable_git_blame
406
435
  developer_tools_features << "Breadcrumbs" if @enable_breadcrumbs
407
436
  developer_tools_features << "System Health" if @enable_system_health
437
+ developer_tools_features << "Swallowed Exception Detection" if @enable_swallowed_exceptions
438
+ developer_tools_features << "Process Crash Capture" if @enable_crash_capture
439
+ developer_tools_features << "Diagnostic Dump" if @enable_diagnostic_dump
408
440
 
409
441
  if developer_tools_features.any?
410
442
  say "\nDeveloper Tools:", :cyan
@@ -356,7 +356,54 @@ RailsErrorDashboard.configure do |config|
356
356
  config.enable_system_health = false
357
357
 
358
358
  <% end -%>
359
+ <% if @enable_swallowed_exceptions -%>
360
+ # Swallowed Exception Detection - ENABLED
361
+ # Requires Ruby 3.3+ — detects exceptions that are raised then silently rescued
362
+ # Uses TracePoint(:rescue), which was added in Ruby 3.3 (Feature #19572)
363
+ config.detect_swallowed_exceptions = true
364
+ config.swallowed_exception_threshold = 0.95 # Rescue ratio to flag (95%+)
365
+ # config.swallowed_exception_flush_interval = 60 # Seconds between DB flushes
366
+ # config.swallowed_exception_max_cache_size = 1000 # Max entries per thread
367
+ # config.swallowed_exception_ignore_classes = [] # App-specific exceptions to skip
368
+ # To disable: Set config.detect_swallowed_exceptions = false
359
369
 
370
+ <% else -%>
371
+ # Swallowed Exception Detection - DISABLED
372
+ # Requires Ruby 3.3+ (TracePoint(:rescue) not available before 3.3)
373
+ # To enable: Set config.detect_swallowed_exceptions = true
374
+ config.detect_swallowed_exceptions = false
375
+ # config.swallowed_exception_threshold = 0.95
376
+
377
+ <% end -%>
378
+ <% if @enable_diagnostic_dump -%>
379
+ # Diagnostic Dump - ENABLED
380
+ # On-demand system state snapshot via rake task or dashboard button
381
+ config.enable_diagnostic_dump = true
382
+ # To disable: Set config.enable_diagnostic_dump = false
383
+
384
+ <% else -%>
385
+ # Diagnostic Dump - DISABLED
386
+ # On-demand system state snapshot (rake task + dashboard page)
387
+ # To enable: Set config.enable_diagnostic_dump = true
388
+ config.enable_diagnostic_dump = false
389
+
390
+ <% end -%>
391
+ <% if @enable_crash_capture -%>
392
+ # Process Crash Capture - ENABLED
393
+ # Captures fatal crashes via at_exit hook. Crash data is written to disk as JSON
394
+ # and imported into the database on next boot. Zero runtime overhead.
395
+ config.enable_crash_capture = true
396
+ # config.crash_capture_path = "/tmp/my_app_crashes" # Default: Dir.tmpdir
397
+ # To disable: Set config.enable_crash_capture = false
398
+
399
+ <% else -%>
400
+ # Process Crash Capture - DISABLED
401
+ # Captures fatal crashes via at_exit hook (written to disk, imported on next boot)
402
+ # To enable: Set config.enable_crash_capture = true
403
+ config.enable_crash_capture = false
404
+ # config.crash_capture_path = "/tmp/my_app_crashes"
405
+
406
+ <% end -%>
360
407
  # Repository settings (auto-detected from git remote, optional override)
361
408
  # config.repository_url = ENV["REPOSITORY_URL"] # e.g., "https://github.com/user/repo"
362
409
  # config.repository_branch = ENV.fetch("REPOSITORY_BRANCH", "main") # Default branch
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Command: Upsert swallowed exception raise/rescue counts into the database.
6
+ #
7
+ # Receives snapshot hashes from SwallowedExceptionTracker and merges them
8
+ # into hourly-bucketed rows. Uses find_or_initialize_by + increment for
9
+ # cross-database compatibility (no raw SQL upsert).
10
+ #
11
+ # raise_counts keys: "ClassName|path:line"
12
+ # rescue_counts keys: "ClassName|raise_path:line->rescue_path:line"
13
+ class FlushSwallowedExceptions
14
+ def self.call(raise_counts:, rescue_counts:)
15
+ new(raise_counts: raise_counts, rescue_counts: rescue_counts).call
16
+ end
17
+
18
+ def initialize(raise_counts:, rescue_counts:)
19
+ @raise_counts = raise_counts
20
+ @rescue_counts = rescue_counts
21
+ end
22
+
23
+ def call
24
+ period = Time.current.beginning_of_hour
25
+ app_id = current_application_id
26
+
27
+ # Process raise counts
28
+ @raise_counts.each do |key, count|
29
+ class_name, location = key.split("|", 2)
30
+ next if class_name.blank? || location.blank?
31
+
32
+ upsert_raise(class_name, location, period, app_id, count)
33
+ end
34
+
35
+ # Process rescue counts
36
+ @rescue_counts.each do |key, count|
37
+ class_name, locations = key.split("|", 2)
38
+ next if class_name.blank? || locations.blank?
39
+
40
+ raise_loc, rescue_loc = locations.split("->", 2)
41
+ next if raise_loc.blank?
42
+
43
+ upsert_rescue(class_name, raise_loc, rescue_loc, period, app_id, count)
44
+ end
45
+ rescue => e
46
+ RailsErrorDashboard::Logger.debug(
47
+ "[RailsErrorDashboard] FlushSwallowedExceptions failed: #{e.class} - #{e.message}"
48
+ )
49
+ end
50
+
51
+ private
52
+
53
+ def upsert_raise(class_name, location, period, app_id, count)
54
+ record = SwallowedException.find_or_initialize_by(
55
+ exception_class: truncate(class_name, 255),
56
+ raise_location: truncate(location, 500),
57
+ rescue_location: nil,
58
+ period_hour: period,
59
+ application_id: app_id
60
+ )
61
+
62
+ record.raise_count = (record.raise_count || 0) + count
63
+ record.last_seen_at = Time.current
64
+ record.save!
65
+ rescue => e
66
+ RailsErrorDashboard::Logger.debug(
67
+ "[RailsErrorDashboard] FlushSwallowedExceptions.upsert_raise failed for #{class_name}: #{e.message}"
68
+ )
69
+ end
70
+
71
+ def upsert_rescue(class_name, raise_loc, rescue_loc, period, app_id, count)
72
+ record = SwallowedException.find_or_initialize_by(
73
+ exception_class: truncate(class_name, 255),
74
+ raise_location: truncate(raise_loc, 500),
75
+ rescue_location: rescue_loc.present? ? truncate(rescue_loc, 500) : nil,
76
+ period_hour: period,
77
+ application_id: app_id
78
+ )
79
+
80
+ record.rescue_count = (record.rescue_count || 0) + count
81
+ record.last_seen_at = Time.current
82
+ record.save!
83
+ rescue => e
84
+ RailsErrorDashboard::Logger.debug(
85
+ "[RailsErrorDashboard] FlushSwallowedExceptions.upsert_rescue failed for #{class_name}: #{e.message}"
86
+ )
87
+ end
88
+
89
+ def current_application_id
90
+ app_name = RailsErrorDashboard.configuration.application_name
91
+ return nil unless app_name.present?
92
+
93
+ Application.find_by(name: app_name)&.id
94
+ rescue => e
95
+ nil
96
+ end
97
+
98
+ def truncate(str, max)
99
+ str.to_s.truncate(max, omission: "")
100
+ end
101
+ end
102
+ end
103
+ end