rails_error_dashboard 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +257 -700
- data/app/controllers/rails_error_dashboard/application_controller.rb +18 -0
- data/app/controllers/rails_error_dashboard/errors_controller.rb +47 -4
- data/app/helpers/rails_error_dashboard/application_helper.rb +17 -0
- data/app/jobs/rails_error_dashboard/application_job.rb +19 -0
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +48 -0
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +263 -0
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +4 -8
- data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +2 -1
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +5 -5
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +10 -6
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +5 -6
- data/app/mailers/rails_error_dashboard/application_mailer.rb +1 -1
- data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +1 -1
- data/app/models/rails_error_dashboard/cascade_pattern.rb +74 -0
- data/app/models/rails_error_dashboard/error_baseline.rb +100 -0
- data/app/models/rails_error_dashboard/error_log.rb +326 -3
- data/app/models/rails_error_dashboard/error_occurrence.rb +49 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +150 -9
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +3 -10
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +1 -2
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +76 -0
- data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +209 -0
- data/app/views/rails_error_dashboard/errors/_stats.html.erb +34 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +19 -39
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +373 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +215 -138
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +388 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +428 -11
- data/config/routes.rb +2 -0
- data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +66 -0
- data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +26 -0
- data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +12 -0
- data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +9 -0
- data/db/migrate/20251225100236_create_error_occurrences.rb +31 -0
- data/db/migrate/20251225101920_create_cascade_patterns.rb +33 -0
- data/db/migrate/20251225102500_create_error_baselines.rb +38 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +270 -1
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +251 -37
- data/lib/generators/rails_error_dashboard/solid_queue/solid_queue_generator.rb +36 -0
- data/lib/generators/rails_error_dashboard/solid_queue/templates/queue.yml +55 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +234 -7
- data/lib/rails_error_dashboard/commands/resolve_error.rb +16 -0
- data/lib/rails_error_dashboard/configuration.rb +82 -5
- data/lib/rails_error_dashboard/error_reporter.rb +15 -7
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +17 -10
- data/lib/rails_error_dashboard/plugin.rb +6 -3
- data/lib/rails_error_dashboard/plugins/audit_log_plugin.rb +0 -1
- data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +2 -3
- data/lib/rails_error_dashboard/plugins/metrics_plugin.rb +0 -2
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +44 -6
- data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
- data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +86 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +134 -2
- data/lib/rails_error_dashboard/queries/error_cascades.rb +74 -0
- data/lib/rails_error_dashboard/queries/error_correlation.rb +375 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +52 -11
- data/lib/rails_error_dashboard/queries/filter_options.rb +0 -1
- data/lib/rails_error_dashboard/queries/platform_comparison.rb +254 -0
- data/lib/rails_error_dashboard/queries/similar_errors.rb +93 -0
- data/lib/rails_error_dashboard/services/baseline_alert_throttler.rb +88 -0
- data/lib/rails_error_dashboard/services/baseline_calculator.rb +269 -0
- data/lib/rails_error_dashboard/services/cascade_detector.rb +95 -0
- data/lib/rails_error_dashboard/services/pattern_detector.rb +268 -0
- data/lib/rails_error_dashboard/services/similarity_calculator.rb +144 -0
- data/lib/rails_error_dashboard/value_objects/error_context.rb +27 -1
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +55 -7
- metadata +52 -9
- data/app/models/rails_error_dashboard/application_record.rb +0 -5
- data/lib/rails_error_dashboard/queries/developer_insights.rb +0 -277
- data/lib/rails_error_dashboard/queries/errors_list_v2.rb +0 -149
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Detects cascade patterns by analyzing error occurrences
|
|
6
|
+
#
|
|
7
|
+
# Runs periodically to find errors that consistently follow other errors,
|
|
8
|
+
# indicating a causal relationship.
|
|
9
|
+
class CascadeDetector
|
|
10
|
+
# Time window to look for cascades (errors within this window may be related)
|
|
11
|
+
DETECTION_WINDOW = 60.seconds
|
|
12
|
+
|
|
13
|
+
# Minimum times a pattern must occur to be considered a cascade
|
|
14
|
+
MIN_CASCADE_FREQUENCY = 3
|
|
15
|
+
|
|
16
|
+
# Minimum probability threshold (% of time parent leads to child)
|
|
17
|
+
MIN_CASCADE_PROBABILITY = 0.7
|
|
18
|
+
|
|
19
|
+
def self.call(lookback_hours: 24)
|
|
20
|
+
new(lookback_hours: lookback_hours).detect_cascades
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(lookback_hours: 24)
|
|
24
|
+
@lookback_hours = lookback_hours
|
|
25
|
+
@detected_count = 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def detect_cascades
|
|
29
|
+
return { detected: 0, updated: 0 } unless can_detect?
|
|
30
|
+
|
|
31
|
+
# Get recent error occurrences
|
|
32
|
+
start_time = @lookback_hours.hours.ago
|
|
33
|
+
occurrences = ErrorOccurrence.where("occurred_at >= ?", start_time).order(:occurred_at)
|
|
34
|
+
|
|
35
|
+
# For each error occurrence, find potential children
|
|
36
|
+
patterns_found = Hash.new { |h, k| h[k] = { delays: [], count: 0 } }
|
|
37
|
+
|
|
38
|
+
occurrences.each do |parent_occ|
|
|
39
|
+
# Find occurrences within detection window
|
|
40
|
+
potential_children = ErrorOccurrence
|
|
41
|
+
.where("occurred_at > ? AND occurred_at <= ?",
|
|
42
|
+
parent_occ.occurred_at,
|
|
43
|
+
parent_occ.occurred_at + DETECTION_WINDOW)
|
|
44
|
+
.where.not(error_log_id: parent_occ.error_log_id)
|
|
45
|
+
|
|
46
|
+
potential_children.each do |child_occ|
|
|
47
|
+
key = [ parent_occ.error_log_id, child_occ.error_log_id ]
|
|
48
|
+
delay = (child_occ.occurred_at - parent_occ.occurred_at).to_f
|
|
49
|
+
|
|
50
|
+
patterns_found[key][:delays] << delay
|
|
51
|
+
patterns_found[key][:count] += 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Filter and save cascade patterns
|
|
56
|
+
updated_count = 0
|
|
57
|
+
patterns_found.each do |(parent_id, child_id), data|
|
|
58
|
+
next if data[:count] < MIN_CASCADE_FREQUENCY
|
|
59
|
+
|
|
60
|
+
# Find or create cascade pattern
|
|
61
|
+
pattern = CascadePattern.find_or_initialize_by(
|
|
62
|
+
parent_error_id: parent_id,
|
|
63
|
+
child_error_id: child_id
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
avg_delay = data[:delays].sum / data[:delays].size
|
|
67
|
+
|
|
68
|
+
if pattern.new_record?
|
|
69
|
+
pattern.frequency = data[:count]
|
|
70
|
+
pattern.avg_delay_seconds = avg_delay
|
|
71
|
+
pattern.last_detected_at = Time.current
|
|
72
|
+
pattern.save
|
|
73
|
+
@detected_count += 1
|
|
74
|
+
else
|
|
75
|
+
# Update existing pattern
|
|
76
|
+
pattern.increment_detection!(avg_delay)
|
|
77
|
+
updated_count += 1
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Calculate probability
|
|
81
|
+
pattern.calculate_probability!
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
{ detected: @detected_count, updated: updated_count }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def can_detect?
|
|
90
|
+
defined?(CascadePattern) && CascadePattern.table_exists? &&
|
|
91
|
+
defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Service object for detecting occurrence patterns in errors
|
|
6
|
+
#
|
|
7
|
+
# Provides two main pattern detection capabilities:
|
|
8
|
+
# 1. Cyclical patterns - Daily/weekly rhythms (e.g., business hours pattern)
|
|
9
|
+
# 2. Burst detection - Many errors in short time period
|
|
10
|
+
#
|
|
11
|
+
# @example Cyclical pattern
|
|
12
|
+
# pattern = PatternDetector.analyze_cyclical_pattern(
|
|
13
|
+
# error_type: "NoMethodError",
|
|
14
|
+
# platform: "ios",
|
|
15
|
+
# days: 30
|
|
16
|
+
# )
|
|
17
|
+
# # => {
|
|
18
|
+
# # pattern_type: :business_hours,
|
|
19
|
+
# # peak_hours: [9, 10, 11, 14, 15],
|
|
20
|
+
# # hourly_distribution: { 0 => 5, 1 => 3, ... },
|
|
21
|
+
# # pattern_strength: 0.8
|
|
22
|
+
# # }
|
|
23
|
+
#
|
|
24
|
+
# @example Burst detection
|
|
25
|
+
# bursts = PatternDetector.detect_bursts(
|
|
26
|
+
# error_type: "NoMethodError",
|
|
27
|
+
# platform: "ios",
|
|
28
|
+
# days: 7
|
|
29
|
+
# )
|
|
30
|
+
# # => [{
|
|
31
|
+
# # start_time: <Time>,
|
|
32
|
+
# # end_time: <Time>,
|
|
33
|
+
# # duration_seconds: 300,
|
|
34
|
+
# # error_count: 25,
|
|
35
|
+
# # burst_intensity: :high
|
|
36
|
+
# # }]
|
|
37
|
+
class PatternDetector
|
|
38
|
+
# Analyze cyclical patterns in error occurrences
|
|
39
|
+
#
|
|
40
|
+
# Detects:
|
|
41
|
+
# - Business hours pattern (9am-5pm peak)
|
|
42
|
+
# - Night pattern (midnight-6am peak)
|
|
43
|
+
# - Weekend pattern (Sat-Sun peak)
|
|
44
|
+
# - Uniform pattern (no clear pattern)
|
|
45
|
+
#
|
|
46
|
+
# @param error_type [String] The error type to analyze
|
|
47
|
+
# @param platform [String] The platform (iOS, Android, API, etc.)
|
|
48
|
+
# @param days [Integer] Number of days to analyze (default: 30)
|
|
49
|
+
# @return [Hash] Pattern analysis with type, peaks, distribution, and strength
|
|
50
|
+
def self.analyze_cyclical_pattern(error_type:, platform:, days: 30)
|
|
51
|
+
start_date = days.days.ago
|
|
52
|
+
|
|
53
|
+
# Get all error occurrences for this error type/platform
|
|
54
|
+
errors = ErrorLog
|
|
55
|
+
.where(error_type: error_type, platform: platform)
|
|
56
|
+
.where("occurred_at >= ?", start_date)
|
|
57
|
+
|
|
58
|
+
return empty_pattern if errors.empty?
|
|
59
|
+
|
|
60
|
+
# Group by hour of day (0-23)
|
|
61
|
+
hourly_distribution = Hash.new(0)
|
|
62
|
+
weekday_distribution = Hash.new(0)
|
|
63
|
+
|
|
64
|
+
errors.each do |error|
|
|
65
|
+
hour = error.occurred_at.hour
|
|
66
|
+
wday = error.occurred_at.wday # 0 = Sunday, 6 = Saturday
|
|
67
|
+
hourly_distribution[hour] += 1
|
|
68
|
+
weekday_distribution[wday] += 1
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Calculate pattern type and peaks
|
|
72
|
+
pattern_type = determine_pattern_type(hourly_distribution, weekday_distribution)
|
|
73
|
+
peak_hours = find_peak_hours(hourly_distribution)
|
|
74
|
+
pattern_strength = calculate_pattern_strength(hourly_distribution)
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
pattern_type: pattern_type,
|
|
78
|
+
peak_hours: peak_hours,
|
|
79
|
+
hourly_distribution: hourly_distribution,
|
|
80
|
+
weekday_distribution: weekday_distribution,
|
|
81
|
+
pattern_strength: pattern_strength,
|
|
82
|
+
total_errors: errors.count,
|
|
83
|
+
analysis_days: days
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Detect error bursts (sequences where errors occur rapidly)
|
|
88
|
+
#
|
|
89
|
+
# A burst is defined as a sequence where inter-arrival time < 1 minute
|
|
90
|
+
# Burst intensity:
|
|
91
|
+
# - :high - 20+ errors in burst
|
|
92
|
+
# - :medium - 10-19 errors
|
|
93
|
+
# - :low - 5-9 errors
|
|
94
|
+
#
|
|
95
|
+
# @param error_type [String] The error type to analyze
|
|
96
|
+
# @param platform [String] The platform
|
|
97
|
+
# @param days [Integer] Number of days to analyze (default: 7)
|
|
98
|
+
# @return [Array<Hash>] Array of burst metadata
|
|
99
|
+
def self.detect_bursts(error_type:, platform:, days: 7)
|
|
100
|
+
start_date = days.days.ago
|
|
101
|
+
|
|
102
|
+
# Get all error occurrences sorted by time
|
|
103
|
+
errors = ErrorLog
|
|
104
|
+
.where(error_type: error_type, platform: platform)
|
|
105
|
+
.where("occurred_at >= ?", start_date)
|
|
106
|
+
.order(:occurred_at)
|
|
107
|
+
|
|
108
|
+
return [] if errors.count < 5 # Need at least 5 errors to detect a burst
|
|
109
|
+
|
|
110
|
+
# Get all occurrence timestamps
|
|
111
|
+
timestamps = errors.flat_map do |error|
|
|
112
|
+
# If error has error_occurrences, use those timestamps
|
|
113
|
+
if error.respond_to?(:error_occurrences) && error.error_occurrences.any?
|
|
114
|
+
error.error_occurrences.pluck(:occurred_at)
|
|
115
|
+
else
|
|
116
|
+
# Otherwise use the error's occurred_at repeated by occurrence_count
|
|
117
|
+
Array.new(error.occurrence_count || 1, error.occurred_at)
|
|
118
|
+
end
|
|
119
|
+
end.sort
|
|
120
|
+
|
|
121
|
+
return [] if timestamps.size < 5
|
|
122
|
+
|
|
123
|
+
# Detect bursts: sequences where inter-arrival < 60 seconds
|
|
124
|
+
bursts = []
|
|
125
|
+
current_burst = nil
|
|
126
|
+
|
|
127
|
+
timestamps.each_with_index do |timestamp, i|
|
|
128
|
+
next if i.zero?
|
|
129
|
+
|
|
130
|
+
inter_arrival = timestamp - timestamps[i - 1]
|
|
131
|
+
|
|
132
|
+
if inter_arrival <= 60 # 60 seconds threshold
|
|
133
|
+
# Start new burst or continue existing
|
|
134
|
+
if current_burst.nil?
|
|
135
|
+
current_burst = {
|
|
136
|
+
start_time: timestamps[i - 1],
|
|
137
|
+
timestamps: [ timestamps[i - 1], timestamp ]
|
|
138
|
+
}
|
|
139
|
+
else
|
|
140
|
+
current_burst[:timestamps] << timestamp
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
# End current burst if it exists and has enough errors
|
|
144
|
+
if current_burst && current_burst[:timestamps].size >= 5
|
|
145
|
+
bursts << finalize_burst(current_burst)
|
|
146
|
+
end
|
|
147
|
+
current_burst = nil
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Don't forget the last burst
|
|
152
|
+
if current_burst && current_burst[:timestamps].size >= 5
|
|
153
|
+
bursts << finalize_burst(current_burst)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
bursts
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
161
|
+
# Empty pattern result
|
|
162
|
+
def self.empty_pattern
|
|
163
|
+
{
|
|
164
|
+
pattern_type: :none,
|
|
165
|
+
peak_hours: [],
|
|
166
|
+
hourly_distribution: {},
|
|
167
|
+
weekday_distribution: {},
|
|
168
|
+
pattern_strength: 0.0,
|
|
169
|
+
total_errors: 0,
|
|
170
|
+
analysis_days: 0
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Determine the pattern type based on hour and weekday distributions
|
|
175
|
+
def self.determine_pattern_type(hourly_dist, weekday_dist)
|
|
176
|
+
return :none if hourly_dist.empty?
|
|
177
|
+
|
|
178
|
+
# Calculate average errors per hour
|
|
179
|
+
avg_per_hour = hourly_dist.values.sum.to_f / 24
|
|
180
|
+
|
|
181
|
+
# Find peak hours (>2x average)
|
|
182
|
+
peak_hours = hourly_dist.select { |_, count| count > avg_per_hour * 2 }.keys.sort
|
|
183
|
+
|
|
184
|
+
# Business hours pattern: peaks between 9am-5pm
|
|
185
|
+
business_hours = (9..17).to_a
|
|
186
|
+
business_peaks = peak_hours & business_hours
|
|
187
|
+
if business_peaks.size >= 3
|
|
188
|
+
return :business_hours
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Night pattern: peaks between midnight-6am
|
|
192
|
+
night_hours = (0..6).to_a
|
|
193
|
+
night_peaks = peak_hours & night_hours
|
|
194
|
+
if night_peaks.size >= 2
|
|
195
|
+
return :night
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Weekend pattern: most errors on Sat/Sun
|
|
199
|
+
if weekday_dist.any?
|
|
200
|
+
weekend_count = (weekday_dist[0] || 0) + (weekday_dist[6] || 0) # Sun + Sat
|
|
201
|
+
total_count = weekday_dist.values.sum
|
|
202
|
+
if weekend_count > total_count * 0.5
|
|
203
|
+
return :weekend
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# No clear pattern
|
|
208
|
+
:uniform
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Find peak hours (hours with >2x average)
|
|
212
|
+
def self.find_peak_hours(hourly_dist)
|
|
213
|
+
return [] if hourly_dist.empty?
|
|
214
|
+
|
|
215
|
+
avg = hourly_dist.values.sum.to_f / 24
|
|
216
|
+
hourly_dist.select { |_, count| count > avg * 2 }.keys.sort
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Calculate pattern strength (0.0-1.0)
|
|
220
|
+
# Measures how concentrated the errors are in peak hours
|
|
221
|
+
def self.calculate_pattern_strength(hourly_dist)
|
|
222
|
+
return 0.0 if hourly_dist.empty?
|
|
223
|
+
|
|
224
|
+
total = hourly_dist.values.sum
|
|
225
|
+
return 0.0 if total.zero?
|
|
226
|
+
|
|
227
|
+
# Calculate coefficient of variation (std dev / mean)
|
|
228
|
+
# Higher variation = stronger pattern
|
|
229
|
+
values = (0..23).map { |h| hourly_dist[h] || 0 }
|
|
230
|
+
mean = total.to_f / 24
|
|
231
|
+
variance = values.sum { |v| (v - mean)**2 } / 24
|
|
232
|
+
std_dev = Math.sqrt(variance)
|
|
233
|
+
|
|
234
|
+
# Normalize to 0-1 scale (coefficient of variation)
|
|
235
|
+
# Divide by sqrt(mean) to get a rough 0-1 scale
|
|
236
|
+
cv = mean > 0 ? std_dev / mean : 0
|
|
237
|
+
[ cv.round(2), 1.0 ].min
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Finalize burst metadata
|
|
241
|
+
def self.finalize_burst(burst_data)
|
|
242
|
+
start_time = burst_data[:start_time]
|
|
243
|
+
end_time = burst_data[:timestamps].last
|
|
244
|
+
duration = end_time - start_time
|
|
245
|
+
count = burst_data[:timestamps].size
|
|
246
|
+
|
|
247
|
+
{
|
|
248
|
+
start_time: start_time,
|
|
249
|
+
end_time: end_time,
|
|
250
|
+
duration_seconds: duration.round(1),
|
|
251
|
+
error_count: count,
|
|
252
|
+
burst_intensity: classify_burst_intensity(count)
|
|
253
|
+
}
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Classify burst intensity based on error count
|
|
257
|
+
def self.classify_burst_intensity(count)
|
|
258
|
+
if count >= 20
|
|
259
|
+
:high
|
|
260
|
+
elsif count >= 10
|
|
261
|
+
:medium
|
|
262
|
+
else
|
|
263
|
+
:low
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Calculates similarity scores between errors using multiple algorithms
|
|
6
|
+
#
|
|
7
|
+
# Combines:
|
|
8
|
+
# - Backtrace similarity (Jaccard index on stack frames) - 70% weight
|
|
9
|
+
# - Message similarity (Levenshtein distance) - 30% weight
|
|
10
|
+
#
|
|
11
|
+
# Returns a similarity score from 0.0 (completely different) to 1.0 (identical)
|
|
12
|
+
class SimilarityCalculator
|
|
13
|
+
# Calculate similarity between two errors
|
|
14
|
+
#
|
|
15
|
+
# @param error1 [ErrorLog] First error to compare
|
|
16
|
+
# @param error2 [ErrorLog] Second error to compare
|
|
17
|
+
# @return [Float] Similarity score from 0.0 to 1.0
|
|
18
|
+
def self.call(error1, error2)
|
|
19
|
+
new(error1, error2).calculate
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(error1, error2)
|
|
23
|
+
@error1 = error1
|
|
24
|
+
@error2 = error2
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def calculate
|
|
28
|
+
# Quick return for same error
|
|
29
|
+
return 1.0 if @error1.id == @error2.id
|
|
30
|
+
|
|
31
|
+
# Quick return for different platforms (per user config - same platform only)
|
|
32
|
+
return 0.0 if different_platforms?
|
|
33
|
+
|
|
34
|
+
backtrace_score = calculate_backtrace_similarity
|
|
35
|
+
message_score = calculate_message_similarity
|
|
36
|
+
|
|
37
|
+
# Weighted combination: backtrace 70%, message 30%
|
|
38
|
+
(backtrace_score * 0.7) + (message_score * 0.3)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def different_platforms?
|
|
44
|
+
return false if @error1.platform.nil? || @error2.platform.nil?
|
|
45
|
+
@error1.platform != @error2.platform
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Calculate Jaccard similarity on backtrace frames
|
|
49
|
+
# Jaccard = intersection / union
|
|
50
|
+
def calculate_backtrace_similarity
|
|
51
|
+
frames1 = extract_frames(@error1.backtrace)
|
|
52
|
+
frames2 = extract_frames(@error2.backtrace)
|
|
53
|
+
|
|
54
|
+
return 0.0 if frames1.empty? || frames2.empty?
|
|
55
|
+
|
|
56
|
+
intersection = (frames1 & frames2).size
|
|
57
|
+
union = (frames1 | frames2).size
|
|
58
|
+
|
|
59
|
+
return 0.0 if union.zero?
|
|
60
|
+
|
|
61
|
+
intersection.to_f / union
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Calculate normalized Levenshtein distance on messages
|
|
65
|
+
# Returns 1.0 for identical messages, decreasing as they differ
|
|
66
|
+
def calculate_message_similarity
|
|
67
|
+
msg1 = normalize_message(@error1.message)
|
|
68
|
+
msg2 = normalize_message(@error2.message)
|
|
69
|
+
|
|
70
|
+
return 1.0 if msg1 == msg2
|
|
71
|
+
return 0.0 if msg1.empty? || msg2.empty?
|
|
72
|
+
|
|
73
|
+
distance = levenshtein_distance(msg1, msg2)
|
|
74
|
+
max_length = [ msg1.length, msg2.length ].max
|
|
75
|
+
|
|
76
|
+
return 0.0 if max_length.zero?
|
|
77
|
+
|
|
78
|
+
# Convert distance to similarity (1.0 = identical, 0.0 = completely different)
|
|
79
|
+
1.0 - (distance.to_f / max_length)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Extract meaningful frames from backtrace
|
|
83
|
+
# Format: "file.rb:123:in `method_name`" => "file.rb:method_name"
|
|
84
|
+
def extract_frames(backtrace)
|
|
85
|
+
return [] if backtrace.blank?
|
|
86
|
+
|
|
87
|
+
lines = backtrace.is_a?(String) ? backtrace.split("\n") : backtrace
|
|
88
|
+
lines.first(20).map do |line| # Only consider first 20 frames
|
|
89
|
+
# Extract file path and method name, ignore line numbers
|
|
90
|
+
if line =~ %r{([^/]+\.rb):.*?in `(.+)'$}
|
|
91
|
+
"#{Regexp.last_match(1)}:#{Regexp.last_match(2)}"
|
|
92
|
+
elsif line =~ %r{([^/]+\.rb)}
|
|
93
|
+
Regexp.last_match(1)
|
|
94
|
+
end
|
|
95
|
+
end.compact.uniq
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Normalize message for comparison
|
|
99
|
+
# Already normalized during error logging, but ensure consistency
|
|
100
|
+
def normalize_message(message)
|
|
101
|
+
return "" if message.nil?
|
|
102
|
+
|
|
103
|
+
message
|
|
104
|
+
.gsub(/#<\w+:0x[0-9a-f]+>/i, "__OBJ__") # Object inspections - temp placeholder
|
|
105
|
+
.gsub(/0x[0-9a-f]+/i, "__HEX__") # Hex addresses - temp placeholder
|
|
106
|
+
.gsub(/"[^"]*"/, '""') # Quoted strings to ""
|
|
107
|
+
.gsub(/'[^']*'/, "''") # Single quotes to ''
|
|
108
|
+
.downcase # Convert to lowercase
|
|
109
|
+
.gsub(/\d+/, "n") # Numbers to n (lowercase)
|
|
110
|
+
.gsub(/__hex__/, "0xhex") # Replace placeholder with final value
|
|
111
|
+
.gsub(/__obj__/, "#<obj>") # Replace placeholder with final value
|
|
112
|
+
.strip
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Calculate Levenshtein distance (edit distance) between two strings
|
|
116
|
+
# Classic dynamic programming algorithm
|
|
117
|
+
def levenshtein_distance(str1, str2)
|
|
118
|
+
return str2.length if str1.empty?
|
|
119
|
+
return str1.length if str2.empty?
|
|
120
|
+
|
|
121
|
+
# Create matrix
|
|
122
|
+
matrix = Array.new(str1.length + 1) { Array.new(str2.length + 1, 0) }
|
|
123
|
+
|
|
124
|
+
# Initialize first row and column
|
|
125
|
+
(0..str1.length).each { |i| matrix[i][0] = i }
|
|
126
|
+
(0..str2.length).each { |j| matrix[0][j] = j }
|
|
127
|
+
|
|
128
|
+
# Fill matrix
|
|
129
|
+
(1..str1.length).each do |i|
|
|
130
|
+
(1..str2.length).each do |j|
|
|
131
|
+
cost = str1[i - 1] == str2[j - 1] ? 0 : 1
|
|
132
|
+
matrix[i][j] = [
|
|
133
|
+
matrix[i - 1][j] + 1, # deletion
|
|
134
|
+
matrix[i][j - 1] + 1, # insertion
|
|
135
|
+
matrix[i - 1][j - 1] + cost # substitution
|
|
136
|
+
].min
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
matrix[str1.length][str2.length]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -6,7 +6,7 @@ module RailsErrorDashboard
|
|
|
6
6
|
# Extracts and normalizes context information from various sources
|
|
7
7
|
class ErrorContext
|
|
8
8
|
attr_reader :user_id, :request_url, :request_params, :user_agent, :ip_address, :platform,
|
|
9
|
-
:controller_name, :action_name
|
|
9
|
+
:controller_name, :action_name, :request_id, :session_id
|
|
10
10
|
|
|
11
11
|
def initialize(context, source = nil)
|
|
12
12
|
@context = context
|
|
@@ -20,6 +20,8 @@ module RailsErrorDashboard
|
|
|
20
20
|
@platform = detect_platform
|
|
21
21
|
@controller_name = extract_controller_name
|
|
22
22
|
@action_name = extract_action_name
|
|
23
|
+
@request_id = extract_request_id
|
|
24
|
+
@session_id = extract_session_id
|
|
23
25
|
end
|
|
24
26
|
|
|
25
27
|
def to_h
|
|
@@ -143,6 +145,30 @@ module RailsErrorDashboard
|
|
|
143
145
|
|
|
144
146
|
nil
|
|
145
147
|
end
|
|
148
|
+
|
|
149
|
+
def extract_request_id
|
|
150
|
+
# From Rails request
|
|
151
|
+
return @context[:request]&.request_id if @context[:request]&.respond_to?(:request_id)
|
|
152
|
+
|
|
153
|
+
# From explicit context
|
|
154
|
+
return @context[:request_id] if @context[:request_id]
|
|
155
|
+
|
|
156
|
+
# From job ID (for background jobs)
|
|
157
|
+
return @context[:job]&.job_id if @context[:job]
|
|
158
|
+
return @context[:jid] if @context[:jid]
|
|
159
|
+
|
|
160
|
+
nil
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def extract_session_id
|
|
164
|
+
# From Rails session
|
|
165
|
+
return @context[:request]&.session&.id if @context[:request]&.session
|
|
166
|
+
|
|
167
|
+
# From explicit context
|
|
168
|
+
return @context[:session_id] if @context[:session_id]
|
|
169
|
+
|
|
170
|
+
nil
|
|
171
|
+
end
|
|
146
172
|
end
|
|
147
173
|
end
|
|
148
174
|
end
|
|
@@ -11,6 +11,16 @@ require "httparty"
|
|
|
11
11
|
# Core library files
|
|
12
12
|
require "rails_error_dashboard/value_objects/error_context"
|
|
13
13
|
require "rails_error_dashboard/services/platform_detector"
|
|
14
|
+
require "rails_error_dashboard/services/similarity_calculator"
|
|
15
|
+
require "rails_error_dashboard/services/cascade_detector"
|
|
16
|
+
require "rails_error_dashboard/services/baseline_calculator"
|
|
17
|
+
require "rails_error_dashboard/services/baseline_alert_throttler"
|
|
18
|
+
require "rails_error_dashboard/services/pattern_detector"
|
|
19
|
+
require "rails_error_dashboard/queries/co_occurring_errors"
|
|
20
|
+
require "rails_error_dashboard/queries/error_cascades"
|
|
21
|
+
require "rails_error_dashboard/queries/baseline_stats"
|
|
22
|
+
require "rails_error_dashboard/queries/platform_comparison"
|
|
23
|
+
require "rails_error_dashboard/queries/error_correlation"
|
|
14
24
|
require "rails_error_dashboard/commands/log_error"
|
|
15
25
|
require "rails_error_dashboard/commands/resolve_error"
|
|
16
26
|
require "rails_error_dashboard/commands/batch_resolve_errors"
|
|
@@ -19,6 +29,7 @@ require "rails_error_dashboard/queries/errors_list"
|
|
|
19
29
|
require "rails_error_dashboard/queries/dashboard_stats"
|
|
20
30
|
require "rails_error_dashboard/queries/analytics_stats"
|
|
21
31
|
require "rails_error_dashboard/queries/filter_options"
|
|
32
|
+
require "rails_error_dashboard/queries/similar_errors"
|
|
22
33
|
require "rails_error_dashboard/error_reporter"
|
|
23
34
|
require "rails_error_dashboard/middleware/error_catcher"
|
|
24
35
|
|
|
@@ -28,12 +39,22 @@ require "rails_error_dashboard/plugin_registry"
|
|
|
28
39
|
|
|
29
40
|
module RailsErrorDashboard
|
|
30
41
|
class << self
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
attr_writer :configuration
|
|
43
|
+
|
|
44
|
+
# Get or initialize configuration
|
|
45
|
+
def configuration
|
|
46
|
+
@configuration ||= Configuration.new
|
|
47
|
+
end
|
|
33
48
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
49
|
+
# Configure the gem
|
|
50
|
+
def configure
|
|
51
|
+
yield(configuration)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Reset configuration to defaults
|
|
55
|
+
def reset_configuration!
|
|
56
|
+
@configuration = Configuration.new
|
|
57
|
+
end
|
|
37
58
|
end
|
|
38
59
|
|
|
39
60
|
# Register a plugin
|
|
@@ -55,6 +76,33 @@ module RailsErrorDashboard
|
|
|
55
76
|
PluginRegistry.plugins
|
|
56
77
|
end
|
|
57
78
|
|
|
58
|
-
#
|
|
59
|
-
|
|
79
|
+
# Register a callback for when any error is logged
|
|
80
|
+
# @param block [Proc] The callback to execute, receives error_log as parameter
|
|
81
|
+
# @example
|
|
82
|
+
# RailsErrorDashboard.on_error_logged do |error_log|
|
|
83
|
+
# puts "Error logged: #{error_log.error_type}"
|
|
84
|
+
# end
|
|
85
|
+
def self.on_error_logged(&block)
|
|
86
|
+
configuration.notification_callbacks[:error_logged] << block if block_given?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Register a callback for when a critical error is logged
|
|
90
|
+
# @param block [Proc] The callback to execute, receives error_log as parameter
|
|
91
|
+
# @example
|
|
92
|
+
# RailsErrorDashboard.on_critical_error do |error_log|
|
|
93
|
+
# PagerDuty.trigger(error_log)
|
|
94
|
+
# end
|
|
95
|
+
def self.on_critical_error(&block)
|
|
96
|
+
configuration.notification_callbacks[:critical_error] << block if block_given?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Register a callback for when an error is resolved
|
|
100
|
+
# @param block [Proc] The callback to execute, receives error_log as parameter
|
|
101
|
+
# @example
|
|
102
|
+
# RailsErrorDashboard.on_error_resolved do |error_log|
|
|
103
|
+
# Slack.notify("Error #{error_log.id} resolved")
|
|
104
|
+
# end
|
|
105
|
+
def self.on_error_resolved(&block)
|
|
106
|
+
configuration.notification_callbacks[:error_resolved] << block if block_given?
|
|
107
|
+
end
|
|
60
108
|
end
|