rails_error_dashboard 0.1.0 → 0.1.3
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 +305 -703
- 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 +926 -15
- 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/application_controller.rb +18 -0
- data/app/controllers/rails_error_dashboard/errors_controller.rb +140 -4
- data/app/helpers/rails_error_dashboard/application_helper.rb +55 -0
- 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/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_comment.rb +27 -0
- data/app/models/rails_error_dashboard/error_log.rb +471 -3
- data/app/models/rails_error_dashboard/error_occurrence.rb +49 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +816 -178
- data/app/views/layouts/rails_error_dashboard_old_backup.html.erb +383 -0
- 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 +78 -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/_timeline.html.erb +167 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +152 -56
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +373 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +294 -138
- data/app/views/rails_error_dashboard/errors/overview.html.erb +253 -0
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +399 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +781 -65
- data/config/routes.rb +9 -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/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 +276 -1
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +272 -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/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 +272 -7
- data/lib/rails_error_dashboard/commands/resolve_error.rb +16 -0
- data/lib/rails_error_dashboard/configuration.rb +90 -5
- data/lib/rails_error_dashboard/error_reporter.rb +15 -7
- data/lib/rails_error_dashboard/logger.rb +105 -0
- 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/plugin_registry.rb +2 -2
- data/lib/rails_error_dashboard/plugins/audit_log_plugin.rb +0 -1
- data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +3 -4
- data/lib/rails_error_dashboard/plugins/metrics_plugin.rb +1 -3
- 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 +242 -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 +106 -10
- 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/backtrace_parser.rb +113 -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 +57 -7
- metadata +69 -10
- 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
- data/lib/tasks/rails_error_dashboard_tasks.rake +0 -4
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Query object for comparing error metrics across platforms
|
|
6
|
+
#
|
|
7
|
+
# Provides analytics comparing iOS vs Android vs API vs Web platforms:
|
|
8
|
+
# - Error rates and trends
|
|
9
|
+
# - Severity distribution
|
|
10
|
+
# - Resolution times
|
|
11
|
+
# - Top errors per platform
|
|
12
|
+
# - Platform stability scores
|
|
13
|
+
# - Cross-platform errors
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# comparison = PlatformComparison.new(days: 7)
|
|
17
|
+
# comparison.error_rate_by_platform
|
|
18
|
+
# # => { "ios" => 150, "android" => 200, "api" => 50, "web" => 100 }
|
|
19
|
+
class PlatformComparison
|
|
20
|
+
attr_reader :days
|
|
21
|
+
|
|
22
|
+
# @param days [Integer] Number of days to analyze (default: 7)
|
|
23
|
+
def initialize(days: 7)
|
|
24
|
+
@days = days
|
|
25
|
+
@start_date = days.days.ago
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get error count by platform for the time period
|
|
29
|
+
# @return [Hash] Platform name => error count
|
|
30
|
+
def error_rate_by_platform
|
|
31
|
+
ErrorLog
|
|
32
|
+
.where("occurred_at >= ?", @start_date)
|
|
33
|
+
.group(:platform)
|
|
34
|
+
.count
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get severity distribution by platform
|
|
38
|
+
# @return [Hash] Platform => { severity => count }
|
|
39
|
+
def severity_distribution_by_platform
|
|
40
|
+
platforms = ErrorLog.distinct.pluck(:platform).compact
|
|
41
|
+
|
|
42
|
+
platforms.each_with_object({}) do |platform, result|
|
|
43
|
+
errors = ErrorLog
|
|
44
|
+
.where(platform: platform)
|
|
45
|
+
.where("occurred_at >= ?", @start_date)
|
|
46
|
+
|
|
47
|
+
# Calculate severity in Ruby since it's a method, not a column
|
|
48
|
+
severity_counts = Hash.new(0)
|
|
49
|
+
errors.each do |error|
|
|
50
|
+
severity_counts[error.severity] += 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
result[platform] = severity_counts
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get average resolution time by platform
|
|
58
|
+
# @return [Hash] Platform => average hours to resolve
|
|
59
|
+
def resolution_time_by_platform
|
|
60
|
+
platforms = ErrorLog.distinct.pluck(:platform).compact
|
|
61
|
+
|
|
62
|
+
platforms.each_with_object({}) do |platform, result|
|
|
63
|
+
resolved_errors = ErrorLog
|
|
64
|
+
.where(platform: platform)
|
|
65
|
+
.where.not(resolved_at: nil)
|
|
66
|
+
.where("occurred_at >= ?", @start_date)
|
|
67
|
+
|
|
68
|
+
if resolved_errors.any?
|
|
69
|
+
total_hours = resolved_errors.sum do |error|
|
|
70
|
+
((error.resolved_at - error.occurred_at) / 3600.0).round(2)
|
|
71
|
+
end
|
|
72
|
+
result[platform] = (total_hours / resolved_errors.count).round(2)
|
|
73
|
+
else
|
|
74
|
+
result[platform] = nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Get top 10 errors for each platform
|
|
80
|
+
# @return [Hash] Platform => Array of error hashes
|
|
81
|
+
def top_errors_by_platform
|
|
82
|
+
platforms = ErrorLog.distinct.pluck(:platform).compact
|
|
83
|
+
|
|
84
|
+
platforms.each_with_object({}) do |platform, result|
|
|
85
|
+
result[platform] = ErrorLog
|
|
86
|
+
.where(platform: platform)
|
|
87
|
+
.where("occurred_at >= ?", @start_date)
|
|
88
|
+
.select(:id, :error_type, :message, :occurrence_count, :occurred_at)
|
|
89
|
+
.order(occurrence_count: :desc)
|
|
90
|
+
.limit(10)
|
|
91
|
+
.map do |error|
|
|
92
|
+
{
|
|
93
|
+
id: error.id,
|
|
94
|
+
error_type: error.error_type,
|
|
95
|
+
message: error.message&.truncate(100),
|
|
96
|
+
severity: error.severity, # Calls the method
|
|
97
|
+
occurrence_count: error.occurrence_count,
|
|
98
|
+
occurred_at: error.occurred_at
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Calculate platform stability score (0-100)
|
|
105
|
+
# Higher score = more stable (fewer errors, faster resolution)
|
|
106
|
+
# @return [Hash] Platform => stability score
|
|
107
|
+
def platform_stability_scores
|
|
108
|
+
platforms = ErrorLog.distinct.pluck(:platform).compact
|
|
109
|
+
error_rates = error_rate_by_platform
|
|
110
|
+
resolution_times = resolution_time_by_platform
|
|
111
|
+
|
|
112
|
+
# Find max values for normalization
|
|
113
|
+
max_errors = error_rates.values.max || 1
|
|
114
|
+
max_resolution_time = resolution_times.values.compact.max || 1
|
|
115
|
+
|
|
116
|
+
platforms.each_with_object({}) do |platform, result|
|
|
117
|
+
error_count = error_rates[platform] || 0
|
|
118
|
+
avg_resolution = resolution_times[platform] || 0
|
|
119
|
+
|
|
120
|
+
# Normalize to 0-1 scale (inverted - lower is better)
|
|
121
|
+
error_score = 1.0 - (error_count.to_f / max_errors)
|
|
122
|
+
resolution_score = avg_resolution.positive? ? 1.0 - (avg_resolution / max_resolution_time) : 1.0
|
|
123
|
+
|
|
124
|
+
# Weight: 70% error count, 30% resolution time
|
|
125
|
+
# Convert to 0-100 scale
|
|
126
|
+
result[platform] = ((error_score * 0.7 + resolution_score * 0.3) * 100).round(1)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Find errors that occur across multiple platforms
|
|
131
|
+
# @return [Array<Hash>] Errors with their platforms
|
|
132
|
+
def cross_platform_errors
|
|
133
|
+
# Get error types that appear on 2+ platforms
|
|
134
|
+
error_types_with_platforms = ErrorLog
|
|
135
|
+
.where("occurred_at >= ?", @start_date)
|
|
136
|
+
.group(:error_type, :platform)
|
|
137
|
+
.select(:error_type, :platform)
|
|
138
|
+
.having("COUNT(*) > 0")
|
|
139
|
+
.pluck(:error_type, :platform)
|
|
140
|
+
|
|
141
|
+
# Group by error_type to find those on multiple platforms
|
|
142
|
+
errors_by_type = error_types_with_platforms.group_by { |error_type, _| error_type }
|
|
143
|
+
|
|
144
|
+
errors_by_type
|
|
145
|
+
.select { |_, platforms| platforms.map(&:last).uniq.count > 1 }
|
|
146
|
+
.map do |error_type, platform_pairs|
|
|
147
|
+
platforms = platform_pairs.map(&:last).uniq
|
|
148
|
+
total_count = ErrorLog
|
|
149
|
+
.where(error_type: error_type, platform: platforms)
|
|
150
|
+
.where("occurred_at >= ?", @start_date)
|
|
151
|
+
.sum(:occurrence_count)
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
error_type: error_type,
|
|
155
|
+
platforms: platforms.sort,
|
|
156
|
+
total_occurrences: total_count,
|
|
157
|
+
platform_breakdown: platforms.each_with_object({}) do |platform, breakdown|
|
|
158
|
+
breakdown[platform] = ErrorLog
|
|
159
|
+
.where(error_type: error_type, platform: platform)
|
|
160
|
+
.where("occurred_at >= ?", @start_date)
|
|
161
|
+
.sum(:occurrence_count)
|
|
162
|
+
end
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
.sort_by { |error| -error[:total_occurrences] }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Get daily error trend by platform
|
|
169
|
+
# @return [Hash] Platform => { date => count }
|
|
170
|
+
def daily_trend_by_platform
|
|
171
|
+
platforms = ErrorLog.distinct.pluck(:platform).compact
|
|
172
|
+
|
|
173
|
+
platforms.each_with_object({}) do |platform, result|
|
|
174
|
+
result[platform] = ErrorLog
|
|
175
|
+
.where(platform: platform)
|
|
176
|
+
.where("occurred_at >= ?", @start_date)
|
|
177
|
+
.group_by_day(:occurred_at, range: @start_date..Time.current)
|
|
178
|
+
.count
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Get platform health summary
|
|
183
|
+
# @return [Hash] Platform => health metrics
|
|
184
|
+
def platform_health_summary
|
|
185
|
+
platforms = ErrorLog.distinct.pluck(:platform).compact
|
|
186
|
+
error_rates = error_rate_by_platform
|
|
187
|
+
stability_scores = platform_stability_scores
|
|
188
|
+
|
|
189
|
+
platforms.each_with_object({}) do |platform, result|
|
|
190
|
+
total_errors = error_rates[platform] || 0
|
|
191
|
+
|
|
192
|
+
# Count critical errors by checking severity method
|
|
193
|
+
critical_errors = ErrorLog
|
|
194
|
+
.where(platform: platform)
|
|
195
|
+
.where("occurred_at >= ?", @start_date)
|
|
196
|
+
.select { |error| error.severity == :critical }
|
|
197
|
+
.count
|
|
198
|
+
|
|
199
|
+
unresolved_errors = ErrorLog
|
|
200
|
+
.where(platform: platform, resolved_at: nil)
|
|
201
|
+
.where("occurred_at >= ?", @start_date)
|
|
202
|
+
.count
|
|
203
|
+
|
|
204
|
+
resolved_errors = ErrorLog
|
|
205
|
+
.where(platform: platform)
|
|
206
|
+
.where.not(resolved_at: nil)
|
|
207
|
+
.where("occurred_at >= ?", @start_date)
|
|
208
|
+
.count
|
|
209
|
+
|
|
210
|
+
resolution_rate = total_errors.positive? ? ((resolved_errors.to_f / total_errors) * 100).round(1) : 0.0
|
|
211
|
+
|
|
212
|
+
# Calculate error velocity (increasing or decreasing)
|
|
213
|
+
first_half = ErrorLog
|
|
214
|
+
.where(platform: platform)
|
|
215
|
+
.where("occurred_at >= ? AND occurred_at < ?", @start_date, @start_date + (@days / 2.0).days)
|
|
216
|
+
.count
|
|
217
|
+
|
|
218
|
+
second_half = ErrorLog
|
|
219
|
+
.where(platform: platform)
|
|
220
|
+
.where("occurred_at >= ?", @start_date + (@days / 2.0).days)
|
|
221
|
+
.count
|
|
222
|
+
|
|
223
|
+
velocity = first_half.positive? ? (((second_half - first_half).to_f / first_half) * 100).round(1) : 0.0
|
|
224
|
+
|
|
225
|
+
result[platform] = {
|
|
226
|
+
total_errors: total_errors,
|
|
227
|
+
critical_errors: critical_errors,
|
|
228
|
+
unresolved_errors: unresolved_errors,
|
|
229
|
+
resolution_rate: resolution_rate,
|
|
230
|
+
stability_score: stability_scores[platform] || 0,
|
|
231
|
+
error_velocity: velocity, # Positive = increasing, negative = decreasing
|
|
232
|
+
health_status: determine_health_status(stability_scores[platform] || 0, velocity)
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
private
|
|
238
|
+
|
|
239
|
+
# Determine health status based on stability score and velocity
|
|
240
|
+
# @param stability_score [Float] 0-100 stability score
|
|
241
|
+
# @param velocity [Float] Error velocity percentage
|
|
242
|
+
# @return [Symbol] :healthy, :warning, or :critical
|
|
243
|
+
def determine_health_status(stability_score, velocity)
|
|
244
|
+
if stability_score >= 80 && velocity <= 10
|
|
245
|
+
:healthy
|
|
246
|
+
elsif stability_score >= 60 && velocity <= 50
|
|
247
|
+
:warning
|
|
248
|
+
else
|
|
249
|
+
:critical
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Queries
|
|
5
|
+
# Find errors similar to a target error using fuzzy matching
|
|
6
|
+
#
|
|
7
|
+
# Uses SimilarityCalculator to compute similarity scores based on:
|
|
8
|
+
# - Backtrace pattern similarity (70% weight)
|
|
9
|
+
# - Message similarity (30% weight)
|
|
10
|
+
#
|
|
11
|
+
# Returns errors with similarity >= threshold, sorted by score descending
|
|
12
|
+
class SimilarErrors
|
|
13
|
+
# Find similar errors
|
|
14
|
+
#
|
|
15
|
+
# @param error_id [Integer] ID of target error
|
|
16
|
+
# @param threshold [Float] Minimum similarity score (0.0-1.0), default 0.6
|
|
17
|
+
# @param limit [Integer] Maximum number of results, default 10
|
|
18
|
+
# @return [Array<Hash>] Array of {error: ErrorLog, similarity: Float}
|
|
19
|
+
def self.call(error_id, threshold: 0.6, limit: 10)
|
|
20
|
+
new(error_id, threshold: threshold, limit: limit).find_similar
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(error_id, threshold: 0.6, limit: 10)
|
|
24
|
+
@error_id = error_id
|
|
25
|
+
@threshold = threshold.to_f
|
|
26
|
+
@limit = limit.to_i
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def find_similar
|
|
30
|
+
target_error = ErrorLog.find_by(id: @error_id)
|
|
31
|
+
return [] unless target_error
|
|
32
|
+
|
|
33
|
+
# Find candidate errors to compare
|
|
34
|
+
candidates = find_candidates(target_error)
|
|
35
|
+
|
|
36
|
+
# Calculate similarity scores
|
|
37
|
+
similar_errors = candidates.map do |candidate|
|
|
38
|
+
score = Services::SimilarityCalculator.call(target_error, candidate)
|
|
39
|
+
next if score < @threshold
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
error: candidate,
|
|
43
|
+
similarity: score.round(3)
|
|
44
|
+
}
|
|
45
|
+
end.compact
|
|
46
|
+
|
|
47
|
+
# Sort by similarity score (highest first) and limit results
|
|
48
|
+
similar_errors.sort_by { |item| -item[:similarity] }.first(@limit)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def find_candidates(target_error)
|
|
54
|
+
# Build candidate query with multiple strategies for performance
|
|
55
|
+
|
|
56
|
+
# Strategy 1: Same backtrace signature (fastest, most precise)
|
|
57
|
+
candidates = []
|
|
58
|
+
if target_error.backtrace_signature.present?
|
|
59
|
+
candidates += ErrorLog
|
|
60
|
+
.where(backtrace_signature: target_error.backtrace_signature)
|
|
61
|
+
.where.not(id: target_error.id)
|
|
62
|
+
.limit(50)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Strategy 2: Same error type (fast, good recall)
|
|
66
|
+
if candidates.size < 20
|
|
67
|
+
candidates += ErrorLog
|
|
68
|
+
.where(error_type: target_error.error_type)
|
|
69
|
+
.where.not(id: target_error.id)
|
|
70
|
+
.where.not(id: candidates.map(&:id))
|
|
71
|
+
.limit(30)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Strategy 3: Same platform + same first word in error type
|
|
75
|
+
# (catches similar errors like NoMethodError vs NameError)
|
|
76
|
+
if candidates.size < 20 && target_error.platform.present?
|
|
77
|
+
error_prefix = target_error.error_type&.split("::")&.last&.split(/(?=[A-Z])/, 2)&.first
|
|
78
|
+
if error_prefix.present?
|
|
79
|
+
candidates += ErrorLog
|
|
80
|
+
.where(platform: target_error.platform)
|
|
81
|
+
.where("error_type LIKE ?", "%#{error_prefix}%")
|
|
82
|
+
.where.not(id: target_error.id)
|
|
83
|
+
.where.not(id: candidates.map(&:id))
|
|
84
|
+
.limit(20)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Return unique candidates
|
|
89
|
+
candidates.uniq
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
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
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Throttles baseline alerts to prevent alert fatigue
|
|
6
|
+
#
|
|
7
|
+
# Tracks when alerts were last sent for each error_type/platform combination
|
|
8
|
+
# and prevents sending duplicate alerts within the cooldown window.
|
|
9
|
+
#
|
|
10
|
+
# Uses an in-memory cache (class variable) for simplicity. For distributed
|
|
11
|
+
# systems, consider using Redis or a database-backed solution.
|
|
12
|
+
class BaselineAlertThrottler
|
|
13
|
+
@last_alert_times = {}
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Check if an alert should be sent (not in cooldown period)
|
|
18
|
+
# @param error_type [String] The error type
|
|
19
|
+
# @param platform [String] The platform
|
|
20
|
+
# @param cooldown_minutes [Integer] Cooldown period in minutes
|
|
21
|
+
# @return [Boolean] True if alert should be sent
|
|
22
|
+
def should_alert?(error_type, platform, cooldown_minutes: 120)
|
|
23
|
+
key = alert_key(error_type, platform)
|
|
24
|
+
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
last_time = @last_alert_times[key]
|
|
27
|
+
|
|
28
|
+
# No previous alert, allow this one
|
|
29
|
+
return true if last_time.nil?
|
|
30
|
+
|
|
31
|
+
# Check if cooldown period has passed
|
|
32
|
+
Time.current > (last_time + cooldown_minutes.minutes)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Record that an alert was sent
|
|
37
|
+
# @param error_type [String] The error type
|
|
38
|
+
# @param platform [String] The platform
|
|
39
|
+
def record_alert(error_type, platform)
|
|
40
|
+
key = alert_key(error_type, platform)
|
|
41
|
+
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
@last_alert_times[key] = Time.current
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get time since last alert
|
|
48
|
+
# @param error_type [String] The error type
|
|
49
|
+
# @param platform [String] The platform
|
|
50
|
+
# @return [Integer, nil] Minutes since last alert, or nil if never alerted
|
|
51
|
+
def minutes_since_last_alert(error_type, platform)
|
|
52
|
+
key = alert_key(error_type, platform)
|
|
53
|
+
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
last_time = @last_alert_times[key]
|
|
56
|
+
return nil if last_time.nil?
|
|
57
|
+
|
|
58
|
+
((Time.current - last_time) / 60).to_i
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Clear all alert records (useful for testing)
|
|
63
|
+
def clear!
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
@last_alert_times.clear
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Clean up old entries (older than max_age_hours)
|
|
70
|
+
# Call periodically to prevent memory growth
|
|
71
|
+
# @param max_age_hours [Integer] Remove entries older than this (default: 24)
|
|
72
|
+
def cleanup!(max_age_hours: 24)
|
|
73
|
+
cutoff_time = max_age_hours.hours.ago
|
|
74
|
+
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
@last_alert_times.delete_if { |_, time| time < cutoff_time }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def alert_key(error_type, platform)
|
|
83
|
+
"#{error_type}:#{platform}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|