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,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# Model: ErrorComment
|
|
5
|
+
# Represents a comment/note on an error for team collaboration
|
|
6
|
+
class ErrorComment < ErrorLogsRecord
|
|
7
|
+
self.table_name = "rails_error_dashboard_error_comments"
|
|
8
|
+
|
|
9
|
+
belongs_to :error_log, class_name: "RailsErrorDashboard::ErrorLog"
|
|
10
|
+
|
|
11
|
+
validates :author_name, presence: true, length: { maximum: 255 }
|
|
12
|
+
validates :body, presence: true, length: { maximum: 10_000 }
|
|
13
|
+
|
|
14
|
+
scope :recent_first, -> { order(created_at: :desc) }
|
|
15
|
+
scope :oldest_first, -> { order(created_at: :asc) }
|
|
16
|
+
|
|
17
|
+
# Get formatted timestamp for display
|
|
18
|
+
def formatted_time
|
|
19
|
+
created_at.strftime("%b %d, %Y at %I:%M %p")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Check if comment was created recently (within last hour)
|
|
23
|
+
def recent?
|
|
24
|
+
created_at > 1.hour.ago
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -12,27 +12,55 @@ module RailsErrorDashboard
|
|
|
12
12
|
belongs_to :user, optional: true
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
# Association for tracking individual error occurrences
|
|
16
|
+
has_many :error_occurrences, class_name: "RailsErrorDashboard::ErrorOccurrence", dependent: :destroy
|
|
17
|
+
|
|
18
|
+
# Association for comment threads (Phase 3: Workflow Integration)
|
|
19
|
+
has_many :comments, class_name: "RailsErrorDashboard::ErrorComment", foreign_key: :error_log_id, dependent: :destroy
|
|
20
|
+
|
|
21
|
+
# Cascade pattern associations
|
|
22
|
+
# parent_cascade_patterns: patterns where this error is the CHILD (errors that cause this error)
|
|
23
|
+
has_many :parent_cascade_patterns, class_name: "RailsErrorDashboard::CascadePattern",
|
|
24
|
+
foreign_key: :child_error_id, dependent: :destroy
|
|
25
|
+
# child_cascade_patterns: patterns where this error is the PARENT (errors this error causes)
|
|
26
|
+
has_many :child_cascade_patterns, class_name: "RailsErrorDashboard::CascadePattern",
|
|
27
|
+
foreign_key: :parent_error_id, dependent: :destroy
|
|
28
|
+
has_many :cascade_parents, through: :parent_cascade_patterns, source: :parent_error
|
|
29
|
+
has_many :cascade_children, through: :child_cascade_patterns, source: :child_error
|
|
30
|
+
|
|
15
31
|
validates :error_type, presence: true
|
|
16
32
|
validates :message, presence: true
|
|
17
|
-
validates :environment, presence: true
|
|
18
33
|
validates :occurred_at, presence: true
|
|
19
34
|
|
|
20
35
|
scope :unresolved, -> { where(resolved: false) }
|
|
21
36
|
scope :resolved, -> { where(resolved: true) }
|
|
22
37
|
scope :recent, -> { order(occurred_at: :desc) }
|
|
23
|
-
scope :by_environment, ->(env) { where(environment: env) }
|
|
24
38
|
scope :by_error_type, ->(type) { where(error_type: type) }
|
|
25
39
|
scope :by_type, ->(type) { where(error_type: type) }
|
|
26
40
|
scope :by_platform, ->(platform) { where(platform: platform) }
|
|
27
41
|
scope :last_24_hours, -> { where("occurred_at >= ?", 24.hours.ago) }
|
|
28
42
|
scope :last_week, -> { where("occurred_at >= ?", 1.week.ago) }
|
|
29
43
|
|
|
44
|
+
# Phase 3: Workflow Integration scopes
|
|
45
|
+
scope :active, -> { where("snoozed_until IS NULL OR snoozed_until < ?", Time.current) }
|
|
46
|
+
scope :snoozed, -> { where("snoozed_until IS NOT NULL AND snoozed_until >= ?", Time.current) }
|
|
47
|
+
scope :by_status, ->(status) { where(status: status) }
|
|
48
|
+
scope :assigned, -> { where.not(assigned_to: nil) }
|
|
49
|
+
scope :unassigned, -> { where(assigned_to: nil) }
|
|
50
|
+
scope :by_assignee, ->(name) { where(assigned_to: name) }
|
|
51
|
+
scope :by_priority, ->(level) { where(priority_level: level) }
|
|
52
|
+
|
|
30
53
|
# Set defaults and tracking
|
|
31
54
|
before_validation :set_defaults, on: :create
|
|
32
55
|
before_create :set_tracking_fields
|
|
56
|
+
before_create :set_release_info
|
|
57
|
+
after_create :calculate_priority_score
|
|
58
|
+
|
|
59
|
+
# Turbo Stream broadcasting
|
|
60
|
+
after_create_commit :broadcast_new_error
|
|
61
|
+
after_update_commit :broadcast_error_update
|
|
33
62
|
|
|
34
63
|
def set_defaults
|
|
35
|
-
self.environment ||= Rails.env.to_s
|
|
36
64
|
self.platform ||= "API"
|
|
37
65
|
end
|
|
38
66
|
|
|
@@ -43,6 +71,18 @@ module RailsErrorDashboard
|
|
|
43
71
|
self.occurrence_count ||= 1
|
|
44
72
|
end
|
|
45
73
|
|
|
74
|
+
def set_release_info
|
|
75
|
+
return unless respond_to?(:app_version=)
|
|
76
|
+
self.app_version ||= fetch_app_version
|
|
77
|
+
self.git_sha ||= fetch_git_sha
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def calculate_priority_score
|
|
81
|
+
return unless respond_to?(:priority_score=)
|
|
82
|
+
self.priority_score = compute_priority_score
|
|
83
|
+
save if persisted?
|
|
84
|
+
end
|
|
85
|
+
|
|
46
86
|
# Generate unique hash for error grouping
|
|
47
87
|
# Includes controller/action for better context-aware grouping
|
|
48
88
|
def generate_error_hash
|
|
@@ -74,7 +114,13 @@ module RailsErrorDashboard
|
|
|
74
114
|
end
|
|
75
115
|
|
|
76
116
|
# Get severity level
|
|
117
|
+
# Checks custom severity rules first, then falls back to default classification
|
|
77
118
|
def severity
|
|
119
|
+
# Check custom severity rules first
|
|
120
|
+
custom_severity = RailsErrorDashboard.configuration.custom_severity_rules[error_type]
|
|
121
|
+
return custom_severity.to_sym if custom_severity.present?
|
|
122
|
+
|
|
123
|
+
# Fall back to default classification
|
|
78
124
|
return :critical if CRITICAL_ERROR_TYPES.include?(error_type)
|
|
79
125
|
return :high if HIGH_SEVERITY_ERROR_TYPES.include?(error_type)
|
|
80
126
|
return :medium if MEDIUM_SEVERITY_ERROR_TYPES.include?(error_type)
|
|
@@ -145,6 +191,139 @@ module RailsErrorDashboard
|
|
|
145
191
|
Commands::ResolveError.call(id, resolution_data)
|
|
146
192
|
end
|
|
147
193
|
|
|
194
|
+
# Phase 3: Workflow Integration methods
|
|
195
|
+
|
|
196
|
+
# Assignment methods
|
|
197
|
+
def assign_to!(assignee_name)
|
|
198
|
+
update!(
|
|
199
|
+
assigned_to: assignee_name,
|
|
200
|
+
assigned_at: Time.current,
|
|
201
|
+
status: "in_progress" # Auto-transition to in_progress when assigned
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def unassign!
|
|
206
|
+
update!(
|
|
207
|
+
assigned_to: nil,
|
|
208
|
+
assigned_at: nil
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def assigned?
|
|
213
|
+
assigned_to.present?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Snooze methods
|
|
217
|
+
def snooze!(hours, reason: nil)
|
|
218
|
+
snooze_until = hours.hours.from_now
|
|
219
|
+
# Store snooze reason in comments if provided
|
|
220
|
+
if reason.present?
|
|
221
|
+
comments.create!(
|
|
222
|
+
author_name: assigned_to || "System",
|
|
223
|
+
body: "Snoozed for #{hours} hours: #{reason}"
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
update!(snoozed_until: snooze_until)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def unsnooze!
|
|
230
|
+
update!(snoozed_until: nil)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def snoozed?
|
|
234
|
+
snoozed_until.present? && snoozed_until >= Time.current
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Priority methods
|
|
238
|
+
def priority_label
|
|
239
|
+
case priority_level
|
|
240
|
+
when 3 then "Critical"
|
|
241
|
+
when 2 then "High"
|
|
242
|
+
when 1 then "Medium"
|
|
243
|
+
when 0 then "Low"
|
|
244
|
+
else "Unset"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def priority_color
|
|
249
|
+
case priority_level
|
|
250
|
+
when 3 then "danger" # Critical = red
|
|
251
|
+
when 2 then "warning" # High = orange
|
|
252
|
+
when 1 then "info" # Medium = blue
|
|
253
|
+
when 0 then "secondary" # Low = gray
|
|
254
|
+
else "light"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def calculate_priority
|
|
259
|
+
# Automatic priority calculation based on severity and frequency
|
|
260
|
+
severity_weight = case severity
|
|
261
|
+
when :critical then 3
|
|
262
|
+
when :high then 2
|
|
263
|
+
when :medium then 1
|
|
264
|
+
else 0
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
frequency_weight = if occurrence_count >= 100
|
|
268
|
+
3
|
|
269
|
+
elsif occurrence_count >= 10
|
|
270
|
+
2
|
|
271
|
+
elsif occurrence_count >= 5
|
|
272
|
+
1
|
|
273
|
+
else
|
|
274
|
+
0
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Take the higher of severity or frequency
|
|
278
|
+
[ severity_weight, frequency_weight ].max
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Status transition methods
|
|
282
|
+
def status_badge_color
|
|
283
|
+
case status
|
|
284
|
+
when "new" then "primary"
|
|
285
|
+
when "in_progress" then "info"
|
|
286
|
+
when "investigating" then "warning"
|
|
287
|
+
when "resolved" then "success"
|
|
288
|
+
when "wont_fix" then "secondary"
|
|
289
|
+
else "light"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def can_transition_to?(new_status)
|
|
294
|
+
# Define valid status transitions
|
|
295
|
+
valid_transitions = {
|
|
296
|
+
"new" => [ "in_progress", "investigating", "wont_fix" ],
|
|
297
|
+
"in_progress" => [ "investigating", "resolved", "new" ],
|
|
298
|
+
"investigating" => [ "resolved", "in_progress", "wont_fix" ],
|
|
299
|
+
"resolved" => [ "new" ], # Can reopen if error recurs
|
|
300
|
+
"wont_fix" => [ "new" ] # Can reopen
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
valid_transitions[status]&.include?(new_status) || false
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def update_status!(new_status, comment: nil)
|
|
307
|
+
return false unless can_transition_to?(new_status)
|
|
308
|
+
|
|
309
|
+
transaction do
|
|
310
|
+
update!(status: new_status)
|
|
311
|
+
|
|
312
|
+
# Auto-resolve if status is "resolved"
|
|
313
|
+
update!(resolved: true) if new_status == "resolved"
|
|
314
|
+
|
|
315
|
+
# Add comment about status change
|
|
316
|
+
if comment.present?
|
|
317
|
+
comments.create!(
|
|
318
|
+
author_name: assigned_to || "System",
|
|
319
|
+
body: "Status changed to #{new_status}: #{comment}"
|
|
320
|
+
)
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
true
|
|
325
|
+
end
|
|
326
|
+
|
|
148
327
|
# Get error statistics
|
|
149
328
|
def self.statistics(days = 7)
|
|
150
329
|
start_date = days.days.ago
|
|
@@ -171,6 +350,143 @@ module RailsErrorDashboard
|
|
|
171
350
|
.limit(limit)
|
|
172
351
|
end
|
|
173
352
|
|
|
353
|
+
# Extract backtrace frames for similarity comparison
|
|
354
|
+
def backtrace_frames
|
|
355
|
+
return [] if backtrace.blank?
|
|
356
|
+
|
|
357
|
+
# Handle different backtrace formats
|
|
358
|
+
lines = if backtrace.is_a?(Array)
|
|
359
|
+
backtrace
|
|
360
|
+
elsif backtrace.is_a?(String)
|
|
361
|
+
# Check if it's a serialized array (starts with "[")
|
|
362
|
+
if backtrace.strip.start_with?("[")
|
|
363
|
+
# Try to parse as JSON array
|
|
364
|
+
begin
|
|
365
|
+
JSON.parse(backtrace)
|
|
366
|
+
rescue JSON::ParserError
|
|
367
|
+
# Fall back to newline split
|
|
368
|
+
backtrace.split("\n")
|
|
369
|
+
end
|
|
370
|
+
else
|
|
371
|
+
backtrace.split("\n")
|
|
372
|
+
end
|
|
373
|
+
else
|
|
374
|
+
[]
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
lines.first(20).map do |line|
|
|
378
|
+
# Extract file path and method name, ignore line numbers
|
|
379
|
+
if line =~ %r{([^/]+\.rb):.*?in `(.+)'$}
|
|
380
|
+
"#{Regexp.last_match(1)}:#{Regexp.last_match(2)}"
|
|
381
|
+
elsif line =~ %r{([^/]+\.rb)}
|
|
382
|
+
Regexp.last_match(1)
|
|
383
|
+
end
|
|
384
|
+
end.compact.uniq
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Calculate backtrace signature for fast similarity matching
|
|
388
|
+
# Signature is a hash of the unique file paths in the backtrace
|
|
389
|
+
def calculate_backtrace_signature
|
|
390
|
+
frames = backtrace_frames
|
|
391
|
+
return nil if frames.empty?
|
|
392
|
+
|
|
393
|
+
# Create signature from sorted file paths (order-independent)
|
|
394
|
+
file_paths = frames.map { |frame| frame.split(":").first }.sort
|
|
395
|
+
Digest::SHA256.hexdigest(file_paths.join("|"))[0..15]
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Find similar errors using fuzzy matching
|
|
399
|
+
# @param threshold [Float] Minimum similarity score (0.0-1.0), default 0.6
|
|
400
|
+
# @param limit [Integer] Maximum results, default 10
|
|
401
|
+
# @return [Array<Hash>] Array of {error: ErrorLog, similarity: Float}
|
|
402
|
+
def similar_errors(threshold: 0.6, limit: 10)
|
|
403
|
+
return [] unless persisted?
|
|
404
|
+
return [] unless RailsErrorDashboard.configuration.enable_similar_errors
|
|
405
|
+
Queries::SimilarErrors.call(id, threshold: threshold, limit: limit)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Find errors that occur together in time
|
|
409
|
+
# @param window_minutes [Integer] Time window in minutes (default: 5)
|
|
410
|
+
# @param min_frequency [Integer] Minimum co-occurrence count (default: 2)
|
|
411
|
+
# @param limit [Integer] Maximum results (default: 10)
|
|
412
|
+
# @return [Array<Hash>] Array of {error: ErrorLog, frequency: Integer, avg_delay_seconds: Float}
|
|
413
|
+
def co_occurring_errors(window_minutes: 5, min_frequency: 2, limit: 10)
|
|
414
|
+
return [] unless persisted?
|
|
415
|
+
return [] unless RailsErrorDashboard.configuration.enable_co_occurring_errors
|
|
416
|
+
return [] unless defined?(Queries::CoOccurringErrors)
|
|
417
|
+
|
|
418
|
+
Queries::CoOccurringErrors.call(
|
|
419
|
+
error_log_id: id,
|
|
420
|
+
window_minutes: window_minutes,
|
|
421
|
+
min_frequency: min_frequency,
|
|
422
|
+
limit: limit
|
|
423
|
+
)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Find cascade patterns (what causes this error, what this error causes)
|
|
427
|
+
# @param min_probability [Float] Minimum cascade probability (0.0-1.0), default 0.5
|
|
428
|
+
# @return [Hash] {parents: Array, children: Array} of cascade patterns
|
|
429
|
+
def error_cascades(min_probability: 0.5)
|
|
430
|
+
return { parents: [], children: [] } unless persisted?
|
|
431
|
+
return { parents: [], children: [] } unless RailsErrorDashboard.configuration.enable_error_cascades
|
|
432
|
+
return { parents: [], children: [] } unless defined?(Queries::ErrorCascades)
|
|
433
|
+
|
|
434
|
+
Queries::ErrorCascades.call(error_id: id, min_probability: min_probability)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Get baseline statistics for this error type
|
|
438
|
+
# @return [Hash] {hourly: ErrorBaseline, daily: ErrorBaseline, weekly: ErrorBaseline}
|
|
439
|
+
def baselines
|
|
440
|
+
return {} unless RailsErrorDashboard.configuration.enable_baseline_alerts
|
|
441
|
+
return {} unless defined?(Queries::BaselineStats)
|
|
442
|
+
|
|
443
|
+
Queries::BaselineStats.new(error_type, platform).all_baselines
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Check if this error is anomalous compared to baseline
|
|
447
|
+
# @param sensitivity [Integer] Standard deviations threshold (default: 2)
|
|
448
|
+
# @return [Hash] Anomaly check result
|
|
449
|
+
def baseline_anomaly(sensitivity: 2)
|
|
450
|
+
return { anomaly: false, message: "Feature disabled" } unless RailsErrorDashboard.configuration.enable_baseline_alerts
|
|
451
|
+
return { anomaly: false, message: "No baseline available" } unless defined?(Queries::BaselineStats)
|
|
452
|
+
|
|
453
|
+
# Get count of this error type today
|
|
454
|
+
today_count = ErrorLog.where(
|
|
455
|
+
error_type: error_type,
|
|
456
|
+
platform: platform
|
|
457
|
+
).where("occurred_at >= ?", Time.current.beginning_of_day).count
|
|
458
|
+
|
|
459
|
+
Queries::BaselineStats.new(error_type, platform).check_anomaly(today_count, sensitivity: sensitivity)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Detect cyclical occurrence patterns (daily/weekly rhythms)
|
|
463
|
+
# @param days [Integer] Number of days to analyze (default: 30)
|
|
464
|
+
# @return [Hash] Pattern analysis result
|
|
465
|
+
def occurrence_pattern(days: 30)
|
|
466
|
+
return {} unless RailsErrorDashboard.configuration.enable_occurrence_patterns
|
|
467
|
+
return {} unless defined?(Services::PatternDetector)
|
|
468
|
+
|
|
469
|
+
Services::PatternDetector.analyze_cyclical_pattern(
|
|
470
|
+
error_type: error_type,
|
|
471
|
+
platform: platform,
|
|
472
|
+
days: days
|
|
473
|
+
)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Detect error bursts (many errors in short time)
|
|
477
|
+
# @param days [Integer] Number of days to analyze (default: 7)
|
|
478
|
+
# @return [Array<Hash>] Array of burst metadata
|
|
479
|
+
def error_bursts(days: 7)
|
|
480
|
+
return [] unless RailsErrorDashboard.configuration.enable_occurrence_patterns
|
|
481
|
+
return [] unless defined?(Services::PatternDetector)
|
|
482
|
+
|
|
483
|
+
Services::PatternDetector.detect_bursts(
|
|
484
|
+
error_type: error_type,
|
|
485
|
+
platform: platform,
|
|
486
|
+
days: days
|
|
487
|
+
)
|
|
488
|
+
end
|
|
489
|
+
|
|
174
490
|
private
|
|
175
491
|
|
|
176
492
|
# Override user association to use configured user model
|
|
@@ -181,5 +497,157 @@ module RailsErrorDashboard
|
|
|
181
497
|
end
|
|
182
498
|
super
|
|
183
499
|
end
|
|
500
|
+
|
|
501
|
+
# Turbo Stream broadcasting methods
|
|
502
|
+
def broadcast_new_error
|
|
503
|
+
return unless defined?(Turbo)
|
|
504
|
+
|
|
505
|
+
platforms = ErrorLog.distinct.pluck(:platform).compact
|
|
506
|
+
show_platform = platforms.size > 1
|
|
507
|
+
|
|
508
|
+
Turbo::StreamsChannel.broadcast_prepend_to(
|
|
509
|
+
"error_list",
|
|
510
|
+
target: "error_list",
|
|
511
|
+
partial: "rails_error_dashboard/errors/error_row",
|
|
512
|
+
locals: { error: self, show_platform: show_platform }
|
|
513
|
+
)
|
|
514
|
+
broadcast_replace_stats
|
|
515
|
+
rescue => e
|
|
516
|
+
Rails.logger.error("Failed to broadcast new error: #{e.message}")
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def broadcast_error_update
|
|
520
|
+
return unless defined?(Turbo)
|
|
521
|
+
|
|
522
|
+
platforms = ErrorLog.distinct.pluck(:platform).compact
|
|
523
|
+
show_platform = platforms.size > 1
|
|
524
|
+
|
|
525
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
526
|
+
"error_list",
|
|
527
|
+
target: "error_#{id}",
|
|
528
|
+
partial: "rails_error_dashboard/errors/error_row",
|
|
529
|
+
locals: { error: self, show_platform: show_platform }
|
|
530
|
+
)
|
|
531
|
+
broadcast_replace_stats
|
|
532
|
+
rescue => e
|
|
533
|
+
Rails.logger.error("Failed to broadcast error update: #{e.message}")
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
def broadcast_replace_stats
|
|
537
|
+
return unless defined?(Turbo)
|
|
538
|
+
|
|
539
|
+
stats = Queries::DashboardStats.call
|
|
540
|
+
Turbo::StreamsChannel.broadcast_replace_to(
|
|
541
|
+
"error_list",
|
|
542
|
+
target: "dashboard_stats",
|
|
543
|
+
partial: "rails_error_dashboard/errors/stats",
|
|
544
|
+
locals: { stats: stats }
|
|
545
|
+
)
|
|
546
|
+
rescue => e
|
|
547
|
+
Rails.logger.error("Failed to broadcast stats update: #{e.message}")
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Enhanced Metrics: Release/Version Tracking
|
|
551
|
+
def fetch_app_version
|
|
552
|
+
RailsErrorDashboard.configuration.app_version || ENV["APP_VERSION"] || detect_version_from_file
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def fetch_git_sha
|
|
556
|
+
RailsErrorDashboard.configuration.git_sha || ENV["GIT_SHA"] || detect_git_sha
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
def detect_version_from_file
|
|
560
|
+
version_file = Rails.root.join("VERSION")
|
|
561
|
+
return File.read(version_file).strip if File.exist?(version_file)
|
|
562
|
+
nil
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def detect_git_sha
|
|
566
|
+
return nil unless File.exist?(Rails.root.join(".git"))
|
|
567
|
+
`git rev-parse --short HEAD 2>/dev/null`.strip.presence
|
|
568
|
+
rescue => e
|
|
569
|
+
Rails.logger.debug("Could not detect git SHA: #{e.message}")
|
|
570
|
+
nil
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# Enhanced Metrics: Smart Priority Scoring
|
|
574
|
+
# Score: 0-100 based on severity, frequency, recency, and user impact
|
|
575
|
+
def compute_priority_score
|
|
576
|
+
severity_score = severity_to_score(severity)
|
|
577
|
+
frequency_score = frequency_to_score(occurrence_count)
|
|
578
|
+
recency_score = recency_to_score(occurred_at)
|
|
579
|
+
user_impact_score = user_impact_to_score
|
|
580
|
+
|
|
581
|
+
# Weighted average
|
|
582
|
+
(severity_score * 0.4 + frequency_score * 0.25 + recency_score * 0.2 + user_impact_score * 0.15).round
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def severity_to_score(sev)
|
|
586
|
+
case sev
|
|
587
|
+
when :critical then 100
|
|
588
|
+
when :high then 75
|
|
589
|
+
when :medium then 50
|
|
590
|
+
when :low then 25
|
|
591
|
+
else 10
|
|
592
|
+
end
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def frequency_to_score(count)
|
|
596
|
+
# Logarithmic scale: 1 occurrence = 10, 10 = 50, 100 = 90, 1000+ = 100
|
|
597
|
+
return 10 if count <= 1
|
|
598
|
+
return 100 if count >= 1000
|
|
599
|
+
|
|
600
|
+
(10 + (Math.log10(count) * 30)).clamp(10, 100).round
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def recency_to_score(time)
|
|
604
|
+
hours_ago = ((Time.current - time) / 1.hour).to_i
|
|
605
|
+
return 100 if hours_ago < 1 # Last hour = 100
|
|
606
|
+
return 80 if hours_ago < 24 # Last 24h = 80
|
|
607
|
+
return 50 if hours_ago < 168 # Last week = 50
|
|
608
|
+
return 20 if hours_ago < 720 # Last month = 20
|
|
609
|
+
10 # Older = 10
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def user_impact_to_score
|
|
613
|
+
return 0 unless user_id.present?
|
|
614
|
+
|
|
615
|
+
# Calculate what % of users are affected by this error type
|
|
616
|
+
total_users = unique_users_affected
|
|
617
|
+
return 0 if total_users.zero?
|
|
618
|
+
|
|
619
|
+
# Scale: 1 user = 10, 10 users = 50, 100+ users = 100
|
|
620
|
+
(10 + (Math.log10(total_users + 1) * 30)).clamp(0, 100).round
|
|
621
|
+
end
|
|
622
|
+
|
|
623
|
+
def unique_users_affected
|
|
624
|
+
ErrorLog.where(error_type: error_type, resolved: false)
|
|
625
|
+
.where.not(user_id: nil)
|
|
626
|
+
.distinct
|
|
627
|
+
.count(:user_id)
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
# Public method: Get user impact percentage
|
|
631
|
+
def user_impact_percentage
|
|
632
|
+
return 0 unless user_id.present?
|
|
633
|
+
|
|
634
|
+
affected_users = unique_users_affected
|
|
635
|
+
return 0 if affected_users.zero?
|
|
636
|
+
|
|
637
|
+
# Get total active users from config or estimate
|
|
638
|
+
total_users = RailsErrorDashboard.configuration.total_users_for_impact || estimate_total_users
|
|
639
|
+
return 0 if total_users.zero?
|
|
640
|
+
|
|
641
|
+
((affected_users.to_f / total_users) * 100).round(1)
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def estimate_total_users
|
|
645
|
+
# Estimate based on users who had any activity in last 30 days
|
|
646
|
+
if defined?(::User)
|
|
647
|
+
::User.where("created_at >= ?", 30.days.ago).count
|
|
648
|
+
else
|
|
649
|
+
100 # Default fallback
|
|
650
|
+
end
|
|
651
|
+
end
|
|
184
652
|
end
|
|
185
653
|
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
# Tracks individual occurrences of errors for co-occurrence analysis
|
|
5
|
+
#
|
|
6
|
+
# Each time an error is logged, we create an ErrorOccurrence record
|
|
7
|
+
# to track when it happened, who was affected, and what request caused it.
|
|
8
|
+
# This allows us to find errors that occur together in time windows.
|
|
9
|
+
class ErrorOccurrence < ErrorLogsRecord
|
|
10
|
+
self.table_name = "rails_error_dashboard_error_occurrences"
|
|
11
|
+
|
|
12
|
+
belongs_to :error_log, class_name: "RailsErrorDashboard::ErrorLog"
|
|
13
|
+
|
|
14
|
+
# Only define user association if User model exists
|
|
15
|
+
if defined?(::User)
|
|
16
|
+
belongs_to :user, optional: true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
validates :occurred_at, presence: true
|
|
20
|
+
validates :error_log_id, presence: true
|
|
21
|
+
|
|
22
|
+
scope :recent, -> { order(occurred_at: :desc) }
|
|
23
|
+
scope :in_time_window, ->(start_time, end_time) { where(occurred_at: start_time..end_time) }
|
|
24
|
+
scope :for_user, ->(user_id) { where(user_id: user_id) }
|
|
25
|
+
scope :for_request, ->(request_id) { where(request_id: request_id) }
|
|
26
|
+
scope :for_session, ->(session_id) { where(session_id: session_id) }
|
|
27
|
+
|
|
28
|
+
# Find occurrences within a time window around this occurrence
|
|
29
|
+
# @param window_minutes [Integer] Time window in minutes (default: 5)
|
|
30
|
+
# @return [ActiveRecord::Relation] Other occurrences in the time window
|
|
31
|
+
def nearby_occurrences(window_minutes: 5)
|
|
32
|
+
window = window_minutes.minutes
|
|
33
|
+
start_time = occurred_at - window
|
|
34
|
+
end_time = occurred_at + window
|
|
35
|
+
|
|
36
|
+
self.class
|
|
37
|
+
.in_time_window(start_time, end_time)
|
|
38
|
+
.where.not(id: id) # Exclude self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Find other error types that occurred near this occurrence
|
|
42
|
+
# @param window_minutes [Integer] Time window in minutes (default: 5)
|
|
43
|
+
# @return [ActiveRecord::Relation] ErrorLog records of co-occurring errors
|
|
44
|
+
def co_occurring_error_types(window_minutes: 5)
|
|
45
|
+
occurrence_ids = nearby_occurrences(window_minutes: window_minutes).pluck(:error_log_id)
|
|
46
|
+
ErrorLog.where(id: occurrence_ids).where.not(error_type: error_log.error_type).distinct
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|