rails_error_dashboard 0.1.1 → 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 +92 -21
- data/app/assets/stylesheets/rails_error_dashboard/_catppuccin_mocha.scss +107 -0
- data/app/assets/stylesheets/rails_error_dashboard/_components.scss +625 -0
- data/app/assets/stylesheets/rails_error_dashboard/_layout.scss +257 -0
- data/app/assets/stylesheets/rails_error_dashboard/_theme_variables.scss +203 -0
- data/app/assets/stylesheets/rails_error_dashboard/application.css.map +7 -0
- data/app/assets/stylesheets/rails_error_dashboard/application.scss +61 -0
- data/app/controllers/rails_error_dashboard/errors_controller.rb +135 -1
- data/app/helpers/rails_error_dashboard/application_helper.rb +80 -4
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +91 -0
- data/app/helpers/rails_error_dashboard/overview_helper.rb +78 -0
- data/app/helpers/rails_error_dashboard/user_agent_helper.rb +118 -0
- data/app/models/rails_error_dashboard/error_comment.rb +27 -0
- data/app/models/rails_error_dashboard/error_log.rb +159 -0
- data/app/views/layouts/rails_error_dashboard/application.html.erb +39 -1
- data/app/views/layouts/rails_error_dashboard.html.erb +796 -299
- data/app/views/layouts/rails_error_dashboard_old_backup.html.erb +383 -0
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +2 -0
- data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +4 -4
- data/app/views/rails_error_dashboard/errors/_timeline.html.erb +167 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +439 -22
- data/app/views/rails_error_dashboard/errors/index.html.erb +127 -11
- data/app/views/rails_error_dashboard/errors/overview.html.erb +253 -0
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +29 -18
- data/app/views/rails_error_dashboard/errors/show.html.erb +353 -54
- data/config/routes.rb +11 -1
- data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +27 -0
- data/db/migrate/20251226020100_create_error_comments.rb +18 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +8 -2
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +21 -0
- data/lib/generators/rails_error_dashboard/uninstall/uninstall_generator.rb +317 -0
- data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +1 -1
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +2 -2
- data/lib/rails_error_dashboard/commands/log_error.rb +47 -9
- data/lib/rails_error_dashboard/commands/resolve_error.rb +1 -1
- data/lib/rails_error_dashboard/configuration.rb +8 -0
- data/lib/rails_error_dashboard/error_reporter.rb +4 -4
- data/lib/rails_error_dashboard/logger.rb +105 -0
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +2 -2
- data/lib/rails_error_dashboard/plugin.rb +3 -3
- data/lib/rails_error_dashboard/plugin_registry.rb +2 -2
- data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +1 -1
- data/lib/rails_error_dashboard/plugins/metrics_plugin.rb +1 -1
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +109 -1
- data/lib/rails_error_dashboard/queries/errors_list.rb +134 -7
- 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/services/backtrace_parser.rb +113 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +5 -0
- data/lib/tasks/rails_error_dashboard_tasks.rake +85 -4
- metadata +36 -2
|
@@ -22,7 +22,15 @@ module RailsErrorDashboard
|
|
|
22
22
|
errors_trend_7d: errors_trend_7d,
|
|
23
23
|
errors_by_severity_7d: errors_by_severity_7d,
|
|
24
24
|
spike_detected: spike_detected?,
|
|
25
|
-
spike_info: spike_info
|
|
25
|
+
spike_info: spike_info,
|
|
26
|
+
# New metrics for Overview dashboard
|
|
27
|
+
error_rate: error_rate,
|
|
28
|
+
affected_users_today: affected_users_today,
|
|
29
|
+
affected_users_yesterday: affected_users_yesterday,
|
|
30
|
+
affected_users_change: affected_users_change,
|
|
31
|
+
trend_percentage: trend_percentage,
|
|
32
|
+
trend_direction: trend_direction,
|
|
33
|
+
top_errors_by_impact: top_errors_by_impact
|
|
26
34
|
}
|
|
27
35
|
end
|
|
28
36
|
|
|
@@ -164,6 +172,106 @@ module RailsErrorDashboard
|
|
|
164
172
|
:critical
|
|
165
173
|
end
|
|
166
174
|
end
|
|
175
|
+
|
|
176
|
+
# Calculate error rate as a percentage
|
|
177
|
+
# Since we don't track total requests, we'll use error count as proxy
|
|
178
|
+
# In the future, this could be: (errors / total_requests) * 100
|
|
179
|
+
def error_rate
|
|
180
|
+
today_errors = ErrorLog.where("occurred_at >= ?", Time.current.beginning_of_day).count
|
|
181
|
+
return 0.0 if today_errors.zero?
|
|
182
|
+
|
|
183
|
+
# For now, use a simple heuristic: errors per hour today
|
|
184
|
+
# Assume we want < 1 error per hour = good (< 1%)
|
|
185
|
+
# 1-5 errors per hour = warning (1-5%)
|
|
186
|
+
# > 5 errors per hour = critical (> 5%)
|
|
187
|
+
hours_today = ((Time.current - Time.current.beginning_of_day) / 1.hour).round(1)
|
|
188
|
+
hours_today = 1.0 if hours_today < 1.0 # Avoid division by zero in early morning
|
|
189
|
+
|
|
190
|
+
errors_per_hour = today_errors / hours_today
|
|
191
|
+
# Convert to percentage scale (0-100)
|
|
192
|
+
# Scale: 0 errors/hr = 0%, 1 error/hr = 1%, 10 errors/hr = 10%, etc.
|
|
193
|
+
[ errors_per_hour, 100.0 ].min.round(1)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Count distinct users affected by errors today
|
|
197
|
+
def affected_users_today
|
|
198
|
+
ErrorLog.where("occurred_at >= ?", Time.current.beginning_of_day)
|
|
199
|
+
.where.not(user_id: nil)
|
|
200
|
+
.distinct
|
|
201
|
+
.count(:user_id)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Count distinct users affected by errors yesterday
|
|
205
|
+
def affected_users_yesterday
|
|
206
|
+
ErrorLog.where("occurred_at >= ? AND occurred_at < ?",
|
|
207
|
+
1.day.ago.beginning_of_day,
|
|
208
|
+
Time.current.beginning_of_day)
|
|
209
|
+
.where.not(user_id: nil)
|
|
210
|
+
.distinct
|
|
211
|
+
.count(:user_id)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Calculate change in affected users (today vs yesterday)
|
|
215
|
+
def affected_users_change
|
|
216
|
+
today = affected_users_today
|
|
217
|
+
yesterday = affected_users_yesterday
|
|
218
|
+
|
|
219
|
+
return 0 if today.zero? && yesterday.zero?
|
|
220
|
+
return today if yesterday.zero?
|
|
221
|
+
|
|
222
|
+
today - yesterday
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Calculate percentage change in errors (today vs yesterday)
|
|
226
|
+
def trend_percentage
|
|
227
|
+
today = ErrorLog.where("occurred_at >= ?", Time.current.beginning_of_day).count
|
|
228
|
+
yesterday = ErrorLog.where("occurred_at >= ? AND occurred_at < ?",
|
|
229
|
+
1.day.ago.beginning_of_day,
|
|
230
|
+
Time.current.beginning_of_day).count
|
|
231
|
+
|
|
232
|
+
return 0.0 if today.zero? && yesterday.zero?
|
|
233
|
+
return 100.0 if yesterday.zero? && today.positive?
|
|
234
|
+
|
|
235
|
+
((today - yesterday).to_f / yesterday * 100).round(1)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Determine trend direction (increasing, decreasing, stable)
|
|
239
|
+
def trend_direction
|
|
240
|
+
trend = trend_percentage
|
|
241
|
+
|
|
242
|
+
if trend > 10
|
|
243
|
+
:increasing
|
|
244
|
+
elsif trend < -10
|
|
245
|
+
:decreasing
|
|
246
|
+
else
|
|
247
|
+
:stable
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Get top 5 errors ranked by impact score
|
|
252
|
+
# Impact = affected_users_count × occurrence_count
|
|
253
|
+
def top_errors_by_impact
|
|
254
|
+
ErrorLog.where("occurred_at >= ?", 7.days.ago)
|
|
255
|
+
.group(:error_type, :id)
|
|
256
|
+
.select("error_type, id, occurrence_count,
|
|
257
|
+
COUNT(DISTINCT user_id) as affected_users,
|
|
258
|
+
COUNT(DISTINCT user_id) * occurrence_count as impact_score")
|
|
259
|
+
.order("impact_score DESC")
|
|
260
|
+
.limit(5)
|
|
261
|
+
.map do |error|
|
|
262
|
+
full_error = ErrorLog.find(error.id)
|
|
263
|
+
{
|
|
264
|
+
id: error.id,
|
|
265
|
+
error_type: error.error_type,
|
|
266
|
+
message: full_error.message&.truncate(80),
|
|
267
|
+
severity: full_error.severity,
|
|
268
|
+
occurrence_count: error.occurrence_count,
|
|
269
|
+
affected_users: error.affected_users.to_i,
|
|
270
|
+
impact_score: error.impact_score.to_i,
|
|
271
|
+
occurred_at: full_error.occurred_at
|
|
272
|
+
}
|
|
273
|
+
end
|
|
274
|
+
end
|
|
167
275
|
end
|
|
168
276
|
end
|
|
169
277
|
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,13 @@ 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)
|
|
35
|
+
# Phase 3: Workflow filters
|
|
36
|
+
query = filter_by_status(query)
|
|
37
|
+
query = filter_by_assignment(query)
|
|
38
|
+
query = filter_by_priority(query)
|
|
39
|
+
query = filter_by_snoozed(query)
|
|
32
40
|
query
|
|
33
41
|
end
|
|
34
42
|
|
|
@@ -39,14 +47,23 @@ module RailsErrorDashboard
|
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
def filter_by_resolved(query)
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
50
|
+
# Handle unresolved filter with explicit true/false values
|
|
51
|
+
# When checkbox is unchecked: unresolved=false → show all errors
|
|
52
|
+
# When checkbox is checked: unresolved=true → show only unresolved errors
|
|
53
|
+
# When no filter: nil → default to unresolved only
|
|
54
|
+
|
|
55
|
+
case @filters[:unresolved]
|
|
56
|
+
when false, "false", "0"
|
|
57
|
+
# Explicitly show all errors (resolved and unresolved)
|
|
47
58
|
query
|
|
59
|
+
when true, "true", "1"
|
|
60
|
+
# Explicitly show only unresolved errors
|
|
61
|
+
query.unresolved
|
|
62
|
+
when nil, ""
|
|
63
|
+
# Default: show only unresolved errors when no filter is set
|
|
64
|
+
query.unresolved
|
|
48
65
|
else
|
|
49
|
-
#
|
|
66
|
+
# Fallback: show only unresolved errors
|
|
50
67
|
query.unresolved
|
|
51
68
|
end
|
|
52
69
|
end
|
|
@@ -102,6 +119,116 @@ module RailsErrorDashboard
|
|
|
102
119
|
|
|
103
120
|
query.where(error_type: error_types)
|
|
104
121
|
end
|
|
122
|
+
|
|
123
|
+
# Phase 3: Workflow filter methods
|
|
124
|
+
|
|
125
|
+
def filter_by_status(query)
|
|
126
|
+
return query unless @filters[:status].present?
|
|
127
|
+
return query unless ErrorLog.column_names.include?("status")
|
|
128
|
+
|
|
129
|
+
query.by_status(@filters[:status])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def filter_by_assignment(query)
|
|
133
|
+
return query unless @filters[:assigned_to].present?
|
|
134
|
+
return query unless ErrorLog.column_names.include?("assigned_to")
|
|
135
|
+
|
|
136
|
+
case @filters[:assigned_to]
|
|
137
|
+
when "__unassigned__"
|
|
138
|
+
query.unassigned
|
|
139
|
+
when "__assigned__"
|
|
140
|
+
query.assigned
|
|
141
|
+
else
|
|
142
|
+
query.by_assignee(@filters[:assigned_to])
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def filter_by_priority(query)
|
|
147
|
+
return query unless @filters[:priority_level].present?
|
|
148
|
+
return query unless ErrorLog.column_names.include?("priority_level")
|
|
149
|
+
|
|
150
|
+
query.by_priority(@filters[:priority_level])
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def filter_by_snoozed(query)
|
|
154
|
+
return query unless ErrorLog.column_names.include?("snoozed_until")
|
|
155
|
+
|
|
156
|
+
# If hide_snoozed is checked, exclude snoozed errors
|
|
157
|
+
if @filters[:hide_snoozed] == "1" || @filters[:hide_snoozed] == true
|
|
158
|
+
query.active
|
|
159
|
+
else
|
|
160
|
+
query
|
|
161
|
+
end
|
|
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
|
|
105
232
|
end
|
|
106
233
|
end
|
|
107
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
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Service: Parse and categorize backtrace frames
|
|
6
|
+
# Filters out framework noise to show only relevant application code
|
|
7
|
+
class BacktraceParser
|
|
8
|
+
# Match both formats:
|
|
9
|
+
# /path/file.rb:123:in `method'
|
|
10
|
+
# /path/file.rb:123:in 'ClassName#method'
|
|
11
|
+
FRAME_PATTERN = %r{^(.+):(\d+)(?::in [`'](.+)['`])?$}
|
|
12
|
+
|
|
13
|
+
def self.parse(backtrace_string)
|
|
14
|
+
new(backtrace_string).parse
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(backtrace_string)
|
|
18
|
+
@backtrace_string = backtrace_string
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def parse
|
|
22
|
+
return [] if @backtrace_string.blank?
|
|
23
|
+
|
|
24
|
+
lines = @backtrace_string.split("\n")
|
|
25
|
+
lines.map.with_index do |line, index|
|
|
26
|
+
parse_frame(line.strip, index)
|
|
27
|
+
end.compact
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def parse_frame(line, index)
|
|
33
|
+
match = line.match(FRAME_PATTERN)
|
|
34
|
+
return nil unless match
|
|
35
|
+
|
|
36
|
+
file_path = match[1]
|
|
37
|
+
line_number = match[2].to_i
|
|
38
|
+
method_name = match[3] || "(unknown)"
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
index: index,
|
|
42
|
+
file_path: file_path,
|
|
43
|
+
line_number: line_number,
|
|
44
|
+
method_name: method_name,
|
|
45
|
+
category: categorize_frame(file_path),
|
|
46
|
+
full_line: line,
|
|
47
|
+
short_path: shorten_path(file_path)
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def categorize_frame(file_path)
|
|
52
|
+
# Application code (highest priority)
|
|
53
|
+
return :app if app_code?(file_path)
|
|
54
|
+
|
|
55
|
+
# Gem code (dependencies)
|
|
56
|
+
return :gem if gem_code?(file_path)
|
|
57
|
+
|
|
58
|
+
# Rails framework
|
|
59
|
+
return :framework if rails_code?(file_path)
|
|
60
|
+
|
|
61
|
+
# Ruby core/stdlib
|
|
62
|
+
:ruby_core
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def app_code?(file_path)
|
|
66
|
+
# Match /app/, /lib/ directories in the application
|
|
67
|
+
file_path.include?("/app/") ||
|
|
68
|
+
(file_path.include?("/lib/") && !file_path.include?("/gems/") && !file_path.include?("/ruby/"))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def gem_code?(file_path)
|
|
72
|
+
file_path.include?("/gems/") ||
|
|
73
|
+
file_path.include?("/bundler/gems/") ||
|
|
74
|
+
file_path.include?("/vendor/bundle/")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def rails_code?(file_path)
|
|
78
|
+
file_path.include?("/railties-") ||
|
|
79
|
+
file_path.include?("/actionpack-") ||
|
|
80
|
+
file_path.include?("/actionview-") ||
|
|
81
|
+
file_path.include?("/activerecord-") ||
|
|
82
|
+
file_path.include?("/activesupport-") ||
|
|
83
|
+
file_path.include?("/actioncable-") ||
|
|
84
|
+
file_path.include?("/activejob-") ||
|
|
85
|
+
file_path.include?("/actionmailer-") ||
|
|
86
|
+
file_path.include?("/activestorage-") ||
|
|
87
|
+
file_path.include?("/actionmailbox-") ||
|
|
88
|
+
file_path.include?("/actiontext-") ||
|
|
89
|
+
file_path.include?("/rails-")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def shorten_path(file_path)
|
|
93
|
+
# Remove gem version numbers and long paths
|
|
94
|
+
# /Users/.../.gem/ruby/3.4.0/gems/activerecord-8.0.4/lib/... → activerecord/.../file.rb
|
|
95
|
+
if file_path.include?("/gems/")
|
|
96
|
+
parts = file_path.split("/gems/").last
|
|
97
|
+
gem_and_path = parts.split("/", 2)
|
|
98
|
+
gem_name = gem_and_path.first.split("-").first # Remove version
|
|
99
|
+
path_in_gem = gem_and_path.last
|
|
100
|
+
"#{gem_name}/#{path_in_gem}"
|
|
101
|
+
# /path/to/app/controllers/... → app/controllers/...
|
|
102
|
+
elsif file_path.include?("/app/")
|
|
103
|
+
file_path.split("/app/").last.prepend("app/")
|
|
104
|
+
elsif file_path.include?("/lib/") && !file_path.include?("/ruby/")
|
|
105
|
+
file_path.split("/lib/").last.prepend("lib/")
|
|
106
|
+
else
|
|
107
|
+
# Just show filename for Ruby core
|
|
108
|
+
File.basename(file_path)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
require "rails_error_dashboard/version"
|
|
2
2
|
require "rails_error_dashboard/engine"
|
|
3
3
|
require "rails_error_dashboard/configuration"
|
|
4
|
+
require "rails_error_dashboard/logger"
|
|
4
5
|
|
|
5
6
|
# External dependencies
|
|
6
7
|
require "pagy"
|
|
7
8
|
require "browser"
|
|
8
9
|
require "groupdate"
|
|
9
10
|
require "httparty"
|
|
11
|
+
require "chartkick"
|
|
10
12
|
|
|
11
13
|
# Core library files
|
|
12
14
|
require "rails_error_dashboard/value_objects/error_context"
|
|
13
15
|
require "rails_error_dashboard/services/platform_detector"
|
|
16
|
+
require "rails_error_dashboard/services/backtrace_parser"
|
|
14
17
|
require "rails_error_dashboard/services/similarity_calculator"
|
|
15
18
|
require "rails_error_dashboard/services/cascade_detector"
|
|
16
19
|
require "rails_error_dashboard/services/baseline_calculator"
|
|
@@ -30,6 +33,8 @@ require "rails_error_dashboard/queries/dashboard_stats"
|
|
|
30
33
|
require "rails_error_dashboard/queries/analytics_stats"
|
|
31
34
|
require "rails_error_dashboard/queries/filter_options"
|
|
32
35
|
require "rails_error_dashboard/queries/similar_errors"
|
|
36
|
+
require "rails_error_dashboard/queries/recurring_issues"
|
|
37
|
+
require "rails_error_dashboard/queries/mttr_stats"
|
|
33
38
|
require "rails_error_dashboard/error_reporter"
|
|
34
39
|
require "rails_error_dashboard/middleware/error_catcher"
|
|
35
40
|
|