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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +257 -700
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +18 -0
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +47 -4
  5. data/app/helpers/rails_error_dashboard/application_helper.rb +17 -0
  6. data/app/jobs/rails_error_dashboard/application_job.rb +19 -0
  7. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +48 -0
  8. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +263 -0
  9. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +4 -8
  10. data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +2 -1
  11. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +5 -5
  12. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +10 -6
  13. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +5 -6
  14. data/app/mailers/rails_error_dashboard/application_mailer.rb +1 -1
  15. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +1 -1
  16. data/app/models/rails_error_dashboard/cascade_pattern.rb +74 -0
  17. data/app/models/rails_error_dashboard/error_baseline.rb +100 -0
  18. data/app/models/rails_error_dashboard/error_log.rb +326 -3
  19. data/app/models/rails_error_dashboard/error_occurrence.rb +49 -0
  20. data/app/views/layouts/rails_error_dashboard.html.erb +150 -9
  21. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +3 -10
  22. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +1 -2
  23. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +76 -0
  24. data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +209 -0
  25. data/app/views/rails_error_dashboard/errors/_stats.html.erb +34 -0
  26. data/app/views/rails_error_dashboard/errors/analytics.html.erb +19 -39
  27. data/app/views/rails_error_dashboard/errors/correlation.html.erb +373 -0
  28. data/app/views/rails_error_dashboard/errors/index.html.erb +215 -138
  29. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +388 -0
  30. data/app/views/rails_error_dashboard/errors/show.html.erb +428 -11
  31. data/config/routes.rb +2 -0
  32. data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +66 -0
  33. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +26 -0
  34. data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +12 -0
  35. data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +9 -0
  36. data/db/migrate/20251225100236_create_error_occurrences.rb +31 -0
  37. data/db/migrate/20251225101920_create_cascade_patterns.rb +33 -0
  38. data/db/migrate/20251225102500_create_error_baselines.rb +38 -0
  39. data/lib/generators/rails_error_dashboard/install/install_generator.rb +270 -1
  40. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +251 -37
  41. data/lib/generators/rails_error_dashboard/solid_queue/solid_queue_generator.rb +36 -0
  42. data/lib/generators/rails_error_dashboard/solid_queue/templates/queue.yml +55 -0
  43. data/lib/rails_error_dashboard/commands/log_error.rb +234 -7
  44. data/lib/rails_error_dashboard/commands/resolve_error.rb +16 -0
  45. data/lib/rails_error_dashboard/configuration.rb +82 -5
  46. data/lib/rails_error_dashboard/error_reporter.rb +15 -7
  47. data/lib/rails_error_dashboard/middleware/error_catcher.rb +17 -10
  48. data/lib/rails_error_dashboard/plugin.rb +6 -3
  49. data/lib/rails_error_dashboard/plugins/audit_log_plugin.rb +0 -1
  50. data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +2 -3
  51. data/lib/rails_error_dashboard/plugins/metrics_plugin.rb +0 -2
  52. data/lib/rails_error_dashboard/queries/analytics_stats.rb +44 -6
  53. data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
  54. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +86 -0
  55. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +134 -2
  56. data/lib/rails_error_dashboard/queries/error_cascades.rb +74 -0
  57. data/lib/rails_error_dashboard/queries/error_correlation.rb +375 -0
  58. data/lib/rails_error_dashboard/queries/errors_list.rb +52 -11
  59. data/lib/rails_error_dashboard/queries/filter_options.rb +0 -1
  60. data/lib/rails_error_dashboard/queries/platform_comparison.rb +254 -0
  61. data/lib/rails_error_dashboard/queries/similar_errors.rb +93 -0
  62. data/lib/rails_error_dashboard/services/baseline_alert_throttler.rb +88 -0
  63. data/lib/rails_error_dashboard/services/baseline_calculator.rb +269 -0
  64. data/lib/rails_error_dashboard/services/cascade_detector.rb +95 -0
  65. data/lib/rails_error_dashboard/services/pattern_detector.rb +268 -0
  66. data/lib/rails_error_dashboard/services/similarity_calculator.rb +144 -0
  67. data/lib/rails_error_dashboard/value_objects/error_context.rb +27 -1
  68. data/lib/rails_error_dashboard/version.rb +1 -1
  69. data/lib/rails_error_dashboard.rb +55 -7
  70. metadata +52 -9
  71. data/app/models/rails_error_dashboard/application_record.rb +0 -5
  72. data/lib/rails_error_dashboard/queries/developer_insights.rb +0 -277
  73. data/lib/rails_error_dashboard/queries/errors_list_v2.rb +0 -149
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Tracks cascade patterns where one error causes another
5
+ #
6
+ # A cascade pattern represents a causal relationship between errors:
7
+ # Parent Error → Child Error
8
+ #
9
+ # For example: DatabaseConnectionError → NoMethodError
10
+ # When a database connection fails, subsequent code may try to call
11
+ # methods on nil objects, causing NoMethodError.
12
+ #
13
+ # @attr parent_error_id [Integer] The error that happens first (potential cause)
14
+ # @attr child_error_id [Integer] The error that happens after (potential effect)
15
+ # @attr frequency [Integer] How many times this cascade has been observed
16
+ # @attr avg_delay_seconds [Float] Average time between parent and child
17
+ # @attr cascade_probability [Float] Likelihood (0.0-1.0) that parent causes child
18
+ # @attr last_detected_at [DateTime] When this cascade was last observed
19
+ class CascadePattern < ErrorLogsRecord
20
+ self.table_name = "rails_error_dashboard_cascade_patterns"
21
+
22
+ belongs_to :parent_error, class_name: "RailsErrorDashboard::ErrorLog"
23
+ belongs_to :child_error, class_name: "RailsErrorDashboard::ErrorLog"
24
+
25
+ validates :parent_error_id, presence: true
26
+ validates :child_error_id, presence: true
27
+ validates :frequency, presence: true, numericality: { greater_than: 0 }
28
+ validate :parent_and_child_must_be_different
29
+
30
+ scope :high_confidence, -> { where("cascade_probability >= ?", 0.7) }
31
+ scope :frequent, ->(min_frequency = 3) { where("frequency >= ?", min_frequency) }
32
+ scope :recent, -> { order(last_detected_at: :desc) }
33
+ scope :by_parent, ->(error_id) { where(parent_error_id: error_id) }
34
+ scope :by_child, ->(error_id) { where(child_error_id: error_id) }
35
+
36
+ # Update cascade pattern stats
37
+ def increment_detection!(delay_seconds)
38
+ self.frequency += 1
39
+
40
+ # Update average delay using incremental formula
41
+ if avg_delay_seconds.present?
42
+ self.avg_delay_seconds = ((avg_delay_seconds * (frequency - 1)) + delay_seconds) / frequency
43
+ else
44
+ self.avg_delay_seconds = delay_seconds
45
+ end
46
+
47
+ self.last_detected_at = Time.current
48
+ save
49
+ end
50
+
51
+ # Calculate cascade probability based on frequency
52
+ # Probability = (times child follows parent) / (total parent occurrences)
53
+ def calculate_probability!
54
+ parent_occurrence_count = parent_error.error_occurrences.count
55
+ return if parent_occurrence_count.zero?
56
+
57
+ self.cascade_probability = (frequency.to_f / parent_occurrence_count).round(3)
58
+ save
59
+ end
60
+
61
+ # Check if this is a strong cascade pattern
62
+ def strong_cascade?
63
+ cascade_probability.present? && cascade_probability >= 0.7 && frequency >= 3
64
+ end
65
+
66
+ private
67
+
68
+ def parent_and_child_must_be_different
69
+ if parent_error_id == child_error_id
70
+ errors.add(:child_error_id, "cannot be the same as parent error")
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ # Stores baseline statistics for error types
5
+ #
6
+ # Baselines are calculated periodically (hourly, daily, weekly) to establish
7
+ # "normal" error behavior. This enables anomaly detection by comparing current
8
+ # error counts against historical baselines.
9
+ #
10
+ # @attr error_type [String] The type of error (e.g., "NoMethodError")
11
+ # @attr platform [String] Platform (iOS, Android, API, Web)
12
+ # @attr baseline_type [String] Time period type (hourly, daily, weekly)
13
+ # @attr period_start [DateTime] Start of the period this baseline covers
14
+ # @attr period_end [DateTime] End of the period this baseline covers
15
+ # @attr count [Integer] Total errors in this period
16
+ # @attr mean [Float] Average error count
17
+ # @attr std_dev [Float] Standard deviation
18
+ # @attr percentile_95 [Float] 95th percentile
19
+ # @attr percentile_99 [Float] 99th percentile
20
+ # @attr sample_size [Integer] Number of periods in the sample
21
+ class ErrorBaseline < ErrorLogsRecord
22
+ self.table_name = "rails_error_dashboard_error_baselines"
23
+
24
+ BASELINE_TYPES = %w[hourly daily weekly].freeze
25
+
26
+ validates :error_type, presence: true
27
+ validates :platform, presence: true
28
+ validates :baseline_type, presence: true, inclusion: { in: BASELINE_TYPES }
29
+ validates :period_start, presence: true
30
+ validates :period_end, presence: true
31
+ validates :count, presence: true, numericality: { greater_than_or_equal_to: 0 }
32
+ validates :sample_size, presence: true, numericality: { greater_than_or_equal_to: 0 }
33
+
34
+ validate :period_end_after_period_start
35
+
36
+ scope :for_error_type, ->(error_type) { where(error_type: error_type) }
37
+ scope :for_platform, ->(platform) { where(platform: platform) }
38
+ scope :hourly, -> { where(baseline_type: "hourly") }
39
+ scope :daily, -> { where(baseline_type: "daily") }
40
+ scope :weekly, -> { where(baseline_type: "weekly") }
41
+ scope :recent, -> { order(period_start: :desc) }
42
+
43
+ # Check if a given count is anomalous compared to this baseline
44
+ # @param current_count [Integer] Current error count to check
45
+ # @param sensitivity [Integer] Number of standard deviations (default: 2)
46
+ # @return [Symbol, nil] :elevated, :high, :critical, or nil if normal
47
+ def anomaly_level(current_count, sensitivity: 2)
48
+ return nil if mean.nil? || std_dev.nil?
49
+ return nil if current_count <= mean
50
+
51
+ std_devs_above = (current_count - mean) / std_dev
52
+
53
+ case std_devs_above
54
+ when sensitivity..(sensitivity + 1)
55
+ :elevated
56
+ when (sensitivity + 1)..(sensitivity + 2)
57
+ :high
58
+ when (sensitivity + 2)..Float::INFINITY
59
+ :critical
60
+ else
61
+ nil
62
+ end
63
+ end
64
+
65
+ # Check if current count is above baseline
66
+ # @param current_count [Integer] Current error count
67
+ # @param sensitivity [Integer] Number of standard deviations (default: 2)
68
+ # @return [Boolean] True if count exceeds baseline + (sensitivity * std_dev)
69
+ def exceeds_baseline?(current_count, sensitivity: 2)
70
+ return false if mean.nil? || std_dev.nil?
71
+ current_count > (mean + (sensitivity * std_dev))
72
+ end
73
+
74
+ # Get the threshold for anomaly detection
75
+ # @param sensitivity [Integer] Number of standard deviations (default: 2)
76
+ # @return [Float, nil] Threshold value or nil if stats not available
77
+ def threshold(sensitivity: 2)
78
+ return nil if mean.nil? || std_dev.nil?
79
+ mean + (sensitivity * std_dev)
80
+ end
81
+
82
+ # Calculate how many standard deviations above mean
83
+ # @param current_count [Integer] Current error count
84
+ # @return [Float, nil] Number of standard deviations or nil
85
+ def std_devs_above_mean(current_count)
86
+ return nil if mean.nil? || std_dev.nil? || std_dev.zero?
87
+ (current_count - mean) / std_dev
88
+ end
89
+
90
+ private
91
+
92
+ def period_end_after_period_start
93
+ return if period_start.nil? || period_end.nil?
94
+
95
+ if period_end <= period_start
96
+ errors.add(:period_end, "must be after period_start")
97
+ end
98
+ end
99
+ end
100
+ end
@@ -12,15 +12,26 @@ 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
+ # Cascade pattern associations
19
+ # parent_cascade_patterns: patterns where this error is the CHILD (errors that cause this error)
20
+ has_many :parent_cascade_patterns, class_name: "RailsErrorDashboard::CascadePattern",
21
+ foreign_key: :child_error_id, dependent: :destroy
22
+ # child_cascade_patterns: patterns where this error is the PARENT (errors this error causes)
23
+ has_many :child_cascade_patterns, class_name: "RailsErrorDashboard::CascadePattern",
24
+ foreign_key: :parent_error_id, dependent: :destroy
25
+ has_many :cascade_parents, through: :parent_cascade_patterns, source: :parent_error
26
+ has_many :cascade_children, through: :child_cascade_patterns, source: :child_error
27
+
15
28
  validates :error_type, presence: true
16
29
  validates :message, presence: true
17
- validates :environment, presence: true
18
30
  validates :occurred_at, presence: true
19
31
 
20
32
  scope :unresolved, -> { where(resolved: false) }
21
33
  scope :resolved, -> { where(resolved: true) }
22
34
  scope :recent, -> { order(occurred_at: :desc) }
23
- scope :by_environment, ->(env) { where(environment: env) }
24
35
  scope :by_error_type, ->(type) { where(error_type: type) }
25
36
  scope :by_type, ->(type) { where(error_type: type) }
26
37
  scope :by_platform, ->(platform) { where(platform: platform) }
@@ -30,9 +41,14 @@ module RailsErrorDashboard
30
41
  # Set defaults and tracking
31
42
  before_validation :set_defaults, on: :create
32
43
  before_create :set_tracking_fields
44
+ before_create :set_release_info
45
+ after_create :calculate_priority_score
46
+
47
+ # Turbo Stream broadcasting
48
+ after_create_commit :broadcast_new_error
49
+ after_update_commit :broadcast_error_update
33
50
 
34
51
  def set_defaults
35
- self.environment ||= Rails.env.to_s
36
52
  self.platform ||= "API"
37
53
  end
38
54
 
@@ -43,6 +59,18 @@ module RailsErrorDashboard
43
59
  self.occurrence_count ||= 1
44
60
  end
45
61
 
62
+ def set_release_info
63
+ return unless respond_to?(:app_version=)
64
+ self.app_version ||= fetch_app_version
65
+ self.git_sha ||= fetch_git_sha
66
+ end
67
+
68
+ def calculate_priority_score
69
+ return unless respond_to?(:priority_score=)
70
+ self.priority_score = compute_priority_score
71
+ save if persisted?
72
+ end
73
+
46
74
  # Generate unique hash for error grouping
47
75
  # Includes controller/action for better context-aware grouping
48
76
  def generate_error_hash
@@ -74,7 +102,13 @@ module RailsErrorDashboard
74
102
  end
75
103
 
76
104
  # Get severity level
105
+ # Checks custom severity rules first, then falls back to default classification
77
106
  def severity
107
+ # Check custom severity rules first
108
+ custom_severity = RailsErrorDashboard.configuration.custom_severity_rules[error_type]
109
+ return custom_severity.to_sym if custom_severity.present?
110
+
111
+ # Fall back to default classification
78
112
  return :critical if CRITICAL_ERROR_TYPES.include?(error_type)
79
113
  return :high if HIGH_SEVERITY_ERROR_TYPES.include?(error_type)
80
114
  return :medium if MEDIUM_SEVERITY_ERROR_TYPES.include?(error_type)
@@ -171,6 +205,143 @@ module RailsErrorDashboard
171
205
  .limit(limit)
172
206
  end
173
207
 
208
+ # Extract backtrace frames for similarity comparison
209
+ def backtrace_frames
210
+ return [] if backtrace.blank?
211
+
212
+ # Handle different backtrace formats
213
+ lines = if backtrace.is_a?(Array)
214
+ backtrace
215
+ elsif backtrace.is_a?(String)
216
+ # Check if it's a serialized array (starts with "[")
217
+ if backtrace.strip.start_with?("[")
218
+ # Try to parse as JSON array
219
+ begin
220
+ JSON.parse(backtrace)
221
+ rescue JSON::ParserError
222
+ # Fall back to newline split
223
+ backtrace.split("\n")
224
+ end
225
+ else
226
+ backtrace.split("\n")
227
+ end
228
+ else
229
+ []
230
+ end
231
+
232
+ lines.first(20).map do |line|
233
+ # Extract file path and method name, ignore line numbers
234
+ if line =~ %r{([^/]+\.rb):.*?in `(.+)'$}
235
+ "#{Regexp.last_match(1)}:#{Regexp.last_match(2)}"
236
+ elsif line =~ %r{([^/]+\.rb)}
237
+ Regexp.last_match(1)
238
+ end
239
+ end.compact.uniq
240
+ end
241
+
242
+ # Calculate backtrace signature for fast similarity matching
243
+ # Signature is a hash of the unique file paths in the backtrace
244
+ def calculate_backtrace_signature
245
+ frames = backtrace_frames
246
+ return nil if frames.empty?
247
+
248
+ # Create signature from sorted file paths (order-independent)
249
+ file_paths = frames.map { |frame| frame.split(":").first }.sort
250
+ Digest::SHA256.hexdigest(file_paths.join("|"))[0..15]
251
+ end
252
+
253
+ # Find similar errors using fuzzy matching
254
+ # @param threshold [Float] Minimum similarity score (0.0-1.0), default 0.6
255
+ # @param limit [Integer] Maximum results, default 10
256
+ # @return [Array<Hash>] Array of {error: ErrorLog, similarity: Float}
257
+ def similar_errors(threshold: 0.6, limit: 10)
258
+ return [] unless persisted?
259
+ return [] unless RailsErrorDashboard.configuration.enable_similar_errors
260
+ Queries::SimilarErrors.call(id, threshold: threshold, limit: limit)
261
+ end
262
+
263
+ # Find errors that occur together in time
264
+ # @param window_minutes [Integer] Time window in minutes (default: 5)
265
+ # @param min_frequency [Integer] Minimum co-occurrence count (default: 2)
266
+ # @param limit [Integer] Maximum results (default: 10)
267
+ # @return [Array<Hash>] Array of {error: ErrorLog, frequency: Integer, avg_delay_seconds: Float}
268
+ def co_occurring_errors(window_minutes: 5, min_frequency: 2, limit: 10)
269
+ return [] unless persisted?
270
+ return [] unless RailsErrorDashboard.configuration.enable_co_occurring_errors
271
+ return [] unless defined?(Queries::CoOccurringErrors)
272
+
273
+ Queries::CoOccurringErrors.call(
274
+ error_log_id: id,
275
+ window_minutes: window_minutes,
276
+ min_frequency: min_frequency,
277
+ limit: limit
278
+ )
279
+ end
280
+
281
+ # Find cascade patterns (what causes this error, what this error causes)
282
+ # @param min_probability [Float] Minimum cascade probability (0.0-1.0), default 0.5
283
+ # @return [Hash] {parents: Array, children: Array} of cascade patterns
284
+ def error_cascades(min_probability: 0.5)
285
+ return { parents: [], children: [] } unless persisted?
286
+ return { parents: [], children: [] } unless RailsErrorDashboard.configuration.enable_error_cascades
287
+ return { parents: [], children: [] } unless defined?(Queries::ErrorCascades)
288
+
289
+ Queries::ErrorCascades.call(error_id: id, min_probability: min_probability)
290
+ end
291
+
292
+ # Get baseline statistics for this error type
293
+ # @return [Hash] {hourly: ErrorBaseline, daily: ErrorBaseline, weekly: ErrorBaseline}
294
+ def baselines
295
+ return {} unless RailsErrorDashboard.configuration.enable_baseline_alerts
296
+ return {} unless defined?(Queries::BaselineStats)
297
+
298
+ Queries::BaselineStats.new(error_type, platform).all_baselines
299
+ end
300
+
301
+ # Check if this error is anomalous compared to baseline
302
+ # @param sensitivity [Integer] Standard deviations threshold (default: 2)
303
+ # @return [Hash] Anomaly check result
304
+ def baseline_anomaly(sensitivity: 2)
305
+ return { anomaly: false, message: "Feature disabled" } unless RailsErrorDashboard.configuration.enable_baseline_alerts
306
+ return { anomaly: false, message: "No baseline available" } unless defined?(Queries::BaselineStats)
307
+
308
+ # Get count of this error type today
309
+ today_count = ErrorLog.where(
310
+ error_type: error_type,
311
+ platform: platform
312
+ ).where("occurred_at >= ?", Time.current.beginning_of_day).count
313
+
314
+ Queries::BaselineStats.new(error_type, platform).check_anomaly(today_count, sensitivity: sensitivity)
315
+ end
316
+
317
+ # Detect cyclical occurrence patterns (daily/weekly rhythms)
318
+ # @param days [Integer] Number of days to analyze (default: 30)
319
+ # @return [Hash] Pattern analysis result
320
+ def occurrence_pattern(days: 30)
321
+ return {} unless RailsErrorDashboard.configuration.enable_occurrence_patterns
322
+ return {} unless defined?(Services::PatternDetector)
323
+
324
+ Services::PatternDetector.analyze_cyclical_pattern(
325
+ error_type: error_type,
326
+ platform: platform,
327
+ days: days
328
+ )
329
+ end
330
+
331
+ # Detect error bursts (many errors in short time)
332
+ # @param days [Integer] Number of days to analyze (default: 7)
333
+ # @return [Array<Hash>] Array of burst metadata
334
+ def error_bursts(days: 7)
335
+ return [] unless RailsErrorDashboard.configuration.enable_occurrence_patterns
336
+ return [] unless defined?(Services::PatternDetector)
337
+
338
+ Services::PatternDetector.detect_bursts(
339
+ error_type: error_type,
340
+ platform: platform,
341
+ days: days
342
+ )
343
+ end
344
+
174
345
  private
175
346
 
176
347
  # Override user association to use configured user model
@@ -181,5 +352,157 @@ module RailsErrorDashboard
181
352
  end
182
353
  super
183
354
  end
355
+
356
+ # Turbo Stream broadcasting methods
357
+ def broadcast_new_error
358
+ return unless defined?(Turbo)
359
+
360
+ platforms = ErrorLog.distinct.pluck(:platform).compact
361
+ show_platform = platforms.size > 1
362
+
363
+ Turbo::StreamsChannel.broadcast_prepend_to(
364
+ "error_list",
365
+ target: "error_list",
366
+ partial: "rails_error_dashboard/errors/error_row",
367
+ locals: { error: self, show_platform: show_platform }
368
+ )
369
+ broadcast_replace_stats
370
+ rescue => e
371
+ Rails.logger.error("Failed to broadcast new error: #{e.message}")
372
+ end
373
+
374
+ def broadcast_error_update
375
+ return unless defined?(Turbo)
376
+
377
+ platforms = ErrorLog.distinct.pluck(:platform).compact
378
+ show_platform = platforms.size > 1
379
+
380
+ Turbo::StreamsChannel.broadcast_replace_to(
381
+ "error_list",
382
+ target: "error_#{id}",
383
+ partial: "rails_error_dashboard/errors/error_row",
384
+ locals: { error: self, show_platform: show_platform }
385
+ )
386
+ broadcast_replace_stats
387
+ rescue => e
388
+ Rails.logger.error("Failed to broadcast error update: #{e.message}")
389
+ end
390
+
391
+ def broadcast_replace_stats
392
+ return unless defined?(Turbo)
393
+
394
+ stats = Queries::DashboardStats.call
395
+ Turbo::StreamsChannel.broadcast_replace_to(
396
+ "error_list",
397
+ target: "dashboard_stats",
398
+ partial: "rails_error_dashboard/errors/stats",
399
+ locals: { stats: stats }
400
+ )
401
+ rescue => e
402
+ Rails.logger.error("Failed to broadcast stats update: #{e.message}")
403
+ end
404
+
405
+ # Enhanced Metrics: Release/Version Tracking
406
+ def fetch_app_version
407
+ RailsErrorDashboard.configuration.app_version || ENV["APP_VERSION"] || detect_version_from_file
408
+ end
409
+
410
+ def fetch_git_sha
411
+ RailsErrorDashboard.configuration.git_sha || ENV["GIT_SHA"] || detect_git_sha
412
+ end
413
+
414
+ def detect_version_from_file
415
+ version_file = Rails.root.join("VERSION")
416
+ return File.read(version_file).strip if File.exist?(version_file)
417
+ nil
418
+ end
419
+
420
+ def detect_git_sha
421
+ return nil unless File.exist?(Rails.root.join(".git"))
422
+ `git rev-parse --short HEAD 2>/dev/null`.strip.presence
423
+ rescue => e
424
+ Rails.logger.debug("Could not detect git SHA: #{e.message}")
425
+ nil
426
+ end
427
+
428
+ # Enhanced Metrics: Smart Priority Scoring
429
+ # Score: 0-100 based on severity, frequency, recency, and user impact
430
+ def compute_priority_score
431
+ severity_score = severity_to_score(severity)
432
+ frequency_score = frequency_to_score(occurrence_count)
433
+ recency_score = recency_to_score(occurred_at)
434
+ user_impact_score = user_impact_to_score
435
+
436
+ # Weighted average
437
+ (severity_score * 0.4 + frequency_score * 0.25 + recency_score * 0.2 + user_impact_score * 0.15).round
438
+ end
439
+
440
+ def severity_to_score(sev)
441
+ case sev
442
+ when :critical then 100
443
+ when :high then 75
444
+ when :medium then 50
445
+ when :low then 25
446
+ else 10
447
+ end
448
+ end
449
+
450
+ def frequency_to_score(count)
451
+ # Logarithmic scale: 1 occurrence = 10, 10 = 50, 100 = 90, 1000+ = 100
452
+ return 10 if count <= 1
453
+ return 100 if count >= 1000
454
+
455
+ (10 + (Math.log10(count) * 30)).clamp(10, 100).round
456
+ end
457
+
458
+ def recency_to_score(time)
459
+ hours_ago = ((Time.current - time) / 1.hour).to_i
460
+ return 100 if hours_ago < 1 # Last hour = 100
461
+ return 80 if hours_ago < 24 # Last 24h = 80
462
+ return 50 if hours_ago < 168 # Last week = 50
463
+ return 20 if hours_ago < 720 # Last month = 20
464
+ 10 # Older = 10
465
+ end
466
+
467
+ def user_impact_to_score
468
+ return 0 unless user_id.present?
469
+
470
+ # Calculate what % of users are affected by this error type
471
+ total_users = unique_users_affected
472
+ return 0 if total_users.zero?
473
+
474
+ # Scale: 1 user = 10, 10 users = 50, 100+ users = 100
475
+ (10 + (Math.log10(total_users + 1) * 30)).clamp(0, 100).round
476
+ end
477
+
478
+ def unique_users_affected
479
+ ErrorLog.where(error_type: error_type, resolved: false)
480
+ .where.not(user_id: nil)
481
+ .distinct
482
+ .count(:user_id)
483
+ end
484
+
485
+ # Public method: Get user impact percentage
486
+ def user_impact_percentage
487
+ return 0 unless user_id.present?
488
+
489
+ affected_users = unique_users_affected
490
+ return 0 if affected_users.zero?
491
+
492
+ # Get total active users from config or estimate
493
+ total_users = RailsErrorDashboard.configuration.total_users_for_impact || estimate_total_users
494
+ return 0 if total_users.zero?
495
+
496
+ ((affected_users.to_f / total_users) * 100).round(1)
497
+ end
498
+
499
+ def estimate_total_users
500
+ # Estimate based on users who had any activity in last 30 days
501
+ if defined?(::User)
502
+ ::User.where("created_at >= ?", 30.days.ago).count
503
+ else
504
+ 100 # Default fallback
505
+ end
506
+ end
184
507
  end
185
508
  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