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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +305 -703
  3. data/app/assets/stylesheets/rails_error_dashboard/_catppuccin_mocha.scss +107 -0
  4. data/app/assets/stylesheets/rails_error_dashboard/_components.scss +625 -0
  5. data/app/assets/stylesheets/rails_error_dashboard/_layout.scss +257 -0
  6. data/app/assets/stylesheets/rails_error_dashboard/_theme_variables.scss +203 -0
  7. data/app/assets/stylesheets/rails_error_dashboard/application.css +926 -15
  8. data/app/assets/stylesheets/rails_error_dashboard/application.css.map +7 -0
  9. data/app/assets/stylesheets/rails_error_dashboard/application.scss +61 -0
  10. data/app/controllers/rails_error_dashboard/application_controller.rb +18 -0
  11. data/app/controllers/rails_error_dashboard/errors_controller.rb +140 -4
  12. data/app/helpers/rails_error_dashboard/application_helper.rb +55 -0
  13. data/app/helpers/rails_error_dashboard/backtrace_helper.rb +91 -0
  14. data/app/helpers/rails_error_dashboard/overview_helper.rb +78 -0
  15. data/app/helpers/rails_error_dashboard/user_agent_helper.rb +118 -0
  16. data/app/jobs/rails_error_dashboard/application_job.rb +19 -0
  17. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +48 -0
  18. data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +263 -0
  19. data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +4 -8
  20. data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +2 -1
  21. data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +5 -5
  22. data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +10 -6
  23. data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +5 -6
  24. data/app/mailers/rails_error_dashboard/application_mailer.rb +1 -1
  25. data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +1 -1
  26. data/app/models/rails_error_dashboard/cascade_pattern.rb +74 -0
  27. data/app/models/rails_error_dashboard/error_baseline.rb +100 -0
  28. data/app/models/rails_error_dashboard/error_comment.rb +27 -0
  29. data/app/models/rails_error_dashboard/error_log.rb +471 -3
  30. data/app/models/rails_error_dashboard/error_occurrence.rb +49 -0
  31. data/app/views/layouts/rails_error_dashboard.html.erb +816 -178
  32. data/app/views/layouts/rails_error_dashboard_old_backup.html.erb +383 -0
  33. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +3 -10
  34. data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +1 -2
  35. data/app/views/rails_error_dashboard/errors/_error_row.html.erb +78 -0
  36. data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +209 -0
  37. data/app/views/rails_error_dashboard/errors/_stats.html.erb +34 -0
  38. data/app/views/rails_error_dashboard/errors/_timeline.html.erb +167 -0
  39. data/app/views/rails_error_dashboard/errors/analytics.html.erb +152 -56
  40. data/app/views/rails_error_dashboard/errors/correlation.html.erb +373 -0
  41. data/app/views/rails_error_dashboard/errors/index.html.erb +294 -138
  42. data/app/views/rails_error_dashboard/errors/overview.html.erb +253 -0
  43. data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +399 -0
  44. data/app/views/rails_error_dashboard/errors/show.html.erb +781 -65
  45. data/config/routes.rb +9 -0
  46. data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +66 -0
  47. data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +26 -0
  48. data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +12 -0
  49. data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +9 -0
  50. data/db/migrate/20251225100236_create_error_occurrences.rb +31 -0
  51. data/db/migrate/20251225101920_create_cascade_patterns.rb +33 -0
  52. data/db/migrate/20251225102500_create_error_baselines.rb +38 -0
  53. data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +27 -0
  54. data/db/migrate/20251226020100_create_error_comments.rb +18 -0
  55. data/lib/generators/rails_error_dashboard/install/install_generator.rb +276 -1
  56. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +272 -37
  57. data/lib/generators/rails_error_dashboard/solid_queue/solid_queue_generator.rb +36 -0
  58. data/lib/generators/rails_error_dashboard/solid_queue/templates/queue.yml +55 -0
  59. data/lib/rails_error_dashboard/commands/batch_delete_errors.rb +1 -1
  60. data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +2 -2
  61. data/lib/rails_error_dashboard/commands/log_error.rb +272 -7
  62. data/lib/rails_error_dashboard/commands/resolve_error.rb +16 -0
  63. data/lib/rails_error_dashboard/configuration.rb +90 -5
  64. data/lib/rails_error_dashboard/error_reporter.rb +15 -7
  65. data/lib/rails_error_dashboard/logger.rb +105 -0
  66. data/lib/rails_error_dashboard/middleware/error_catcher.rb +17 -10
  67. data/lib/rails_error_dashboard/plugin.rb +6 -3
  68. data/lib/rails_error_dashboard/plugin_registry.rb +2 -2
  69. data/lib/rails_error_dashboard/plugins/audit_log_plugin.rb +0 -1
  70. data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +3 -4
  71. data/lib/rails_error_dashboard/plugins/metrics_plugin.rb +1 -3
  72. data/lib/rails_error_dashboard/queries/analytics_stats.rb +44 -6
  73. data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
  74. data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +86 -0
  75. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +242 -2
  76. data/lib/rails_error_dashboard/queries/error_cascades.rb +74 -0
  77. data/lib/rails_error_dashboard/queries/error_correlation.rb +375 -0
  78. data/lib/rails_error_dashboard/queries/errors_list.rb +106 -10
  79. data/lib/rails_error_dashboard/queries/filter_options.rb +0 -1
  80. data/lib/rails_error_dashboard/queries/platform_comparison.rb +254 -0
  81. data/lib/rails_error_dashboard/queries/similar_errors.rb +93 -0
  82. data/lib/rails_error_dashboard/services/backtrace_parser.rb +113 -0
  83. data/lib/rails_error_dashboard/services/baseline_alert_throttler.rb +88 -0
  84. data/lib/rails_error_dashboard/services/baseline_calculator.rb +269 -0
  85. data/lib/rails_error_dashboard/services/cascade_detector.rb +95 -0
  86. data/lib/rails_error_dashboard/services/pattern_detector.rb +268 -0
  87. data/lib/rails_error_dashboard/services/similarity_calculator.rb +144 -0
  88. data/lib/rails_error_dashboard/value_objects/error_context.rb +27 -1
  89. data/lib/rails_error_dashboard/version.rb +1 -1
  90. data/lib/rails_error_dashboard.rb +57 -7
  91. metadata +69 -10
  92. data/app/models/rails_error_dashboard/application_record.rb +0 -5
  93. data/lib/rails_error_dashboard/queries/developer_insights.rb +0 -277
  94. data/lib/rails_error_dashboard/queries/errors_list_v2.rb +0 -149
  95. 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