rails_error_dashboard 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +257 -700
- data/app/controllers/rails_error_dashboard/application_controller.rb +18 -0
- data/app/controllers/rails_error_dashboard/errors_controller.rb +47 -4
- data/app/helpers/rails_error_dashboard/application_helper.rb +17 -0
- data/app/jobs/rails_error_dashboard/application_job.rb +19 -0
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +48 -0
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +263 -0
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +4 -8
- data/app/jobs/rails_error_dashboard/email_error_notification_job.rb +2 -1
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +5 -5
- data/app/jobs/rails_error_dashboard/slack_error_notification_job.rb +10 -6
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +5 -6
- data/app/mailers/rails_error_dashboard/application_mailer.rb +1 -1
- data/app/mailers/rails_error_dashboard/error_notification_mailer.rb +1 -1
- data/app/models/rails_error_dashboard/cascade_pattern.rb +74 -0
- data/app/models/rails_error_dashboard/error_baseline.rb +100 -0
- data/app/models/rails_error_dashboard/error_log.rb +326 -3
- data/app/models/rails_error_dashboard/error_occurrence.rb +49 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +150 -9
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.html.erb +3 -10
- data/app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb +1 -2
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +76 -0
- data/app/views/rails_error_dashboard/errors/_pattern_insights.html.erb +209 -0
- data/app/views/rails_error_dashboard/errors/_stats.html.erb +34 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +19 -39
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +373 -0
- data/app/views/rails_error_dashboard/errors/index.html.erb +215 -138
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +388 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +428 -11
- data/config/routes.rb +2 -0
- data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +66 -0
- data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +26 -0
- data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +12 -0
- data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +9 -0
- data/db/migrate/20251225100236_create_error_occurrences.rb +31 -0
- data/db/migrate/20251225101920_create_cascade_patterns.rb +33 -0
- data/db/migrate/20251225102500_create_error_baselines.rb +38 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +270 -1
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +251 -37
- data/lib/generators/rails_error_dashboard/solid_queue/solid_queue_generator.rb +36 -0
- data/lib/generators/rails_error_dashboard/solid_queue/templates/queue.yml +55 -0
- data/lib/rails_error_dashboard/commands/log_error.rb +234 -7
- data/lib/rails_error_dashboard/commands/resolve_error.rb +16 -0
- data/lib/rails_error_dashboard/configuration.rb +82 -5
- data/lib/rails_error_dashboard/error_reporter.rb +15 -7
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +17 -10
- data/lib/rails_error_dashboard/plugin.rb +6 -3
- data/lib/rails_error_dashboard/plugins/audit_log_plugin.rb +0 -1
- data/lib/rails_error_dashboard/plugins/jira_integration_plugin.rb +2 -3
- data/lib/rails_error_dashboard/plugins/metrics_plugin.rb +0 -2
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +44 -6
- data/lib/rails_error_dashboard/queries/baseline_stats.rb +107 -0
- data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +86 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +134 -2
- data/lib/rails_error_dashboard/queries/error_cascades.rb +74 -0
- data/lib/rails_error_dashboard/queries/error_correlation.rb +375 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +52 -11
- data/lib/rails_error_dashboard/queries/filter_options.rb +0 -1
- data/lib/rails_error_dashboard/queries/platform_comparison.rb +254 -0
- data/lib/rails_error_dashboard/queries/similar_errors.rb +93 -0
- data/lib/rails_error_dashboard/services/baseline_alert_throttler.rb +88 -0
- data/lib/rails_error_dashboard/services/baseline_calculator.rb +269 -0
- data/lib/rails_error_dashboard/services/cascade_detector.rb +95 -0
- data/lib/rails_error_dashboard/services/pattern_detector.rb +268 -0
- data/lib/rails_error_dashboard/services/similarity_calculator.rb +144 -0
- data/lib/rails_error_dashboard/value_objects/error_context.rb +27 -1
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +55 -7
- metadata +52 -9
- data/app/models/rails_error_dashboard/application_record.rb +0 -5
- data/lib/rails_error_dashboard/queries/developer_insights.rb +0 -277
- data/lib/rails_error_dashboard/queries/errors_list_v2.rb +0 -149
|
@@ -0,0 +1,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
|