rails_error_dashboard 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a69809a78e932510ab0212e0abd6021c925a28a7c5d80d69942ef1c80a7476db
4
- data.tar.gz: 4e62ac66ce28cd4a3c34ac4df4ac682a42656fe157b7918d044fddd3a9779f4e
3
+ metadata.gz: e514a83af22b8b3938befb45429a46617ee0f547a6831d98cdb44e435f3e3a1c
4
+ data.tar.gz: 8329f6c148cfdd9c2d5fb9cd87e89e77f93c7a45beb55ec278218bb5f12e2dde
5
5
  SHA512:
6
- metadata.gz: a99bb20a9479fe2669426f6798ff368812834084a6c02a2db9739789a7b5a5a724bb08916e3891ee23a1de2d3df5539aed43f64e2791d4275c62de6c364ea5b9
7
- data.tar.gz: cb6a155bf3d4a62c21f51d7a7f878d5f5676947fcd441bfda08f36877e37e795093dc756f13d4318b8dcb4cc2ecacfac1e2cfcdcaf1df86481ed5f3fd9f70578
6
+ metadata.gz: 8bfe23263da657c250f8c982ec9271e1ad6158f28041db817a3fc9ee529a0dbdadc8045f30f7633a771265ae0592cc2e0903af215b63f447f449ed0dc478039c
7
+ data.tar.gz: 889dac2f878d6d31afa91c4efbd20be4432452046bcb5a1071f507e23b41d7b5bcdb07ae67468bf3f678917084dc5149492fa01733276918fc52c4daf6f0a766
data/README.md CHANGED
@@ -22,7 +22,7 @@ gem 'rails_error_dashboard'
22
22
 
23
23
  **[rails-error-dashboard.anjan.dev](https://rails-error-dashboard.anjan.dev)** — Username: `gandalf` · Password: `youshallnotpass`
24
24
 
25
- > **Beta Software** — Functional and tested (2,100+ tests passing), but the API may change before v1.0. Supports Rails 7.0-8.1 and Ruby 3.2-4.0.
25
+ > **Beta Software** — Functional and tested (2,600+ tests passing), but the API may change before v1.0. Supports Rails 7.0-8.1 and Ruby 3.2-4.0.
26
26
 
27
27
  ### Screenshots
28
28
 
@@ -53,6 +53,8 @@ gem 'rails_error_dashboard'
53
53
  | SaaS pricing tiers and usage limits | Unlimited errors, unlimited projects |
54
54
  | Vendor lock-in with proprietary APIs | 100% open source, fully portable |
55
55
  | Complex SDK setup and external services | 5-minute Rails Engine installation |
56
+ | Pay extra for local variable capture (Sentry) | Local + instance variables included free |
57
+ | No tool detects silently rescued exceptions | Swallowed exception detection built in |
56
58
 
57
59
  ---
58
60
 
@@ -60,7 +62,7 @@ gem 'rails_error_dashboard'
60
62
 
61
63
  ### Core (Always Enabled)
62
64
 
63
- Error capture from controllers, jobs, and middleware. Beautiful Bootstrap 5 dashboard with dark/light mode, search, filtering, and real-time updates. Analytics with trend charts, severity breakdown, and spike detection. Workflow management with assignment, priority, snooze, comments, and batch operations. Security via HTTP Basic Auth or custom lambda (Devise, Warden, session-based). Exception cause chains, enriched HTTP context, custom fingerprinting, CurrentAttributes integration, auto-reopen on recurrence, and sensitive data filtering — all built in.
65
+ Error capture from controllers, jobs, and middleware. Beautiful Bootstrap 5 dashboard with dark/light mode, search, filtering, and real-time updates. Analytics with trend charts, severity breakdown, and spike detection. Workflow management with assignment, priority, snooze, mute/unmute (notification suppression), comments, and batch operations. Security via HTTP Basic Auth or custom lambda (Devise, Warden, session-based). Exception cause chains, enriched HTTP context, custom fingerprinting, CurrentAttributes integration, auto-reopen on recurrence, and sensitive data filtering — all built in.
64
66
 
65
67
  ### Optional Features
66
68
 
@@ -85,10 +87,12 @@ config.enable_breadcrumbs = true
85
87
  <details>
86
88
  <summary><strong>System Health Snapshot</strong></summary>
87
89
 
88
- Know your app's runtime state at the moment of failure — GC stats, process memory, thread count, connection pool utilization, and Puma thread stats captured automatically.
90
+ Know your app's runtime state at the moment of failure — GC stats, process memory, thread count, connection pool utilization, Puma thread stats, RubyVM cache health, and YJIT compilation stats captured automatically.
89
91
 
90
92
  - Sub-millisecond total snapshot, every metric individually rescue-wrapped
91
93
  - No ObjectSpace scanning, no Thread backtraces, no subprocess calls
94
+ - RubyVM.stat: constant cache invalidations, shape cache stats
95
+ - YJIT runtime stats: compiled iseqs, invalidation count, code region sizes
92
96
 
93
97
  ```ruby
94
98
  config.enable_system_health = true
@@ -181,6 +185,98 @@ Seven analysis engines built in:
181
185
  [Complete documentation →](docs/FEATURES.md#advanced-analytics-features)
182
186
  </details>
183
187
 
188
+ <details>
189
+ <summary><strong>Local Variable + Instance Variable Capture</strong></summary>
190
+
191
+ See the exact values of local variables and instance variables at the moment an exception was raised — the most valuable debugging context possible.
192
+
193
+ - TracePoint(`:raise`) captures locals and ivars before the stack unwinds
194
+ - Configurable limits: max variable count, nesting depth, string truncation length
195
+ - Sensitive data auto-filtered via Rails `filter_parameters` — passwords, tokens, and PII never stored
196
+ - Never stores Binding objects — values extracted immediately, Binding is GC'd
197
+ - Independent config flags: enable one or both
198
+
199
+ ![Local Variables](docs/images/local-variables.png)
200
+
201
+ ```ruby
202
+ config.enable_local_variables = true
203
+ config.enable_instance_variables = true
204
+ ```
205
+
206
+ [Complete documentation →](docs/FEATURES.md)
207
+ </details>
208
+
209
+ <details>
210
+ <summary><strong>Swallowed Exception Detection</strong></summary>
211
+
212
+ Detect exceptions that are raised but silently rescued — the hardest bugs to find. No other error tracker does this.
213
+
214
+ - Uses TracePoint(`:raise`) + TracePoint(`:rescue`) to track exception lifecycle
215
+ - Identifies code paths where exceptions are caught but never logged or re-raised
216
+ - Dashboard page at `/errors/swallowed_exceptions` shows detection counts, locations, and patterns
217
+ - Memory-bounded aggregation with background flush
218
+ - Requires Ruby 3.3+
219
+
220
+ ![Swallowed Exceptions](docs/images/swallowed-exceptions.png)
221
+
222
+ ```ruby
223
+ config.detect_swallowed_exceptions = true
224
+ ```
225
+
226
+ [Complete documentation →](docs/FEATURES.md)
227
+ </details>
228
+
229
+ <details>
230
+ <summary><strong>On-Demand Diagnostic Dump</strong></summary>
231
+
232
+ Snapshot your app's entire system state on demand — environment, GC stats, threads, connection pool, memory, job queue health, and more.
233
+
234
+ - Trigger via dashboard button or `rake rails_error_dashboard:diagnostic_dump`
235
+ - Dashboard page at `/errors/diagnostic_dumps` with full history
236
+ - Useful for debugging intermittent production issues without reproducing them
237
+
238
+ ![Diagnostic Dumps](docs/images/diagnostic-dumps.png)
239
+
240
+ ```ruby
241
+ config.enable_diagnostic_dump = true
242
+ ```
243
+
244
+ [Complete documentation →](docs/FEATURES.md)
245
+ </details>
246
+
247
+ <details>
248
+ <summary><strong>Rack Attack Event Tracking</strong></summary>
249
+
250
+ Track Rack Attack security events (throttles, blocklists, tracks) as breadcrumbs attached to errors, with a dedicated summary page.
251
+
252
+ - Captures throttle, blocklist, and track events automatically
253
+ - Dashboard page at `/errors/rack_attack_summary` with event breakdown
254
+ - Requires breadcrumbs to be enabled
255
+
256
+ ```ruby
257
+ config.enable_rack_attack_tracking = true
258
+ ```
259
+
260
+ [Complete documentation →](docs/FEATURES.md)
261
+ </details>
262
+
263
+ <details>
264
+ <summary><strong>Process Crash Capture</strong></summary>
265
+
266
+ Capture unhandled exceptions that crash the Ruby process via an `at_exit` hook — the last line of defense.
267
+
268
+ - Disk-based fallback: writes crash data to disk because the database may be unavailable during shutdown
269
+ - Imported automatically on next boot
270
+ - Captures exception details, backtrace, uptime, GC stats, thread count, and cause chain
271
+ - A self-hosted only feature — impossible for SaaS tools
272
+
273
+ ```ruby
274
+ config.enable_crash_capture = true
275
+ ```
276
+
277
+ [Complete documentation →](docs/FEATURES.md)
278
+ </details>
279
+
184
280
  <details>
185
281
  <summary><strong>Plugin System</strong></summary>
186
282
 
@@ -304,7 +400,7 @@ Built with **CQRS (Command/Query Responsibility Segregation)**:
304
400
 
305
401
  ## Testing
306
402
 
307
- 2,100+ tests covering unit, integration, and browser-based system tests.
403
+ 2,600+ tests covering unit, integration, and browser-based system tests.
308
404
 
309
405
  ```bash
310
406
  bundle exec rspec # Full suite
@@ -338,22 +434,24 @@ Available as open source under the [MIT License](https://opensource.org/licenses
338
434
 
339
435
  ## Acknowledgments
340
436
 
341
- Built with [Rails](https://rubyonrails.org/) · UI by [Bootstrap 5](https://getbootstrap.com/) · Charts by [Chart.js](https://www.chartjs.org/) · Pagination by [Pagy](https://github.com/ddnexus/pagy)
437
+ Built with [Rails](https://rubyonrails.org/) · UI by [Bootstrap 5](https://getbootstrap.com/) · Charts by [Chart.js](https://www.chartjs.org/) · Pagination by [Pagy](https://github.com/ddnexus/pagy) · Docs theme by [Jekyll VitePress Theme](https://jekyll-vitepress.dev/) by [@crmne](https://github.com/crmne)
342
438
 
343
439
  ## Contributors
344
440
 
345
441
  [![Contributors](https://contrib.rocks/image?repo=AnjanJ/rails_error_dashboard)](https://github.com/AnjanJ/rails_error_dashboard/graphs/contributors)
346
442
 
347
- Special thanks to [@bonniesimon](https://github.com/bonniesimon), [@gundestrup](https://github.com/gundestrup), and [@midwire](https://github.com/midwire). See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
443
+ Special thanks to [@bonniesimon](https://github.com/bonniesimon), [@gundestrup](https://github.com/gundestrup), [@midwire](https://github.com/midwire), [@RafaelTurtle](https://github.com/RafaelTurtle), and [@j4rs](https://github.com/j4rs). See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
348
444
 
349
445
  ---
350
446
 
351
447
  ## Support
352
448
 
353
- If this gem saves you some headaches (or some money on error tracking SaaS), consider [buying me a coffee](https://buymeacoffee.com/anjanj). It keeps the project going and lets me know people are finding it useful.
449
+ If this gem saves you some headaches (or some money on error tracking SaaS), consider buying me a coffee. It keeps the project going and lets me know people are finding it useful.
450
+
451
+ <a href="https://www.buymeacoffee.com/anjanj" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="200"></a>
354
452
 
355
453
  ---
356
454
 
357
- **Made with care by [Anjan](https://www.anjan.dev) for the Rails community**
455
+ **Made with ❤️ by [Anjan](https://anjan.dev)**
358
456
 
359
457
  *One Gem to rule them all, One Gem to find them, One Gem to bring them all, and in the dashboard bind them.*
@@ -19,6 +19,7 @@ module RailsErrorDashboard
19
19
  assignee_name
20
20
  priority_level
21
21
  hide_snoozed
22
+ hide_muted
22
23
  reopened
23
24
  sort_by
24
25
  sort_direction
@@ -121,6 +122,16 @@ module RailsErrorDashboard
121
122
  redirect_to error_path(@error)
122
123
  end
123
124
 
125
+ def mute
126
+ @error = Commands::MuteError.call(params[:id], muted_by: params[:muted_by], reason: params[:reason])
127
+ redirect_to error_path(@error)
128
+ end
129
+
130
+ def unmute
131
+ @error = Commands::UnmuteError.call(params[:id])
132
+ redirect_to error_path(@error)
133
+ end
134
+
124
135
  def update_status
125
136
  result = Commands::UpdateErrorStatus.call(params[:id], status: params[:status], comment: params[:comment])
126
137
  redirect_to error_path(result[:error])
@@ -200,6 +211,10 @@ module RailsErrorDashboard
200
211
  resolved_by_name: params[:resolved_by_name],
201
212
  resolution_comment: params[:resolution_comment]
202
213
  )
214
+ when "mute"
215
+ Commands::BatchMuteErrors.call(error_ids, muted_by: params[:muted_by])
216
+ when "unmute"
217
+ Commands::BatchUnmuteErrors.call(error_ids)
203
218
  when "delete"
204
219
  Commands::BatchDeleteErrors.call(error_ids)
205
220
  else
@@ -71,6 +71,8 @@ module RailsErrorDashboard
71
71
  scope :unassigned, -> { where(assigned_to: nil) }
72
72
  scope :by_assignee, ->(name) { where(assigned_to: name) }
73
73
  scope :by_priority, ->(level) { where(priority_level: level) }
74
+ scope :muted, -> { where(muted: true) }
75
+ scope :unmuted, -> { where(muted: false) }
74
76
 
75
77
  # Set defaults and tracking
76
78
  before_validation :set_defaults, on: :create
@@ -160,6 +162,20 @@ module RailsErrorDashboard
160
162
  snoozed_until.present? && snoozed_until >= Time.current
161
163
  end
162
164
 
165
+ # Mute query — checks column existence for backward compatibility
166
+ def muted?
167
+ self.class.column_names.include?("muted") && muted == true
168
+ end
169
+
170
+ # Mute/unmute convenience methods — delegate to Commands
171
+ def mute!(mute_data = {})
172
+ Commands::MuteError.call(id, **mute_data)
173
+ end
174
+
175
+ def unmute!
176
+ Commands::UnmuteError.call(id)
177
+ end
178
+
163
179
  # Priority methods
164
180
  def priority_label
165
181
  priority_data = PRIORITY_LEVELS[priority_level]
@@ -90,6 +90,9 @@
90
90
  <% if error.reopened? %>
91
91
  <i class="bi bi-arrow-counterclockwise text-warning ms-1" data-bs-toggle="tooltip" title="Reopened"></i>
92
92
  <% end %>
93
+ <% if error.respond_to?(:muted?) && error.muted? %>
94
+ <i class="bi bi-bell-slash text-secondary ms-1" data-bs-toggle="tooltip" title="Muted - notifications silenced"></i>
95
+ <% end %>
93
96
  </td>
94
97
  <td onclick="event.stopPropagation();">
95
98
  <%= link_to error_path(error), class: "btn btn-sm btn-outline-primary" do %>
@@ -137,3 +137,38 @@
137
137
  </div>
138
138
  </div>
139
139
  </div>
140
+
141
+ <!-- Mute Notifications Modal -->
142
+ <div class="modal fade" id="muteModal" tabindex="-1" aria-labelledby="muteModalLabel" aria-hidden="true">
143
+ <div class="modal-dialog">
144
+ <div class="modal-content">
145
+ <%= form_with url: mute_error_path(error), method: :post do |f| %>
146
+ <div class="modal-header">
147
+ <h5 class="modal-title" id="muteModalLabel">
148
+ <i class="bi bi-bell-slash"></i> Mute Notifications
149
+ </h5>
150
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
151
+ </div>
152
+ <div class="modal-body">
153
+ <p class="text-muted">
154
+ Muted errors still appear in the dashboard but will not trigger any notifications
155
+ (Slack, email, Discord, PagerDuty, webhooks).
156
+ </p>
157
+ <div class="mb-3">
158
+ <label for="muted_by" class="form-label">Your Name (Optional)</label>
159
+ <%= text_field_tag :muted_by, nil, class: "form-control", placeholder: "e.g., John Doe" %>
160
+ </div>
161
+ <div class="mb-3">
162
+ <label for="reason" class="form-label">Reason (Optional)</label>
163
+ <%= text_area_tag :reason, nil, class: "form-control", rows: 3, placeholder: "Why are you muting this error?" %>
164
+ <small class="text-muted">Reason will be added as a comment</small>
165
+ </div>
166
+ </div>
167
+ <div class="modal-footer">
168
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
169
+ <%= submit_tag "Mute Notifications", class: "btn btn-secondary", data: { action: "click->loading#click" } %>
170
+ </div>
171
+ <% end %>
172
+ </div>
173
+ </div>
174
+ </div>
@@ -172,6 +172,35 @@
172
172
  </div>
173
173
  <% end %>
174
174
 
175
+ <!-- Mute Notifications -->
176
+ <% if error.respond_to?(:muted?) %>
177
+ <div class="mb-3">
178
+ <small class="metadata-label d-block mb-1">Notifications</small>
179
+ <% if error.muted? %>
180
+ <div class="alert alert-secondary py-2 mb-2">
181
+ <i class="bi bi-bell-slash"></i>
182
+ <strong>Muted</strong><br>
183
+ <% if error.muted_by.present? %>
184
+ <small>By <%= error.muted_by %></small><br>
185
+ <% end %>
186
+ <% if error.muted_reason.present? %>
187
+ <small><em><%= error.muted_reason %></em></small><br>
188
+ <% end %>
189
+ <% if error.muted_at.present? %>
190
+ <small>Since <%= local_time(error.muted_at, format: :short) %></small>
191
+ <% end %>
192
+ </div>
193
+ <%= button_to unmute_error_path(error), method: :post, class: "btn btn-sm btn-outline-primary" do %>
194
+ <i class="bi bi-bell"></i> Unmute
195
+ <% end %>
196
+ <% else %>
197
+ <button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#muteModal">
198
+ <i class="bi bi-bell-slash"></i> Mute
199
+ </button>
200
+ <% end %>
201
+ </div>
202
+ <% end %>
203
+
175
204
  <% if error.resolved? && error.resolved_by_name.present? %>
176
205
  <div class="mb-3">
177
206
  <small class="metadata-label d-block mb-1">Resolved By</small>
@@ -334,6 +363,54 @@
334
363
  </code>
335
364
  </div>
336
365
  <% end %>
366
+
367
+ <% if health[:ruby_vm] %>
368
+ <% vm = health[:ruby_vm] %>
369
+ <div class="mb-1">
370
+ <small class="text-muted">VM Cache Invalidations:</small>
371
+ <% invals = vm[:constant_cache_invalidations].to_i %>
372
+ <code class="ms-1 <%= 'text-danger' if invals > 10_000 %>"><%= begin; number_with_delimiter(invals); rescue; invals; end %></code>
373
+ </div>
374
+ <div class="mb-1">
375
+ <small class="text-muted">VM Cache Misses:</small>
376
+ <code class="ms-1"><%= begin; number_with_delimiter(vm[:constant_cache_misses]); rescue; vm[:constant_cache_misses]; end %></code>
377
+ </div>
378
+ <% if vm[:shape_cache_size] %>
379
+ <div class="mb-1">
380
+ <small class="text-muted">Shape Cache Size:</small>
381
+ <code class="ms-1"><%= begin; number_with_delimiter(vm[:shape_cache_size]); rescue; vm[:shape_cache_size]; end %></code>
382
+ </div>
383
+ <% end %>
384
+ <% end %>
385
+
386
+ <% if health[:yjit] %>
387
+ <% yj = health[:yjit] %>
388
+ <% if yj[:compiled_iseq_count] || yj[:compiled_block_count] %>
389
+ <div class="mb-1">
390
+ <small class="text-muted">YJIT Compiled:</small>
391
+ <code class="ms-1"><%= yj[:compiled_iseq_count] %> iseqs / <%= yj[:compiled_block_count] %> blocks</code>
392
+ </div>
393
+ <% end %>
394
+ <% if yj[:invalidation_count] %>
395
+ <div class="mb-1">
396
+ <small class="text-muted">YJIT Invalidations:</small>
397
+ <% yj_invals = yj[:invalidation_count].to_i %>
398
+ <code class="ms-1 <%= 'text-danger' if yj_invals > 100 %>"><%= yj_invals %></code>
399
+ </div>
400
+ <% end %>
401
+ <% if yj[:code_region_size] %>
402
+ <div class="mb-1">
403
+ <small class="text-muted">YJIT Code Size:</small>
404
+ <code class="ms-1"><%= (yj[:code_region_size].to_f / 1024).round(1) %> KB</code>
405
+ </div>
406
+ <% end %>
407
+ <% if yj[:compile_time_ns] %>
408
+ <div class="mb-1">
409
+ <small class="text-muted">YJIT Compile Time:</small>
410
+ <code class="ms-1"><%= (yj[:compile_time_ns].to_f / 1_000_000).round(2) %> ms</code>
411
+ </div>
412
+ <% end %>
413
+ <% end %>
337
414
  </div>
338
415
  </div>
339
416
  <% end %>
@@ -360,6 +360,13 @@
360
360
  <%= label_tag :hide_snoozed, "Hide snoozed", class: "form-check-label" %>
361
361
  </div>
362
362
  </div>
363
+
364
+ <div class="col-auto">
365
+ <div class="form-check">
366
+ <%= check_box_tag :hide_muted, "1", params[:hide_muted] == "1", class: "form-check-input" %>
367
+ <%= label_tag :hide_muted, "Hide muted", class: "form-check-label" %>
368
+ </div>
369
+ </div>
363
370
  </div>
364
371
  </div>
365
372
 
@@ -28,10 +28,15 @@
28
28
  <% end %>
29
29
  </h2>
30
30
  </div>
31
- <div class="d-flex gap-2">
31
+ <div class="d-flex gap-2 align-items-center">
32
32
  <button type="button" class="btn btn-outline-secondary" onclick="downloadErrorJSON(event)" title="Download error details as JSON">
33
33
  <i class="bi bi-download"></i> Export JSON
34
34
  </button>
35
+ <% if @error.respond_to?(:muted?) && @error.muted? %>
36
+ <button type="button" class="btn btn-secondary" disabled>
37
+ <i class="bi bi-bell-slash"></i> Muted
38
+ </button>
39
+ <% end %>
35
40
  <% if @error.resolved? %>
36
41
  <span class="badge bg-success fs-6">
37
42
  <i class="bi bi-check-circle"></i> Resolved
data/config/routes.rb CHANGED
@@ -15,6 +15,8 @@ RailsErrorDashboard::Engine.routes.draw do
15
15
  post :update_priority
16
16
  post :snooze
17
17
  post :unsnooze
18
+ post :mute
19
+ post :unmute
18
20
  post :update_status
19
21
  post :add_comment
20
22
  end
@@ -95,6 +95,12 @@ class CreateRailsErrorDashboardCompleteSchema < ActiveRecord::Migration[7.0]
95
95
  # Instance variable capture (from 20260306000002)
96
96
  t.text :instance_variables
97
97
 
98
+ # Mute notifications (from 20260323000001)
99
+ t.boolean :muted, default: false, null: false
100
+ t.datetime :muted_at
101
+ t.string :muted_by
102
+ t.string :muted_reason
103
+
98
104
  t.timestamps
99
105
  end
100
106
 
@@ -133,6 +139,9 @@ class CreateRailsErrorDashboardCompleteSchema < ActiveRecord::Migration[7.0]
133
139
  add_index :rails_error_dashboard_error_logs, [ :application_id, :occurred_at ], name: "index_error_logs_on_app_occurred"
134
140
  add_index :rails_error_dashboard_error_logs, [ :application_id, :resolved ], name: "index_error_logs_on_app_resolved"
135
141
 
142
+ # Mute index (from 20260323000001)
143
+ add_index :rails_error_dashboard_error_logs, :muted
144
+
136
145
  # Workflow indexes (from 20251229111223)
137
146
  add_index :rails_error_dashboard_error_logs, [ :assigned_to, :status, :occurred_at ], name: "index_error_logs_on_assignment_workflow"
138
147
  add_index :rails_error_dashboard_error_logs, [ :priority_level, :resolved, :occurred_at ], name: "index_error_logs_on_priority_resolution"
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddMutedToErrorLogs < ActiveRecord::Migration[7.0]
4
+ def change
5
+ return if column_exists?(:rails_error_dashboard_error_logs, :muted)
6
+
7
+ add_column :rails_error_dashboard_error_logs, :muted, :boolean, default: false, null: false
8
+ add_column :rails_error_dashboard_error_logs, :muted_at, :datetime
9
+ add_column :rails_error_dashboard_error_logs, :muted_by, :string
10
+ add_column :rails_error_dashboard_error_logs, :muted_reason, :string
11
+
12
+ add_index :rails_error_dashboard_error_logs, :muted
13
+ end
14
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Command: Mute multiple errors at once
6
+ class BatchMuteErrors
7
+ def self.call(error_ids, muted_by: nil)
8
+ new(error_ids, muted_by).call
9
+ end
10
+
11
+ def initialize(error_ids, muted_by = nil)
12
+ @error_ids = Array(error_ids).compact
13
+ @muted_by = muted_by
14
+ end
15
+
16
+ def call
17
+ return { success: false, count: 0, errors: [ "No error IDs provided" ] } if @error_ids.empty?
18
+
19
+ errors = ErrorLog.where(id: @error_ids)
20
+
21
+ muted_count = 0
22
+ failed_ids = []
23
+ muted_errors = []
24
+
25
+ errors.each do |error|
26
+ begin
27
+ error.update!(
28
+ muted: true,
29
+ muted_at: Time.current,
30
+ muted_by: @muted_by
31
+ )
32
+ muted_count += 1
33
+ muted_errors << error
34
+ rescue => e
35
+ failed_ids << error.id
36
+ RailsErrorDashboard::Logger.error("Failed to mute error #{error.id}: #{e.message}")
37
+ end
38
+ end
39
+
40
+ PluginRegistry.dispatch(:on_errors_batch_muted, muted_errors) if muted_errors.any?
41
+
42
+ {
43
+ success: failed_ids.empty?,
44
+ count: muted_count,
45
+ total: @error_ids.size,
46
+ failed_ids: failed_ids,
47
+ errors: failed_ids.empty? ? [] : [ "Failed to mute #{failed_ids.size} error(s)" ]
48
+ }
49
+ rescue => e
50
+ RailsErrorDashboard::Logger.error("Batch mute failed: #{e.message}")
51
+ { success: false, count: 0, total: @error_ids.size, errors: [ e.message ] }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Command: Unmute multiple errors at once
6
+ class BatchUnmuteErrors
7
+ def self.call(error_ids)
8
+ new(error_ids).call
9
+ end
10
+
11
+ def initialize(error_ids)
12
+ @error_ids = Array(error_ids).compact
13
+ end
14
+
15
+ def call
16
+ return { success: false, count: 0, errors: [ "No error IDs provided" ] } if @error_ids.empty?
17
+
18
+ errors = ErrorLog.where(id: @error_ids)
19
+
20
+ unmuted_count = 0
21
+ failed_ids = []
22
+ unmuted_errors = []
23
+
24
+ errors.each do |error|
25
+ begin
26
+ error.update!(
27
+ muted: false,
28
+ muted_at: nil,
29
+ muted_by: nil,
30
+ muted_reason: nil
31
+ )
32
+ unmuted_count += 1
33
+ unmuted_errors << error
34
+ rescue => e
35
+ failed_ids << error.id
36
+ RailsErrorDashboard::Logger.error("Failed to unmute error #{error.id}: #{e.message}")
37
+ end
38
+ end
39
+
40
+ PluginRegistry.dispatch(:on_errors_batch_unmuted, unmuted_errors) if unmuted_errors.any?
41
+
42
+ {
43
+ success: failed_ids.empty?,
44
+ count: unmuted_count,
45
+ total: @error_ids.size,
46
+ failed_ids: failed_ids,
47
+ errors: failed_ids.empty? ? [] : [ "Failed to unmute #{failed_ids.size} error(s)" ]
48
+ }
49
+ rescue => e
50
+ RailsErrorDashboard::Logger.error("Batch unmute failed: #{e.message}")
51
+ { success: false, count: 0, total: @error_ids.size, errors: [ e.message ] }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -266,31 +266,20 @@ module RailsErrorDashboard
266
266
  end
267
267
  end
268
268
 
269
- # Send notifications for new errors and reopened errors (with throttling)
269
+ # Send notifications for new errors and reopened errors (with throttling).
270
+ # Muted errors skip notification dispatch but still fire plugin events.
270
271
  if error_log.occurrence_count == 1
271
- # Brand new error — notify if severity meets minimum
272
- if Services::NotificationThrottler.severity_meets_minimum?(error_log)
273
- Services::ErrorNotificationDispatcher.call(error_log)
274
- Services::NotificationThrottler.record_notification(error_log)
275
- end
272
+ maybe_notify(error_log) { Services::NotificationThrottler.severity_meets_minimum?(error_log) }
276
273
  PluginRegistry.dispatch(:on_error_logged, error_log)
277
274
  trigger_callbacks(error_log)
278
275
  emit_instrumentation_events(error_log)
279
276
  elsif error_log.just_reopened
280
- # Reopened error — notify if meets severity + not in cooldown
281
- if Services::NotificationThrottler.should_notify?(error_log)
282
- Services::ErrorNotificationDispatcher.call(error_log)
283
- Services::NotificationThrottler.record_notification(error_log)
284
- end
277
+ maybe_notify(error_log) { Services::NotificationThrottler.should_notify?(error_log) }
285
278
  PluginRegistry.dispatch(:on_error_reopened, error_log)
286
279
  trigger_callbacks(error_log)
287
280
  emit_instrumentation_events(error_log)
288
281
  else
289
- # Recurring unresolved error — check threshold milestones
290
- if Services::NotificationThrottler.threshold_reached?(error_log)
291
- Services::ErrorNotificationDispatcher.call(error_log)
292
- Services::NotificationThrottler.record_notification(error_log)
293
- end
282
+ maybe_notify(error_log) { Services::NotificationThrottler.threshold_reached?(error_log) }
294
283
  PluginRegistry.dispatch(:on_error_recurred, error_log)
295
284
  end
296
285
 
@@ -310,6 +299,16 @@ module RailsErrorDashboard
310
299
 
311
300
  private
312
301
 
302
+ # Dispatch notification if error is not muted and the throttle check passes.
303
+ # Muted errors skip notifications but still fire plugin events/callbacks.
304
+ def maybe_notify(error_log)
305
+ return if error_log.muted?
306
+ return unless yield
307
+
308
+ Services::ErrorNotificationDispatcher.call(error_log)
309
+ Services::NotificationThrottler.record_notification(error_log)
310
+ end
311
+
313
312
  # Find or create application for multi-app support
314
313
  def find_or_create_application
315
314
  app_name = RailsErrorDashboard.configuration.application_name ||
@@ -369,8 +368,9 @@ module RailsErrorDashboard
369
368
  def check_baseline_anomaly(error_log)
370
369
  config = RailsErrorDashboard.configuration
371
370
 
372
- # Return early if baseline alerts are disabled
371
+ # Return early if baseline alerts are disabled or error is muted
373
372
  return unless config.enable_baseline_alerts
373
+ return if error_log.muted?
374
374
  return unless defined?(Queries::BaselineStats)
375
375
  return unless defined?(BaselineAlertJob)
376
376
 
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Command: Mute notifications for an error
6
+ # Muted errors still appear in the dashboard but do not trigger any notifications.
7
+ class MuteError
8
+ def self.call(error_id, muted_by: nil, reason: nil)
9
+ new(error_id, muted_by, reason).call
10
+ end
11
+
12
+ def initialize(error_id, muted_by, reason)
13
+ @error_id = error_id
14
+ @muted_by = muted_by
15
+ @reason = reason
16
+ end
17
+
18
+ def call
19
+ error = ErrorLog.find(@error_id)
20
+
21
+ if @reason.present?
22
+ error.comments.create!(
23
+ author_name: @muted_by || "System",
24
+ body: "Muted notifications: #{@reason}"
25
+ )
26
+ end
27
+
28
+ error.update!(
29
+ muted: true,
30
+ muted_at: Time.current,
31
+ muted_by: @muted_by,
32
+ muted_reason: @reason
33
+ )
34
+
35
+ PluginRegistry.dispatch(:on_error_muted, error)
36
+ error
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Commands
5
+ # Command: Unmute notifications for an error
6
+ # Restores normal notification behavior for the error.
7
+ class UnmuteError
8
+ def self.call(error_id)
9
+ new(error_id).call
10
+ end
11
+
12
+ def initialize(error_id)
13
+ @error_id = error_id
14
+ end
15
+
16
+ def call
17
+ error = ErrorLog.find(@error_id)
18
+ error.update!(
19
+ muted: false,
20
+ muted_at: nil,
21
+ muted_by: nil,
22
+ muted_reason: nil
23
+ )
24
+
25
+ PluginRegistry.dispatch(:on_error_unmuted, error)
26
+ error
27
+ end
28
+ end
29
+ end
30
+ end
@@ -39,6 +39,7 @@ module RailsErrorDashboard
39
39
  query = filter_by_assignment(query)
40
40
  query = filter_by_priority(query)
41
41
  query = filter_by_snoozed(query)
42
+ query = filter_by_muted(query)
42
43
  query = filter_by_reopened(query)
43
44
  query
44
45
  end
@@ -196,6 +197,16 @@ module RailsErrorDashboard
196
197
  end
197
198
  end
198
199
 
200
+ def filter_by_muted(query)
201
+ return query unless ErrorLog.column_names.include?("muted")
202
+
203
+ if @filters[:hide_muted] == "1" || @filters[:hide_muted] == true
204
+ query.unmuted
205
+ else
206
+ query
207
+ end
208
+ end
209
+
199
210
  def filter_by_reopened(query)
200
211
  return query unless @filters[:reopened] == "true"
201
212
  return query unless ErrorLog.column_names.include?("reopened_at")
@@ -34,6 +34,8 @@ module RailsErrorDashboard
34
34
  connection_pool: connection_pool_stats,
35
35
  puma: puma_stats,
36
36
  job_queue: job_queue_stats,
37
+ ruby_vm: ruby_vm_stats,
38
+ yjit: yjit_stats,
37
39
  captured_at: Time.current.iso8601
38
40
  }
39
41
  end
@@ -140,6 +142,37 @@ module RailsErrorDashboard
140
142
  rescue => e
141
143
  nil
142
144
  end
145
+
146
+ # RubyVM.stat — constant/method cache invalidation rates
147
+ # Keys vary by Ruby version; pass through full hash for forward-compat
148
+ # Ruby 3.2+: constant_cache_invalidations, constant_cache_misses,
149
+ # global_cvar_state, next_shape_id, shape_cache_size
150
+ def ruby_vm_stats
151
+ return nil unless defined?(RubyVM) && RubyVM.respond_to?(:stat)
152
+ RubyVM.stat
153
+ rescue => e
154
+ nil
155
+ end
156
+
157
+ # RubyVM::YJIT.runtime_stats — JIT compilation health
158
+ # Cherry-picks diagnostic keys (full hash has 30+ entries)
159
+ def yjit_stats
160
+ return nil unless defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enabled?) && RubyVM::YJIT.enabled?
161
+ raw = RubyVM::YJIT.runtime_stats
162
+ {
163
+ inline_code_size: raw[:inline_code_size],
164
+ code_region_size: raw[:code_region_size],
165
+ compiled_iseq_count: raw[:compiled_iseq_count],
166
+ compiled_block_count: raw[:compiled_block_count],
167
+ compile_time_ns: raw[:compile_time_ns],
168
+ invalidation_count: raw[:invalidation_count],
169
+ invalidate_method_lookup: raw[:invalidate_method_lookup],
170
+ invalidate_constant_state_bump: raw[:invalidate_constant_state_bump],
171
+ object_shape_count: raw[:object_shape_count]
172
+ }
173
+ rescue => e
174
+ nil
175
+ end
143
176
  end
144
177
  end
145
178
  end
@@ -1,3 +1,3 @@
1
1
  module RailsErrorDashboard
2
- VERSION = "0.4.0"
2
+ VERSION = "0.4.2"
3
3
  end
@@ -78,6 +78,10 @@ require "rails_error_dashboard/commands/unassign_error"
78
78
  require "rails_error_dashboard/commands/update_error_priority"
79
79
  require "rails_error_dashboard/commands/snooze_error"
80
80
  require "rails_error_dashboard/commands/unsnooze_error"
81
+ require "rails_error_dashboard/commands/mute_error"
82
+ require "rails_error_dashboard/commands/unmute_error"
83
+ require "rails_error_dashboard/commands/batch_mute_errors"
84
+ require "rails_error_dashboard/commands/batch_unmute_errors"
81
85
  require "rails_error_dashboard/commands/update_error_status"
82
86
  require "rails_error_dashboard/commands/add_error_comment"
83
87
  require "rails_error_dashboard/commands/increment_cascade_detection"
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.4.0
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anjan Jagirdar
@@ -338,6 +338,7 @@ files:
338
338
  - db/migrate/20260306000002_add_instance_variables_to_error_logs.rb
339
339
  - db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb
340
340
  - db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb
341
+ - db/migrate/20260323000001_add_muted_to_error_logs.rb
341
342
  - lib/generators/rails_error_dashboard/install/install_generator.rb
342
343
  - lib/generators/rails_error_dashboard/install/templates/README
343
344
  - lib/generators/rails_error_dashboard/install/templates/initializer.rb
@@ -348,16 +349,20 @@ files:
348
349
  - lib/rails_error_dashboard/commands/add_error_comment.rb
349
350
  - lib/rails_error_dashboard/commands/assign_error.rb
350
351
  - lib/rails_error_dashboard/commands/batch_delete_errors.rb
352
+ - lib/rails_error_dashboard/commands/batch_mute_errors.rb
351
353
  - lib/rails_error_dashboard/commands/batch_resolve_errors.rb
354
+ - lib/rails_error_dashboard/commands/batch_unmute_errors.rb
352
355
  - lib/rails_error_dashboard/commands/calculate_cascade_probability.rb
353
356
  - lib/rails_error_dashboard/commands/find_or_create_application.rb
354
357
  - lib/rails_error_dashboard/commands/find_or_increment_error.rb
355
358
  - lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb
356
359
  - lib/rails_error_dashboard/commands/increment_cascade_detection.rb
357
360
  - lib/rails_error_dashboard/commands/log_error.rb
361
+ - lib/rails_error_dashboard/commands/mute_error.rb
358
362
  - lib/rails_error_dashboard/commands/resolve_error.rb
359
363
  - lib/rails_error_dashboard/commands/snooze_error.rb
360
364
  - lib/rails_error_dashboard/commands/unassign_error.rb
365
+ - lib/rails_error_dashboard/commands/unmute_error.rb
361
366
  - lib/rails_error_dashboard/commands/unsnooze_error.rb
362
367
  - lib/rails_error_dashboard/commands/update_error_priority.rb
363
368
  - lib/rails_error_dashboard/commands/update_error_status.rb
@@ -457,7 +462,7 @@ metadata:
457
462
  bug_tracker_uri: https://github.com/AnjanJ/rails_error_dashboard/issues
458
463
  funding_uri: https://buymeacoffee.com/anjanj
459
464
  post_install_message: "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n
460
- \ Rails Error Dashboard v0.4.0\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n\U0001F195
465
+ \ Rails Error Dashboard v0.4.2\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n\U0001F195
461
466
  First time? Quick start:\n rails generate rails_error_dashboard:install\n rails
462
467
  db:migrate\n # Add to config/routes.rb:\n mount RailsErrorDashboard::Engine
463
468
  => '/error_dashboard'\n\n\U0001F504 Upgrading from v0.1.x?\n rails db:migrate\n