rails_error_dashboard 0.1.3 → 0.1.4
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 +26 -0
- data/app/assets/stylesheets/rails_error_dashboard/application.css +15 -926
- data/app/controllers/rails_error_dashboard/errors_controller.rb +42 -1
- data/app/helpers/rails_error_dashboard/application_helper.rb +38 -0
- data/app/models/rails_error_dashboard/error_log.rb +14 -0
- data/app/views/layouts/rails_error_dashboard/application.html.erb +39 -1
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +301 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +44 -7
- data/config/routes.rb +4 -1
- data/lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb +317 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +77 -5
- data/lib/rails_error_dashboard/queries/mttr_stats.rb +111 -0
- data/lib/rails_error_dashboard/queries/recurring_issues.rb +97 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +3 -0
- data/lib/tasks/rails_error_dashboard_tasks.rake +85 -0
- metadata +20 -2
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Generators
|
|
5
|
+
class UninstallGenerator < Rails::Generators::Base
|
|
6
|
+
desc "Uninstalls Rails Error Dashboard and removes all associated files and data"
|
|
7
|
+
|
|
8
|
+
class_option :keep_data, type: :boolean, default: false, desc: "Keep error data in database (don't drop tables)"
|
|
9
|
+
class_option :skip_confirmation, type: :boolean, default: false, desc: "Skip confirmation prompts"
|
|
10
|
+
class_option :manual_only, type: :boolean, default: false, desc: "Show manual instructions only, don't perform automated uninstall"
|
|
11
|
+
|
|
12
|
+
def welcome_message
|
|
13
|
+
say "\n"
|
|
14
|
+
say "=" * 80
|
|
15
|
+
say " 🗑️ Rails Error Dashboard - Uninstall", :red
|
|
16
|
+
say "=" * 80
|
|
17
|
+
say "\n"
|
|
18
|
+
say "This will remove Rails Error Dashboard from your application.", :yellow
|
|
19
|
+
say "\n"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def detect_installed_components
|
|
23
|
+
@components = {
|
|
24
|
+
initializer: File.exist?("config/initializers/rails_error_dashboard.rb"),
|
|
25
|
+
route: route_mounted?,
|
|
26
|
+
migrations: migrations_exist?,
|
|
27
|
+
tables: tables_exist?,
|
|
28
|
+
gemfile: gemfile_includes_gem?
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
say "Detected components:", :cyan
|
|
32
|
+
say " #{status_icon(@components[:gemfile])} Gemfile entry"
|
|
33
|
+
say " #{status_icon(@components[:initializer])} Initializer (config/initializers/rails_error_dashboard.rb)"
|
|
34
|
+
say " #{status_icon(@components[:route])} Route (mount RailsErrorDashboard::Engine)"
|
|
35
|
+
say " #{status_icon(@components[:migrations])} Migrations (#{migration_count} files)"
|
|
36
|
+
say " #{status_icon(@components[:tables])} Database tables (#{table_count} tables)"
|
|
37
|
+
say "\n"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def show_manual_instructions
|
|
41
|
+
say "=" * 80
|
|
42
|
+
say " 📖 Manual Uninstall Instructions", :cyan
|
|
43
|
+
say "=" * 80
|
|
44
|
+
say "\n"
|
|
45
|
+
|
|
46
|
+
say "Step 1: Remove from Gemfile", :yellow
|
|
47
|
+
say " Open: Gemfile"
|
|
48
|
+
say " Remove: gem 'rails_error_dashboard'"
|
|
49
|
+
say " Run: bundle install"
|
|
50
|
+
say "\n"
|
|
51
|
+
|
|
52
|
+
if @components[:initializer]
|
|
53
|
+
say "Step 2: Remove initializer", :yellow
|
|
54
|
+
say " Delete: config/initializers/rails_error_dashboard.rb"
|
|
55
|
+
say "\n"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if @components[:route]
|
|
59
|
+
say "Step 3: Remove route", :yellow
|
|
60
|
+
say " Open: config/routes.rb"
|
|
61
|
+
say " Remove: mount RailsErrorDashboard::Engine => '/error_dashboard'"
|
|
62
|
+
say "\n"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
if @components[:migrations]
|
|
66
|
+
say "Step 4: Remove migrations", :yellow
|
|
67
|
+
say " Delete migration files from db/migrate/:"
|
|
68
|
+
migration_files.each do |file|
|
|
69
|
+
say " - #{File.basename(file)}", :light_black
|
|
70
|
+
end
|
|
71
|
+
say "\n"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
if @components[:tables]
|
|
75
|
+
say "Step 5: Drop database tables (⚠️ DESTRUCTIVE - will delete all error data)", :yellow
|
|
76
|
+
say " Run: rails rails_error_dashboard:db:drop"
|
|
77
|
+
say " Or manually in rails console:"
|
|
78
|
+
say " ActiveRecord::Base.connection.execute('DROP TABLE rails_error_dashboard_error_logs')", :light_black
|
|
79
|
+
say " ActiveRecord::Base.connection.execute('DROP TABLE rails_error_dashboard_error_occurrences')", :light_black
|
|
80
|
+
say " ActiveRecord::Base.connection.execute('DROP TABLE rails_error_dashboard_cascade_patterns')", :light_black
|
|
81
|
+
say " ActiveRecord::Base.connection.execute('DROP TABLE rails_error_dashboard_error_baselines')", :light_black
|
|
82
|
+
say " ActiveRecord::Base.connection.execute('DROP TABLE rails_error_dashboard_error_comments')", :light_black
|
|
83
|
+
say " ActiveRecord::Migration.drop_table(:rails_error_dashboard_error_logs) rescue nil", :light_black
|
|
84
|
+
say "\n"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
say "Step 6: Clean up environment variables (optional)", :yellow
|
|
88
|
+
say " Remove from .env or environment:"
|
|
89
|
+
say " - ERROR_DASHBOARD_USER"
|
|
90
|
+
say " - ERROR_DASHBOARD_PASSWORD"
|
|
91
|
+
say " - SLACK_WEBHOOK_URL"
|
|
92
|
+
say " - ERROR_NOTIFICATION_EMAILS"
|
|
93
|
+
say " - DISCORD_WEBHOOK_URL"
|
|
94
|
+
say " - PAGERDUTY_INTEGRATION_KEY"
|
|
95
|
+
say " - WEBHOOK_URLS"
|
|
96
|
+
say " - DASHBOARD_BASE_URL"
|
|
97
|
+
say "\n"
|
|
98
|
+
|
|
99
|
+
say "Step 7: Restart your application", :yellow
|
|
100
|
+
say " Run: rails restart (or restart your server)"
|
|
101
|
+
say "\n"
|
|
102
|
+
|
|
103
|
+
say "=" * 80
|
|
104
|
+
say "\n"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def confirm_automated_uninstall
|
|
108
|
+
return if options[:manual_only]
|
|
109
|
+
return if options[:skip_confirmation]
|
|
110
|
+
|
|
111
|
+
say "Would you like to run the automated uninstall? (recommended)", :cyan
|
|
112
|
+
say "This will:", :yellow
|
|
113
|
+
say " ✓ Remove initializer file"
|
|
114
|
+
say " ✓ Remove route from config/routes.rb"
|
|
115
|
+
say " ✓ Remove migration files"
|
|
116
|
+
if options[:keep_data]
|
|
117
|
+
say " ✗ Keep database tables and data (--keep-data flag set)", :green
|
|
118
|
+
else
|
|
119
|
+
say " ⚠️ Drop all database tables (deletes all error data!)", :red
|
|
120
|
+
end
|
|
121
|
+
say "\n"
|
|
122
|
+
|
|
123
|
+
response = ask("Proceed with automated uninstall? (yes/no):", :yellow, limited_to: [ "yes", "no", "y", "n" ])
|
|
124
|
+
|
|
125
|
+
if response.downcase == "no" || response.downcase == "n"
|
|
126
|
+
say "\n"
|
|
127
|
+
say "Automated uninstall cancelled.", :yellow
|
|
128
|
+
say "Follow the manual instructions above to uninstall.", :cyan
|
|
129
|
+
say "\n"
|
|
130
|
+
exit 0
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
say "\n"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def final_data_warning
|
|
137
|
+
return if options[:manual_only]
|
|
138
|
+
return if options[:keep_data]
|
|
139
|
+
return unless @components[:tables]
|
|
140
|
+
|
|
141
|
+
say "=" * 80
|
|
142
|
+
say " ⚠️ FINAL WARNING - Data Deletion", :red
|
|
143
|
+
say "=" * 80
|
|
144
|
+
say "\n"
|
|
145
|
+
say "You are about to PERMANENTLY DELETE all error tracking data!", :red
|
|
146
|
+
say "\n"
|
|
147
|
+
say "Database tables to be dropped:", :yellow
|
|
148
|
+
table_names.each do |table|
|
|
149
|
+
say " • #{table}", :light_black
|
|
150
|
+
end
|
|
151
|
+
say "\n"
|
|
152
|
+
say "This action CANNOT be undone!", :red
|
|
153
|
+
say "\n"
|
|
154
|
+
|
|
155
|
+
response = ask("Type 'DELETE ALL DATA' to confirm:", :red)
|
|
156
|
+
|
|
157
|
+
if response != "DELETE ALL DATA"
|
|
158
|
+
say "\n"
|
|
159
|
+
say "Data deletion cancelled. Database tables will be kept.", :green
|
|
160
|
+
say "Use --keep-data flag to skip this warning in the future.", :cyan
|
|
161
|
+
@components[:tables] = false # Don't drop tables
|
|
162
|
+
say "\n"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
say "\n"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def remove_initializer
|
|
169
|
+
return if options[:manual_only]
|
|
170
|
+
return unless @components[:initializer]
|
|
171
|
+
|
|
172
|
+
remove_file "config/initializers/rails_error_dashboard.rb"
|
|
173
|
+
say " ✓ Removed initializer", :green
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def remove_route
|
|
177
|
+
return if options[:manual_only]
|
|
178
|
+
return unless @components[:route]
|
|
179
|
+
|
|
180
|
+
begin
|
|
181
|
+
gsub_file "config/routes.rb", /mount RailsErrorDashboard::Engine.*\n/, ""
|
|
182
|
+
say " ✓ Removed route", :green
|
|
183
|
+
rescue => e
|
|
184
|
+
say " ⚠️ Could not automatically remove route: #{e.message}", :yellow
|
|
185
|
+
say " Please manually remove: mount RailsErrorDashboard::Engine => '/error_dashboard'", :yellow
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def remove_migrations
|
|
190
|
+
return if options[:manual_only]
|
|
191
|
+
return unless @components[:migrations]
|
|
192
|
+
|
|
193
|
+
migration_files.each do |file|
|
|
194
|
+
remove_file file
|
|
195
|
+
end
|
|
196
|
+
say " ✓ Removed #{migration_count} migration file(s)", :green
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def drop_database_tables
|
|
200
|
+
return if options[:manual_only]
|
|
201
|
+
return if options[:keep_data]
|
|
202
|
+
return unless @components[:tables]
|
|
203
|
+
|
|
204
|
+
say " Dropping database tables...", :yellow
|
|
205
|
+
|
|
206
|
+
# Drop tables in reverse order (to respect foreign keys)
|
|
207
|
+
tables_to_drop = [
|
|
208
|
+
"rails_error_dashboard_error_comments",
|
|
209
|
+
"rails_error_dashboard_error_occurrences",
|
|
210
|
+
"rails_error_dashboard_cascade_patterns",
|
|
211
|
+
"rails_error_dashboard_error_baselines",
|
|
212
|
+
"rails_error_dashboard_error_logs"
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
dropped_count = 0
|
|
216
|
+
tables_to_drop.each do |table|
|
|
217
|
+
if ActiveRecord::Base.connection.table_exists?(table)
|
|
218
|
+
ActiveRecord::Base.connection.drop_table(table, if_exists: true)
|
|
219
|
+
dropped_count += 1
|
|
220
|
+
end
|
|
221
|
+
rescue => e
|
|
222
|
+
say " ⚠️ Could not drop table #{table}: #{e.message}", :yellow
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
say " ✓ Dropped #{dropped_count} database table(s)", :green
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def show_completion_message
|
|
229
|
+
return if options[:manual_only]
|
|
230
|
+
|
|
231
|
+
say "\n"
|
|
232
|
+
say "=" * 80
|
|
233
|
+
say " ✅ Uninstall Complete!", :green
|
|
234
|
+
say "=" * 80
|
|
235
|
+
say "\n"
|
|
236
|
+
|
|
237
|
+
say "Remaining manual steps:", :cyan
|
|
238
|
+
say "\n"
|
|
239
|
+
|
|
240
|
+
say "1. Remove from Gemfile:", :yellow
|
|
241
|
+
say " Open: Gemfile"
|
|
242
|
+
say " Remove: gem 'rails_error_dashboard'"
|
|
243
|
+
say " Run: bundle install"
|
|
244
|
+
say "\n"
|
|
245
|
+
|
|
246
|
+
say "2. Restart your application:", :yellow
|
|
247
|
+
say " Run: rails restart"
|
|
248
|
+
say " Or: kill and restart your server process"
|
|
249
|
+
say "\n"
|
|
250
|
+
|
|
251
|
+
if options[:keep_data]
|
|
252
|
+
say "3. Database tables were kept (--keep-data flag)", :green
|
|
253
|
+
say " To remove data later, run:", :yellow
|
|
254
|
+
say " rails generate rails_error_dashboard:uninstall", :yellow
|
|
255
|
+
say "\n"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
say "Clean up environment variables (optional):", :yellow
|
|
259
|
+
say " • ERROR_DASHBOARD_USER, ERROR_DASHBOARD_PASSWORD"
|
|
260
|
+
say " • SLACK_WEBHOOK_URL, ERROR_NOTIFICATION_EMAILS"
|
|
261
|
+
say " • DISCORD_WEBHOOK_URL, PAGERDUTY_INTEGRATION_KEY"
|
|
262
|
+
say " • WEBHOOK_URLS, DASHBOARD_BASE_URL"
|
|
263
|
+
say "\n"
|
|
264
|
+
|
|
265
|
+
say "Thank you for using Rails Error Dashboard! 👋", :cyan
|
|
266
|
+
say "\n"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
private
|
|
270
|
+
|
|
271
|
+
def status_icon(present)
|
|
272
|
+
present ? "✓" : "✗"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def route_mounted?
|
|
276
|
+
return false unless File.exist?("config/routes.rb")
|
|
277
|
+
File.read("config/routes.rb").include?("RailsErrorDashboard::Engine")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def migrations_exist?
|
|
281
|
+
migration_files.any?
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def migration_files
|
|
285
|
+
Dir.glob("db/migrate/*rails_error_dashboard*.rb")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def migration_count
|
|
289
|
+
migration_files.count
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def tables_exist?
|
|
293
|
+
return false unless defined?(ActiveRecord::Base)
|
|
294
|
+
table_names.any? { |table| ActiveRecord::Base.connection.table_exists?(table) rescue false }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def table_names
|
|
298
|
+
[
|
|
299
|
+
"rails_error_dashboard_error_logs",
|
|
300
|
+
"rails_error_dashboard_error_occurrences",
|
|
301
|
+
"rails_error_dashboard_cascade_patterns",
|
|
302
|
+
"rails_error_dashboard_error_baselines",
|
|
303
|
+
"rails_error_dashboard_error_comments"
|
|
304
|
+
]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def table_count
|
|
308
|
+
table_names.count { |table| ActiveRecord::Base.connection.table_exists?(table) rescue false }
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def gemfile_includes_gem?
|
|
312
|
+
return false unless File.exist?("Gemfile")
|
|
313
|
+
File.read("Gemfile").match?(/gem\s+['"]rails_error_dashboard['"]/)
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
@@ -14,10 +14,11 @@ module RailsErrorDashboard
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def call
|
|
17
|
-
query = ErrorLog
|
|
17
|
+
query = ErrorLog
|
|
18
18
|
# Only eager load user if User model exists
|
|
19
19
|
query = query.includes(:user) if defined?(::User)
|
|
20
20
|
query = apply_filters(query)
|
|
21
|
+
query = apply_sorting(query)
|
|
21
22
|
query
|
|
22
23
|
end
|
|
23
24
|
|
|
@@ -29,6 +30,8 @@ module RailsErrorDashboard
|
|
|
29
30
|
query = filter_by_platform(query)
|
|
30
31
|
query = filter_by_search(query)
|
|
31
32
|
query = filter_by_severity(query)
|
|
33
|
+
query = filter_by_timeframe(query)
|
|
34
|
+
query = filter_by_frequency(query)
|
|
32
35
|
# Phase 3: Workflow filters
|
|
33
36
|
query = filter_by_status(query)
|
|
34
37
|
query = filter_by_assignment(query)
|
|
@@ -121,14 +124,14 @@ module RailsErrorDashboard
|
|
|
121
124
|
|
|
122
125
|
def filter_by_status(query)
|
|
123
126
|
return query unless @filters[:status].present?
|
|
124
|
-
return query unless
|
|
127
|
+
return query unless ErrorLog.column_names.include?("status")
|
|
125
128
|
|
|
126
129
|
query.by_status(@filters[:status])
|
|
127
130
|
end
|
|
128
131
|
|
|
129
132
|
def filter_by_assignment(query)
|
|
130
133
|
return query unless @filters[:assigned_to].present?
|
|
131
|
-
return query unless
|
|
134
|
+
return query unless ErrorLog.column_names.include?("assigned_to")
|
|
132
135
|
|
|
133
136
|
case @filters[:assigned_to]
|
|
134
137
|
when "__unassigned__"
|
|
@@ -142,13 +145,13 @@ module RailsErrorDashboard
|
|
|
142
145
|
|
|
143
146
|
def filter_by_priority(query)
|
|
144
147
|
return query unless @filters[:priority_level].present?
|
|
145
|
-
return query unless
|
|
148
|
+
return query unless ErrorLog.column_names.include?("priority_level")
|
|
146
149
|
|
|
147
150
|
query.by_priority(@filters[:priority_level])
|
|
148
151
|
end
|
|
149
152
|
|
|
150
153
|
def filter_by_snoozed(query)
|
|
151
|
-
return query unless
|
|
154
|
+
return query unless ErrorLog.column_names.include?("snoozed_until")
|
|
152
155
|
|
|
153
156
|
# If hide_snoozed is checked, exclude snoozed errors
|
|
154
157
|
if @filters[:hide_snoozed] == "1" || @filters[:hide_snoozed] == true
|
|
@@ -157,6 +160,75 @@ module RailsErrorDashboard
|
|
|
157
160
|
query
|
|
158
161
|
end
|
|
159
162
|
end
|
|
163
|
+
|
|
164
|
+
def filter_by_timeframe(query)
|
|
165
|
+
return query unless @filters[:timeframe].present?
|
|
166
|
+
|
|
167
|
+
case @filters[:timeframe]
|
|
168
|
+
when "last_hour"
|
|
169
|
+
query.where("occurred_at >= ?", 1.hour.ago)
|
|
170
|
+
when "today"
|
|
171
|
+
query.where("occurred_at >= ?", Time.current.beginning_of_day)
|
|
172
|
+
when "yesterday"
|
|
173
|
+
query.where("occurred_at BETWEEN ? AND ?",
|
|
174
|
+
1.day.ago.beginning_of_day,
|
|
175
|
+
1.day.ago.end_of_day)
|
|
176
|
+
when "last_7_days"
|
|
177
|
+
query.where("occurred_at >= ?", 7.days.ago)
|
|
178
|
+
when "last_30_days"
|
|
179
|
+
query.where("occurred_at >= ?", 30.days.ago)
|
|
180
|
+
when "last_90_days"
|
|
181
|
+
query.where("occurred_at >= ?", 90.days.ago)
|
|
182
|
+
else
|
|
183
|
+
query
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def filter_by_frequency(query)
|
|
188
|
+
return query unless @filters[:frequency].present?
|
|
189
|
+
|
|
190
|
+
case @filters[:frequency]
|
|
191
|
+
when "once"
|
|
192
|
+
query.where(occurrence_count: 1)
|
|
193
|
+
when "few"
|
|
194
|
+
query.where("occurrence_count BETWEEN ? AND ?", 2, 9)
|
|
195
|
+
when "frequent"
|
|
196
|
+
query.where("occurrence_count BETWEEN ? AND ?", 10, 99)
|
|
197
|
+
when "very_frequent"
|
|
198
|
+
query.where("occurrence_count >= ?", 100)
|
|
199
|
+
when "recurring"
|
|
200
|
+
# Errors that occurred multiple times AND are still active
|
|
201
|
+
query.where("occurrence_count > ?", 5)
|
|
202
|
+
.where("last_seen_at > ?", 24.hours.ago)
|
|
203
|
+
else
|
|
204
|
+
query
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def apply_sorting(query)
|
|
209
|
+
sort_column = @filters[:sort_by].presence || "occurred_at"
|
|
210
|
+
sort_direction = @filters[:sort_direction].presence || "desc"
|
|
211
|
+
|
|
212
|
+
# Validate sort direction
|
|
213
|
+
sort_direction = %w[asc desc].include?(sort_direction) ? sort_direction : "desc"
|
|
214
|
+
|
|
215
|
+
# Map severity to priority for sorting (since severity is an enum/method)
|
|
216
|
+
# We'll use priority_score which factors in severity
|
|
217
|
+
case sort_column
|
|
218
|
+
when "occurred_at", "first_seen_at", "last_seen_at", "created_at", "resolved_at"
|
|
219
|
+
query.order(sort_column => sort_direction)
|
|
220
|
+
when "occurrence_count", "priority_score"
|
|
221
|
+
query.order(sort_column => sort_direction, occurred_at: :desc)
|
|
222
|
+
when "error_type", "platform", "app_version"
|
|
223
|
+
query.order(sort_column => sort_direction, occurred_at: :desc)
|
|
224
|
+
when "severity"
|
|
225
|
+
# Sort by priority_score as proxy for severity (critical=highest score)
|
|
226
|
+
query.order(priority_score: sort_direction, occurred_at: :desc)
|
|
227
|
+
else
|
|
228
|
+
# Default sort
|
|
229
|
+
query.order(occurred_at: :desc)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
160
232
|
end
|
|
161
233
|
end
|
|
162
234
|
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Calculate Mean Time to Resolution (MTTR) statistics
|
|
6
|
+
# Provides metrics on how quickly errors are resolved
|
|
7
|
+
class MttrStats
|
|
8
|
+
def self.call(days = 30)
|
|
9
|
+
new(days).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(days = 30)
|
|
13
|
+
@days = days
|
|
14
|
+
@start_date = days.days.ago
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
{
|
|
19
|
+
overall_mttr: calculate_overall_mttr,
|
|
20
|
+
mttr_by_platform: mttr_by_platform,
|
|
21
|
+
mttr_by_severity: mttr_by_severity,
|
|
22
|
+
mttr_trend: mttr_trend_by_week,
|
|
23
|
+
fastest_resolution: fastest_resolution_time,
|
|
24
|
+
slowest_resolution: slowest_resolution_time,
|
|
25
|
+
total_resolved: resolved_errors.count
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def resolved_errors
|
|
32
|
+
@resolved_errors ||= ErrorLog
|
|
33
|
+
.where.not(resolved_at: nil)
|
|
34
|
+
.where("occurred_at >= ?", @start_date)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def calculate_overall_mttr
|
|
38
|
+
return 0 if resolved_errors.empty?
|
|
39
|
+
|
|
40
|
+
total_hours = resolved_errors.sum do |error|
|
|
41
|
+
((error.resolved_at - error.occurred_at) / 3600.0).round(2)
|
|
42
|
+
end
|
|
43
|
+
(total_hours / resolved_errors.count).round(2)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def mttr_by_platform
|
|
47
|
+
platforms = ErrorLog.distinct.pluck(:platform).compact
|
|
48
|
+
|
|
49
|
+
platforms.each_with_object({}) do |platform, result|
|
|
50
|
+
platform_resolved = resolved_errors.where(platform: platform)
|
|
51
|
+
next if platform_resolved.empty?
|
|
52
|
+
|
|
53
|
+
total_hours = platform_resolved.sum { |e| ((e.resolved_at - e.occurred_at) / 3600.0) }
|
|
54
|
+
result[platform] = (total_hours / platform_resolved.count).round(2)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def mttr_by_severity
|
|
59
|
+
{
|
|
60
|
+
critical: calculate_mttr_for_severity(:critical),
|
|
61
|
+
high: calculate_mttr_for_severity(:high),
|
|
62
|
+
medium: calculate_mttr_for_severity(:medium),
|
|
63
|
+
low: calculate_mttr_for_severity(:low)
|
|
64
|
+
}.compact
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def calculate_mttr_for_severity(severity)
|
|
68
|
+
severity_errors = resolved_errors.select { |e| e.severity == severity }
|
|
69
|
+
return nil if severity_errors.empty?
|
|
70
|
+
|
|
71
|
+
total_hours = severity_errors.sum { |e| ((e.resolved_at - e.occurred_at) / 3600.0) }
|
|
72
|
+
(total_hours / severity_errors.count).round(2)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def mttr_trend_by_week
|
|
76
|
+
trends = {}
|
|
77
|
+
current_date = @start_date
|
|
78
|
+
|
|
79
|
+
while current_date < Time.current
|
|
80
|
+
week_end = current_date + 1.week
|
|
81
|
+
week_resolved = ErrorLog
|
|
82
|
+
.where.not(resolved_at: nil)
|
|
83
|
+
.where("occurred_at >= ? AND occurred_at < ?", current_date, week_end)
|
|
84
|
+
|
|
85
|
+
if week_resolved.any?
|
|
86
|
+
total_hours = week_resolved.sum { |e| ((e.resolved_at - e.occurred_at) / 3600.0) }
|
|
87
|
+
trends[current_date.to_date.to_s] = (total_hours / week_resolved.count).round(2)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
current_date = week_end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
trends
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def fastest_resolution_time
|
|
97
|
+
return nil if resolved_errors.empty?
|
|
98
|
+
|
|
99
|
+
resolved_errors.min_by { |e| e.resolved_at - e.occurred_at }
|
|
100
|
+
.then { |e| ((e.resolved_at - e.occurred_at) / 60.0).round } # minutes
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def slowest_resolution_time
|
|
104
|
+
return nil if resolved_errors.empty?
|
|
105
|
+
|
|
106
|
+
resolved_errors.max_by { |e| e.resolved_at - e.occurred_at }
|
|
107
|
+
.then { |e| ((e.resolved_at - e.occurred_at) / 3600.0).round(1) } # hours
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query: Analyze recurring and persistent errors
|
|
6
|
+
# Returns data about high-frequency errors, persistent issues, and cyclical patterns
|
|
7
|
+
class RecurringIssues
|
|
8
|
+
def self.call(days = 30)
|
|
9
|
+
new(days).call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(days = 30)
|
|
13
|
+
@days = days
|
|
14
|
+
@start_date = days.days.ago
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
{
|
|
19
|
+
high_frequency_errors: high_frequency_errors,
|
|
20
|
+
persistent_errors: persistent_errors,
|
|
21
|
+
cyclical_patterns: cyclical_patterns
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def base_query
|
|
28
|
+
ErrorLog.where("occurred_at >= ?", @start_date)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def high_frequency_errors
|
|
32
|
+
# Errors with high occurrence count
|
|
33
|
+
base_query
|
|
34
|
+
.where("occurrence_count > ?", 10)
|
|
35
|
+
.group(:error_type)
|
|
36
|
+
.select("error_type,
|
|
37
|
+
SUM(occurrence_count) as total_occurrences,
|
|
38
|
+
MIN(first_seen_at) as first_occurrence,
|
|
39
|
+
MAX(last_seen_at) as last_occurrence,
|
|
40
|
+
COUNT(*) as unique_error_count")
|
|
41
|
+
.order("total_occurrences DESC")
|
|
42
|
+
.limit(10)
|
|
43
|
+
.map do |error|
|
|
44
|
+
first_seen = error.first_occurrence.is_a?(Time) ? error.first_occurrence : Time.parse(error.first_occurrence.to_s)
|
|
45
|
+
last_seen = error.last_occurrence.is_a?(Time) ? error.last_occurrence : Time.parse(error.last_occurrence.to_s)
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
error_type: error.error_type,
|
|
49
|
+
total_occurrences: error.total_occurrences,
|
|
50
|
+
first_seen: first_seen,
|
|
51
|
+
last_seen: last_seen,
|
|
52
|
+
duration_days: ((last_seen - first_seen) / 1.day).round,
|
|
53
|
+
still_active: last_seen > 24.hours.ago
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def persistent_errors
|
|
59
|
+
# Errors that have been unresolved for longest time
|
|
60
|
+
base_query
|
|
61
|
+
.where(resolved: false)
|
|
62
|
+
.where("first_seen_at < ?", 7.days.ago)
|
|
63
|
+
.order("first_seen_at ASC")
|
|
64
|
+
.limit(10)
|
|
65
|
+
.map do |error|
|
|
66
|
+
{
|
|
67
|
+
id: error.id,
|
|
68
|
+
error_type: error.error_type,
|
|
69
|
+
message: error.message.to_s.truncate(100),
|
|
70
|
+
first_seen: error.first_seen_at,
|
|
71
|
+
age_days: ((Time.current - error.first_seen_at) / 1.day).round,
|
|
72
|
+
occurrence_count: error.occurrence_count,
|
|
73
|
+
platform: error.platform
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def cyclical_patterns
|
|
79
|
+
# Use existing PatternDetector if available
|
|
80
|
+
return {} unless defined?(Services::PatternDetector)
|
|
81
|
+
|
|
82
|
+
top_error_types = base_query.group(:error_type).count.sort_by { |_, count| -count }.first(5).to_h.keys
|
|
83
|
+
|
|
84
|
+
top_error_types.each_with_object({}) do |error_type, result|
|
|
85
|
+
pattern = Services::PatternDetector.analyze_cyclical_pattern(
|
|
86
|
+
error_type: error_type,
|
|
87
|
+
platform: nil,
|
|
88
|
+
days: @days
|
|
89
|
+
)
|
|
90
|
+
result[error_type] = pattern if pattern[:pattern_strength] > 0.6
|
|
91
|
+
end
|
|
92
|
+
rescue NameError
|
|
93
|
+
{} # PatternDetector not available
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -8,6 +8,7 @@ require "pagy"
|
|
|
8
8
|
require "browser"
|
|
9
9
|
require "groupdate"
|
|
10
10
|
require "httparty"
|
|
11
|
+
require "chartkick"
|
|
11
12
|
|
|
12
13
|
# Core library files
|
|
13
14
|
require "rails_error_dashboard/value_objects/error_context"
|
|
@@ -32,6 +33,8 @@ require "rails_error_dashboard/queries/dashboard_stats"
|
|
|
32
33
|
require "rails_error_dashboard/queries/analytics_stats"
|
|
33
34
|
require "rails_error_dashboard/queries/filter_options"
|
|
34
35
|
require "rails_error_dashboard/queries/similar_errors"
|
|
36
|
+
require "rails_error_dashboard/queries/recurring_issues"
|
|
37
|
+
require "rails_error_dashboard/queries/mttr_stats"
|
|
35
38
|
require "rails_error_dashboard/error_reporter"
|
|
36
39
|
require "rails_error_dashboard/middleware/error_catcher"
|
|
37
40
|
|