rails_error_dashboard 0.4.1 → 0.5.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.
- checksums.yaml +4 -4
- data/README.md +14 -6
- data/app/controllers/rails_error_dashboard/errors_controller.rb +36 -0
- data/app/models/rails_error_dashboard/error_log.rb +16 -0
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +3 -0
- data/app/views/rails_error_dashboard/errors/_modals.html.erb +35 -0
- data/app/views/rails_error_dashboard/errors/_sidebar_metadata.html.erb +29 -0
- data/app/views/rails_error_dashboard/errors/actioncable_health_summary.html.erb +132 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +7 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +6 -1
- data/config/routes.rb +3 -0
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +9 -0
- data/db/migrate/20260323000001_add_muted_to_error_logs.rb +14 -0
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +55 -0
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +55 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +17 -17
- data/lib/rails_error_dashboard/commands/mute_error.rb +40 -0
- data/lib/rails_error_dashboard/commands/unmute_error.rb +30 -0
- data/lib/rails_error_dashboard/configuration.rb +13 -0
- data/lib/rails_error_dashboard/engine.rb +7 -0
- data/lib/rails_error_dashboard/queries/action_cable_summary.rb +96 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +11 -0
- data/lib/rails_error_dashboard/services/system_health_snapshot.rb +13 -0
- data/lib/rails_error_dashboard/subscribers/action_cable_subscriber.rb +107 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +6 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: af41ac0baefaf436b68c2db7ac1598a15f409cb76efbbf2eb8fb2daa9ceaab92
|
|
4
|
+
data.tar.gz: 22073a8675b855c06622dc34ae649608af129f57f5c391c9562c20bd95db53ec
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: badaacf9fb78a5d93ee3d545d93877cc59c8685f8800567ad6f84979cec365baebd3f3193b905bbf73b67f99c70308c2480043bfb78c38f0a1b149637f1595c4
|
|
7
|
+
data.tar.gz: 548db884ddfcb084293083c3e8b787bdc50d0fd2128b8f68bdc315f578199ce9c36ef805a038657d437864055ee45e9f5bfc41ef6386be6e5b3c750be40ed238
|
data/README.md
CHANGED
|
@@ -62,7 +62,7 @@ gem 'rails_error_dashboard'
|
|
|
62
62
|
|
|
63
63
|
### Core (Always Enabled)
|
|
64
64
|
|
|
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, 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.
|
|
66
66
|
|
|
67
67
|
### Optional Features
|
|
68
68
|
|
|
@@ -116,7 +116,7 @@ Requires breadcrumbs to be enabled.
|
|
|
116
116
|
</details>
|
|
117
117
|
|
|
118
118
|
<details>
|
|
119
|
-
<summary><strong>Operational Health Panels — Jobs, Database, Cache</strong></summary>
|
|
119
|
+
<summary><strong>Operational Health Panels — Jobs, Database, Cache, ActionCable</strong></summary>
|
|
120
120
|
|
|
121
121
|
**Job Health** — Auto-detects Sidekiq, SolidQueue, or GoodJob. Per-error table with adapter badge, failed count (color-coded), sorted worst-first.
|
|
122
122
|
|
|
@@ -130,6 +130,12 @@ Requires breadcrumbs to be enabled.
|
|
|
130
130
|
|
|
131
131
|

|
|
132
132
|
|
|
133
|
+
**ActionCable Health** — Track WebSocket channel actions, transmissions, subscription confirmations, and rejections. Dashboard page at `/errors/actioncable_health_summary` with channel breakdown sorted by rejections. System health snapshot captures live connection count and adapter. No error tracker surfaces this alongside HTTP errors.
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
config.enable_actioncable_tracking = true # requires enable_breadcrumbs = true
|
|
137
|
+
```
|
|
138
|
+
|
|
133
139
|
[Complete documentation →](docs/FEATURES.md#job-health-page)
|
|
134
140
|
</details>
|
|
135
141
|
|
|
@@ -434,22 +440,24 @@ Available as open source under the [MIT License](https://opensource.org/licenses
|
|
|
434
440
|
|
|
435
441
|
## Acknowledgments
|
|
436
442
|
|
|
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)
|
|
443
|
+
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)
|
|
438
444
|
|
|
439
445
|
## Contributors
|
|
440
446
|
|
|
441
447
|
[](https://github.com/AnjanJ/rails_error_dashboard/graphs/contributors)
|
|
442
448
|
|
|
443
|
-
Special thanks to [@bonniesimon](https://github.com/bonniesimon), [@gundestrup](https://github.com/gundestrup), [@midwire](https://github.com/midwire),
|
|
449
|
+
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.
|
|
444
450
|
|
|
445
451
|
---
|
|
446
452
|
|
|
447
453
|
## Support
|
|
448
454
|
|
|
449
|
-
If this gem saves you some headaches (or some money on error tracking SaaS), consider
|
|
455
|
+
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.
|
|
456
|
+
|
|
457
|
+
<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>
|
|
450
458
|
|
|
451
459
|
---
|
|
452
460
|
|
|
453
|
-
**Made with
|
|
461
|
+
**Made with ❤️ by [Anjan](https://anjan.dev)**
|
|
454
462
|
|
|
455
463
|
*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
|
|
@@ -394,6 +409,27 @@ module RailsErrorDashboard
|
|
|
394
409
|
@pagy, @events = pagy(:offset, all_events, limit: params[:per_page] || 25)
|
|
395
410
|
end
|
|
396
411
|
|
|
412
|
+
def actioncable_health_summary
|
|
413
|
+
unless RailsErrorDashboard.configuration.enable_actioncable_tracking &&
|
|
414
|
+
RailsErrorDashboard.configuration.enable_breadcrumbs
|
|
415
|
+
flash[:alert] = "ActionCable tracking is not enabled. Enable enable_actioncable_tracking and enable_breadcrumbs in config/initializers/rails_error_dashboard.rb"
|
|
416
|
+
redirect_to errors_path
|
|
417
|
+
return
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
days = (params[:days] || 30).to_i
|
|
421
|
+
@days = days
|
|
422
|
+
result = Queries::ActionCableSummary.call(days, application_id: @current_application_id)
|
|
423
|
+
all_channels = result[:channels]
|
|
424
|
+
|
|
425
|
+
# Summary stats (computed before pagination)
|
|
426
|
+
@unique_channels = all_channels.size
|
|
427
|
+
@total_events = all_channels.sum { |c| c[:total_events] }
|
|
428
|
+
@total_rejections = all_channels.sum { |c| c[:rejection_count] }
|
|
429
|
+
|
|
430
|
+
@pagy, @channels = pagy(:offset, all_channels, limit: params[:per_page] || 25)
|
|
431
|
+
end
|
|
432
|
+
|
|
397
433
|
def diagnostic_dumps
|
|
398
434
|
unless RailsErrorDashboard.configuration.enable_diagnostic_dump
|
|
399
435
|
flash[:alert] = "Diagnostic dumps are not enabled. Enable them in config/initializers/rails_error_dashboard.rb"
|
|
@@ -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>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<% content_for :page_title, "ActionCable Health" %>
|
|
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-broadcast me-2"></i>
|
|
7
|
+
ActionCable Health
|
|
8
|
+
</h1>
|
|
9
|
+
|
|
10
|
+
<div class="btn-group" role="group">
|
|
11
|
+
<%= link_to actioncable_health_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 actioncable_health_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 actioncable_health_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_channels == 0 %>
|
|
24
|
+
<div class="text-center py-5">
|
|
25
|
+
<i class="bi bi-broadcast display-1 text-success mb-3"></i>
|
|
26
|
+
<h4 class="text-muted">No ActionCable Events Found</h4>
|
|
27
|
+
<p class="text-muted">
|
|
28
|
+
No ActionCable channel actions, transmissions, or subscription 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 ActionCable tracking works:</h6>
|
|
33
|
+
<ul class="mb-0">
|
|
34
|
+
<li>Breadcrumbs must be enabled (<code>enable_breadcrumbs = true</code>)</li>
|
|
35
|
+
<li>ActionCable tracking must be enabled (<code>enable_actioncable_tracking = true</code>)</li>
|
|
36
|
+
<li>ActionCable must be configured in your app</li>
|
|
37
|
+
<li>Channel actions, transmissions, and subscription 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-primary"><%= @unique_channels %></div>
|
|
48
|
+
<small class="text-muted">Active Channels</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-info"><%= @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 <%= @total_rejections > 0 ? 'text-danger' : 'text-success' %>"><%= @total_rejections %></div>
|
|
64
|
+
<small class="text-muted">Subscription Rejections</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-broadcast text-primary me-2"></i>
|
|
74
|
+
ActionCable Events by Channel
|
|
75
|
+
<span class="badge bg-primary"><%= @unique_channels %></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>Channel</th>
|
|
85
|
+
<th width="100">Actions</th>
|
|
86
|
+
<th width="120">Transmissions</th>
|
|
87
|
+
<th width="120">Subscriptions</th>
|
|
88
|
+
<th width="100">Rejections</th>
|
|
89
|
+
<th width="80">Errors</th>
|
|
90
|
+
<th width="140">Last Seen</th>
|
|
91
|
+
</tr>
|
|
92
|
+
</thead>
|
|
93
|
+
<tbody>
|
|
94
|
+
<% @channels.each do |channel| %>
|
|
95
|
+
<tr>
|
|
96
|
+
<td><code><%= channel[:channel] %></code></td>
|
|
97
|
+
<td><%= channel[:perform_count] %></td>
|
|
98
|
+
<td><%= channel[:transmit_count] %></td>
|
|
99
|
+
<td><span class="text-success"><%= channel[:subscription_count] %></span></td>
|
|
100
|
+
<td>
|
|
101
|
+
<% if channel[:rejection_count] > 0 %>
|
|
102
|
+
<span class="badge bg-danger"><%= channel[:rejection_count] %></span>
|
|
103
|
+
<% else %>
|
|
104
|
+
<span class="text-muted">0</span>
|
|
105
|
+
<% end %>
|
|
106
|
+
</td>
|
|
107
|
+
<td><%= channel[:error_count] %></td>
|
|
108
|
+
<td><%= local_time_ago(channel[:last_seen]) %></td>
|
|
109
|
+
</tr>
|
|
110
|
+
<% end %>
|
|
111
|
+
</tbody>
|
|
112
|
+
</table>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
<div class="card-footer bg-white border-top d-flex justify-content-between align-items-center">
|
|
116
|
+
<div>
|
|
117
|
+
<small class="text-muted">
|
|
118
|
+
<i class="bi bi-lightbulb text-warning"></i> ActionCable events are captured when they coincide with errors. High rejection counts may indicate authentication or authorization issues.
|
|
119
|
+
</small>
|
|
120
|
+
<small class="ms-3">
|
|
121
|
+
<a href="https://guides.rubyonrails.org/action_cable_overview.html" target="_blank" rel="noopener" class="text-decoration-none">
|
|
122
|
+
<i class="bi bi-book"></i> ActionCable Guide <i class="bi bi-box-arrow-up-right" style="font-size: 0.7em;"></i>
|
|
123
|
+
</a>
|
|
124
|
+
</small>
|
|
125
|
+
</div>
|
|
126
|
+
<div>
|
|
127
|
+
<%== @pagy.series_nav(:bootstrap) if @pagy.pages > 1 %>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
<% end %>
|
|
132
|
+
</div>
|
|
@@ -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
|
|
@@ -29,6 +31,7 @@ RailsErrorDashboard::Engine.routes.draw do
|
|
|
29
31
|
get :database_health_summary
|
|
30
32
|
get :swallowed_exceptions
|
|
31
33
|
get :rack_attack_summary
|
|
34
|
+
get :actioncable_health_summary
|
|
32
35
|
get :diagnostic_dumps
|
|
33
36
|
post :create_diagnostic_dump
|
|
34
37
|
post :batch_action
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -158,6 +158,9 @@ module RailsErrorDashboard
|
|
|
158
158
|
# Rack Attack event tracking (requires enable_breadcrumbs = true)
|
|
159
159
|
attr_accessor :enable_rack_attack_tracking # Master switch (default: false)
|
|
160
160
|
|
|
161
|
+
# ActionCable event tracking (requires enable_breadcrumbs = true)
|
|
162
|
+
attr_accessor :enable_actioncable_tracking # Master switch (default: false)
|
|
163
|
+
|
|
161
164
|
# Notification callbacks (managed via helper methods, not set directly)
|
|
162
165
|
attr_reader :notification_callbacks
|
|
163
166
|
|
|
@@ -300,6 +303,9 @@ module RailsErrorDashboard
|
|
|
300
303
|
# Rack Attack event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
301
304
|
@enable_rack_attack_tracking = false
|
|
302
305
|
|
|
306
|
+
# ActionCable event tracking defaults - OFF by default (opt-in, requires breadcrumbs)
|
|
307
|
+
@enable_actioncable_tracking = false
|
|
308
|
+
|
|
303
309
|
# Internal logging defaults - SILENT by default
|
|
304
310
|
@enable_internal_logging = false # Opt-in for debugging
|
|
305
311
|
@log_level = :silent # Silent by default, use :debug, :info, :warn, :error, or :silent
|
|
@@ -440,6 +446,13 @@ module RailsErrorDashboard
|
|
|
440
446
|
@enable_rack_attack_tracking = false
|
|
441
447
|
end
|
|
442
448
|
|
|
449
|
+
# Validate actioncable tracking requires breadcrumbs
|
|
450
|
+
if enable_actioncable_tracking && !enable_breadcrumbs
|
|
451
|
+
warnings << "enable_actioncable_tracking requires enable_breadcrumbs = true. " \
|
|
452
|
+
"ActionCable tracking has been auto-disabled."
|
|
453
|
+
@enable_actioncable_tracking = false
|
|
454
|
+
end
|
|
455
|
+
|
|
443
456
|
# Validate crash capture path (must exist if custom path specified)
|
|
444
457
|
if enable_crash_capture && crash_capture_path
|
|
445
458
|
unless Dir.exist?(crash_capture_path)
|
|
@@ -77,6 +77,13 @@ module RailsErrorDashboard
|
|
|
77
77
|
RailsErrorDashboard::Subscribers::RackAttackSubscriber.subscribe!
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# Subscribe to ActionCable AS::Notifications events (requires breadcrumbs + ActionCable)
|
|
81
|
+
if RailsErrorDashboard.configuration.enable_actioncable_tracking &&
|
|
82
|
+
RailsErrorDashboard.configuration.enable_breadcrumbs &&
|
|
83
|
+
defined?(ActionCable)
|
|
84
|
+
RailsErrorDashboard::Subscribers::ActionCableSubscriber.subscribe!
|
|
85
|
+
end
|
|
86
|
+
|
|
80
87
|
# Enable TracePoint(:raise) for local variable and/or instance variable capture
|
|
81
88
|
if RailsErrorDashboard.configuration.enable_local_variables ||
|
|
82
89
|
RailsErrorDashboard.configuration.enable_instance_variables
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Aggregate ActionCable events from breadcrumbs across all errors
|
|
6
|
+
# Scans error_logs breadcrumbs JSON, filters for "action_cable" category crumbs,
|
|
7
|
+
# and groups by channel name with counts by event type.
|
|
8
|
+
class ActionCableSummary
|
|
9
|
+
def self.call(days = 30, application_id: nil)
|
|
10
|
+
new(days, application_id: application_id).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(days = 30, application_id: nil)
|
|
14
|
+
@days = days
|
|
15
|
+
@application_id = application_id
|
|
16
|
+
@start_date = days.days.ago
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
{
|
|
21
|
+
channels: aggregated_channels
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def base_query
|
|
28
|
+
scope = ErrorLog.where("occurred_at >= ?", @start_date)
|
|
29
|
+
.where.not(breadcrumbs: nil)
|
|
30
|
+
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
31
|
+
scope
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def aggregated_channels
|
|
35
|
+
results = {}
|
|
36
|
+
|
|
37
|
+
base_query.select(:id, :breadcrumbs, :occurred_at).find_each(batch_size: 500) do |error_log|
|
|
38
|
+
crumbs = parse_breadcrumbs(error_log.breadcrumbs)
|
|
39
|
+
next if crumbs.empty?
|
|
40
|
+
|
|
41
|
+
ac_crumbs = crumbs.select { |c| c["c"] == "action_cable" }
|
|
42
|
+
next if ac_crumbs.empty?
|
|
43
|
+
|
|
44
|
+
ac_crumbs.each do |crumb|
|
|
45
|
+
meta = crumb["meta"] || {}
|
|
46
|
+
channel = meta["channel"].to_s.presence || "Unknown"
|
|
47
|
+
event_type = meta["event_type"].to_s
|
|
48
|
+
|
|
49
|
+
results[channel] ||= {
|
|
50
|
+
channel: channel,
|
|
51
|
+
perform_count: 0,
|
|
52
|
+
transmit_count: 0,
|
|
53
|
+
subscription_count: 0,
|
|
54
|
+
rejection_count: 0,
|
|
55
|
+
error_ids: [],
|
|
56
|
+
last_seen: nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
entry = results[channel]
|
|
60
|
+
|
|
61
|
+
case event_type
|
|
62
|
+
when "perform_action"
|
|
63
|
+
entry[:perform_count] += 1
|
|
64
|
+
when "transmit"
|
|
65
|
+
entry[:transmit_count] += 1
|
|
66
|
+
when "transmit_subscription_confirmation"
|
|
67
|
+
entry[:subscription_count] += 1
|
|
68
|
+
when "transmit_subscription_rejection"
|
|
69
|
+
entry[:rejection_count] += 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
entry[:error_ids] << error_log.id
|
|
73
|
+
entry[:last_seen] = [ entry[:last_seen], error_log.occurred_at ].compact.max
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
results.values.each do |r|
|
|
78
|
+
r[:error_ids] = r[:error_ids].uniq
|
|
79
|
+
r[:error_count] = r[:error_ids].size
|
|
80
|
+
r[:total_events] = r[:perform_count] + r[:transmit_count] + r[:subscription_count] + r[:rejection_count]
|
|
81
|
+
end
|
|
82
|
+
results.values.sort_by { |r| [ -r[:rejection_count], -r[:total_events] ] }
|
|
83
|
+
rescue => e
|
|
84
|
+
Rails.logger.error("[RailsErrorDashboard] ActionCableSummary query failed: #{e.class}: #{e.message}")
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_breadcrumbs(raw)
|
|
89
|
+
return [] if raw.blank?
|
|
90
|
+
JSON.parse(raw)
|
|
91
|
+
rescue JSON::ParserError
|
|
92
|
+
[]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
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")
|
|
@@ -36,6 +36,7 @@ module RailsErrorDashboard
|
|
|
36
36
|
job_queue: job_queue_stats,
|
|
37
37
|
ruby_vm: ruby_vm_stats,
|
|
38
38
|
yjit: yjit_stats,
|
|
39
|
+
actioncable: actioncable_stats,
|
|
39
40
|
captured_at: Time.current.iso8601
|
|
40
41
|
}
|
|
41
42
|
end
|
|
@@ -154,6 +155,18 @@ module RailsErrorDashboard
|
|
|
154
155
|
nil
|
|
155
156
|
end
|
|
156
157
|
|
|
158
|
+
# ActionCable connection stats — read-only, <0.1ms
|
|
159
|
+
def actioncable_stats
|
|
160
|
+
return nil unless defined?(ActionCable) && defined?(ActionCable::Server)
|
|
161
|
+
server = ActionCable.server
|
|
162
|
+
{
|
|
163
|
+
connections: server.connections.count,
|
|
164
|
+
adapter: server.pubsub&.class&.name&.demodulize
|
|
165
|
+
}
|
|
166
|
+
rescue => e
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
157
170
|
# RubyVM::YJIT.runtime_stats — JIT compilation health
|
|
158
171
|
# Cherry-picks diagnostic keys (full hash has 30+ entries)
|
|
159
172
|
def yjit_stats
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Registers ActiveSupport::Notifications subscribers for ActionCable events.
|
|
6
|
+
#
|
|
7
|
+
# ActionCable emits:
|
|
8
|
+
# - perform_action.action_cable — channel action executed
|
|
9
|
+
# - transmit.action_cable — data transmitted to subscriber
|
|
10
|
+
# - transmit_subscription_confirmation.action_cable — subscription confirmed
|
|
11
|
+
# - transmit_subscription_rejection.action_cable — subscription rejected
|
|
12
|
+
#
|
|
13
|
+
# Each event is captured as a breadcrumb with category "action_cable",
|
|
14
|
+
# allowing correlation between WebSocket events and error spikes.
|
|
15
|
+
#
|
|
16
|
+
# SAFETY RULES (HOST_APP_SAFETY.md):
|
|
17
|
+
# - Every subscriber wrapped in rescue => e; nil
|
|
18
|
+
# - Never raise from subscriber callbacks
|
|
19
|
+
# - Skip if buffer is nil (not in a request context)
|
|
20
|
+
class ActionCableSubscriber
|
|
21
|
+
EVENTS = %w[
|
|
22
|
+
perform_action.action_cable
|
|
23
|
+
transmit.action_cable
|
|
24
|
+
transmit_subscription_confirmation.action_cable
|
|
25
|
+
transmit_subscription_rejection.action_cable
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# Event subscriptions managed by this class
|
|
29
|
+
@subscriptions = []
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
attr_reader :subscriptions
|
|
33
|
+
|
|
34
|
+
# Register all ActionCable event subscribers
|
|
35
|
+
# @return [Array] Array of subscription objects
|
|
36
|
+
def subscribe!
|
|
37
|
+
@subscriptions = []
|
|
38
|
+
|
|
39
|
+
EVENTS.each do |event_name|
|
|
40
|
+
@subscriptions << subscribe_event(event_name)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@subscriptions
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Remove all ActionCable subscribers
|
|
47
|
+
def unsubscribe!
|
|
48
|
+
@subscriptions.each do |sub|
|
|
49
|
+
ActiveSupport::Notifications.unsubscribe(sub) if sub
|
|
50
|
+
rescue => e
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
@subscriptions = []
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def subscribe_event(event_name)
|
|
59
|
+
ActiveSupport::Notifications.subscribe(event_name) do |*args|
|
|
60
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
61
|
+
handle_action_cable(event, event_name)
|
|
62
|
+
rescue => e
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def handle_action_cable(event, event_name)
|
|
68
|
+
return unless Services::BreadcrumbCollector.current_buffer
|
|
69
|
+
|
|
70
|
+
payload = event.payload || {}
|
|
71
|
+
channel = payload[:channel_class] || payload[:channel] || "Unknown"
|
|
72
|
+
channel = channel.to_s
|
|
73
|
+
|
|
74
|
+
event_type = event_name.split(".").first # "perform_action", "transmit", etc.
|
|
75
|
+
action = payload[:action].to_s
|
|
76
|
+
|
|
77
|
+
message = build_message(event_type, channel, action)
|
|
78
|
+
|
|
79
|
+
metadata = {
|
|
80
|
+
channel: channel,
|
|
81
|
+
event_type: event_type
|
|
82
|
+
}
|
|
83
|
+
metadata[:action] = action if action.present?
|
|
84
|
+
|
|
85
|
+
duration_ms = event.duration if event.respond_to?(:duration)
|
|
86
|
+
|
|
87
|
+
Services::BreadcrumbCollector.add("action_cable", message, duration_ms: duration_ms, metadata: metadata)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def build_message(event_type, channel, action)
|
|
91
|
+
case event_type
|
|
92
|
+
when "perform_action"
|
|
93
|
+
action.present? ? "perform: #{channel}##{action}" : "perform: #{channel}"
|
|
94
|
+
when "transmit"
|
|
95
|
+
"transmit: #{channel}"
|
|
96
|
+
when "transmit_subscription_confirmation"
|
|
97
|
+
"subscribed: #{channel}"
|
|
98
|
+
when "transmit_subscription_rejection"
|
|
99
|
+
"rejected: #{channel}"
|
|
100
|
+
else
|
|
101
|
+
"#{event_type}: #{channel}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -64,7 +64,9 @@ require "rails_error_dashboard/services/crash_capture"
|
|
|
64
64
|
require "rails_error_dashboard/services/diagnostic_dump_generator"
|
|
65
65
|
require "rails_error_dashboard/subscribers/breadcrumb_subscriber"
|
|
66
66
|
require "rails_error_dashboard/subscribers/rack_attack_subscriber"
|
|
67
|
+
require "rails_error_dashboard/subscribers/action_cable_subscriber"
|
|
67
68
|
require "rails_error_dashboard/queries/co_occurring_errors"
|
|
69
|
+
require "rails_error_dashboard/queries/action_cable_summary"
|
|
68
70
|
require "rails_error_dashboard/queries/error_cascades"
|
|
69
71
|
require "rails_error_dashboard/queries/baseline_stats"
|
|
70
72
|
require "rails_error_dashboard/queries/platform_comparison"
|
|
@@ -78,6 +80,10 @@ require "rails_error_dashboard/commands/unassign_error"
|
|
|
78
80
|
require "rails_error_dashboard/commands/update_error_priority"
|
|
79
81
|
require "rails_error_dashboard/commands/snooze_error"
|
|
80
82
|
require "rails_error_dashboard/commands/unsnooze_error"
|
|
83
|
+
require "rails_error_dashboard/commands/mute_error"
|
|
84
|
+
require "rails_error_dashboard/commands/unmute_error"
|
|
85
|
+
require "rails_error_dashboard/commands/batch_mute_errors"
|
|
86
|
+
require "rails_error_dashboard/commands/batch_unmute_errors"
|
|
81
87
|
require "rails_error_dashboard/commands/update_error_status"
|
|
82
88
|
require "rails_error_dashboard/commands/add_error_comment"
|
|
83
89
|
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
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anjan Jagirdar
|
|
@@ -290,6 +290,7 @@ files:
|
|
|
290
290
|
- app/views/rails_error_dashboard/errors/_stats.html.erb
|
|
291
291
|
- app/views/rails_error_dashboard/errors/_timeline.html.erb
|
|
292
292
|
- app/views/rails_error_dashboard/errors/_user_errors_table.html.erb
|
|
293
|
+
- app/views/rails_error_dashboard/errors/actioncable_health_summary.html.erb
|
|
293
294
|
- app/views/rails_error_dashboard/errors/analytics.html.erb
|
|
294
295
|
- app/views/rails_error_dashboard/errors/cache_health_summary.html.erb
|
|
295
296
|
- app/views/rails_error_dashboard/errors/correlation.html.erb
|
|
@@ -338,6 +339,7 @@ files:
|
|
|
338
339
|
- db/migrate/20260306000002_add_instance_variables_to_error_logs.rb
|
|
339
340
|
- db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb
|
|
340
341
|
- db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb
|
|
342
|
+
- db/migrate/20260323000001_add_muted_to_error_logs.rb
|
|
341
343
|
- lib/generators/rails_error_dashboard/install/install_generator.rb
|
|
342
344
|
- lib/generators/rails_error_dashboard/install/templates/README
|
|
343
345
|
- lib/generators/rails_error_dashboard/install/templates/initializer.rb
|
|
@@ -348,16 +350,20 @@ files:
|
|
|
348
350
|
- lib/rails_error_dashboard/commands/add_error_comment.rb
|
|
349
351
|
- lib/rails_error_dashboard/commands/assign_error.rb
|
|
350
352
|
- lib/rails_error_dashboard/commands/batch_delete_errors.rb
|
|
353
|
+
- lib/rails_error_dashboard/commands/batch_mute_errors.rb
|
|
351
354
|
- lib/rails_error_dashboard/commands/batch_resolve_errors.rb
|
|
355
|
+
- lib/rails_error_dashboard/commands/batch_unmute_errors.rb
|
|
352
356
|
- lib/rails_error_dashboard/commands/calculate_cascade_probability.rb
|
|
353
357
|
- lib/rails_error_dashboard/commands/find_or_create_application.rb
|
|
354
358
|
- lib/rails_error_dashboard/commands/find_or_increment_error.rb
|
|
355
359
|
- lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb
|
|
356
360
|
- lib/rails_error_dashboard/commands/increment_cascade_detection.rb
|
|
357
361
|
- lib/rails_error_dashboard/commands/log_error.rb
|
|
362
|
+
- lib/rails_error_dashboard/commands/mute_error.rb
|
|
358
363
|
- lib/rails_error_dashboard/commands/resolve_error.rb
|
|
359
364
|
- lib/rails_error_dashboard/commands/snooze_error.rb
|
|
360
365
|
- lib/rails_error_dashboard/commands/unassign_error.rb
|
|
366
|
+
- lib/rails_error_dashboard/commands/unmute_error.rb
|
|
361
367
|
- lib/rails_error_dashboard/commands/unsnooze_error.rb
|
|
362
368
|
- lib/rails_error_dashboard/commands/update_error_priority.rb
|
|
363
369
|
- lib/rails_error_dashboard/commands/update_error_status.rb
|
|
@@ -377,6 +383,7 @@ files:
|
|
|
377
383
|
- lib/rails_error_dashboard/plugins/audit_log_plugin.rb
|
|
378
384
|
- lib/rails_error_dashboard/plugins/jira_integration_plugin.rb
|
|
379
385
|
- lib/rails_error_dashboard/plugins/metrics_plugin.rb
|
|
386
|
+
- lib/rails_error_dashboard/queries/action_cable_summary.rb
|
|
380
387
|
- lib/rails_error_dashboard/queries/analytics_stats.rb
|
|
381
388
|
- lib/rails_error_dashboard/queries/baseline_stats.rb
|
|
382
389
|
- lib/rails_error_dashboard/queries/cache_health_summary.rb
|
|
@@ -440,6 +447,7 @@ files:
|
|
|
440
447
|
- lib/rails_error_dashboard/services/system_health_snapshot.rb
|
|
441
448
|
- lib/rails_error_dashboard/services/variable_serializer.rb
|
|
442
449
|
- lib/rails_error_dashboard/services/webhook_payload_builder.rb
|
|
450
|
+
- lib/rails_error_dashboard/subscribers/action_cable_subscriber.rb
|
|
443
451
|
- lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb
|
|
444
452
|
- lib/rails_error_dashboard/subscribers/rack_attack_subscriber.rb
|
|
445
453
|
- lib/rails_error_dashboard/value_objects/error_context.rb
|
|
@@ -457,7 +465,7 @@ metadata:
|
|
|
457
465
|
bug_tracker_uri: https://github.com/AnjanJ/rails_error_dashboard/issues
|
|
458
466
|
funding_uri: https://buymeacoffee.com/anjanj
|
|
459
467
|
post_install_message: "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n
|
|
460
|
-
\ Rails Error Dashboard v0.
|
|
468
|
+
\ Rails Error Dashboard v0.5.0\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n\U0001F195
|
|
461
469
|
First time? Quick start:\n rails generate rails_error_dashboard:install\n rails
|
|
462
470
|
db:migrate\n # Add to config/routes.rb:\n mount RailsErrorDashboard::Engine
|
|
463
471
|
=> '/error_dashboard'\n\n\U0001F504 Upgrading from v0.1.x?\n rails db:migrate\n
|