rails_error_dashboard 0.1.37 → 0.2.0
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 +20 -4
- data/app/controllers/rails_error_dashboard/application_controller.rb +2 -5
- data/app/controllers/rails_error_dashboard/errors_controller.rb +2 -3
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
- data/app/jobs/rails_error_dashboard/baseline_alert_job.rb +19 -15
- data/app/jobs/rails_error_dashboard/discord_error_notification_job.rb +19 -9
- data/app/jobs/rails_error_dashboard/pagerduty_error_notification_job.rb +37 -11
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +44 -0
- data/app/jobs/rails_error_dashboard/webhook_error_notification_job.rb +38 -16
- data/app/models/rails_error_dashboard/error_log.rb +10 -0
- data/app/models/rails_error_dashboard/error_logs_record.rb +11 -6
- data/app/views/layouts/rails_error_dashboard.html.erb +16 -0
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +3 -0
- data/app/views/rails_error_dashboard/errors/_stats.html.erb +12 -4
- data/app/views/rails_error_dashboard/errors/index.html.erb +9 -7
- data/app/views/rails_error_dashboard/errors/show.html.erb +138 -7
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +36 -0
- data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +1 -1
- data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +1 -1
- data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +1 -1
- data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +1 -1
- data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +1 -1
- data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +1 -1
- data/db/migrate/20251225100236_create_error_occurrences.rb +1 -1
- data/db/migrate/20251225101920_create_cascade_patterns.rb +1 -1
- data/db/migrate/20251225102500_create_error_baselines.rb +1 -1
- data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +1 -1
- data/db/migrate/20251226020100_create_error_comments.rb +1 -1
- data/db/migrate/20251230075315_cleanup_orphaned_migrations.rb +1 -1
- data/db/migrate/20260220000001_add_exception_cause_to_error_logs.rb +9 -0
- data/db/migrate/20260220000002_add_enriched_context_to_error_logs.rb +12 -0
- data/db/migrate/20260220000003_add_time_series_indexes_to_error_logs.rb +67 -0
- data/db/migrate/20260221000001_add_environment_info_to_error_logs.rb +9 -0
- data/db/migrate/20260221000002_add_reopened_at_to_error_logs.rb +9 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +145 -24
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +12 -8
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +58 -10
- data/lib/rails_error_dashboard/commands/log_error.rb +109 -10
- data/lib/rails_error_dashboard/configuration.rb +52 -0
- data/lib/rails_error_dashboard/manual_error_reporter.rb +12 -0
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +3 -0
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +8 -0
- data/lib/rails_error_dashboard/queries/errors_list.rb +8 -0
- data/lib/rails_error_dashboard/services/backtrace_parser.rb +31 -0
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +31 -1
- data/lib/rails_error_dashboard/services/cause_chain_extractor.rb +62 -0
- data/lib/rails_error_dashboard/services/environment_snapshot.rb +85 -0
- data/lib/rails_error_dashboard/services/error_hash_generator.rb +50 -2
- data/lib/rails_error_dashboard/services/notification_throttler.rb +109 -0
- data/lib/rails_error_dashboard/services/platform_detector.rb +36 -11
- data/lib/rails_error_dashboard/services/sensitive_data_filter.rb +176 -0
- data/lib/rails_error_dashboard/value_objects/error_context.rb +81 -4
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +11 -6
- data/lib/tasks/error_dashboard.rake +158 -2
- metadata +14 -60
|
@@ -24,6 +24,7 @@ module RailsErrorDashboard
|
|
|
24
24
|
total_month: base_scope.where("occurred_at >= ?", 30.days.ago).count,
|
|
25
25
|
unresolved: base_scope.unresolved.count,
|
|
26
26
|
resolved: base_scope.resolved.count,
|
|
27
|
+
reopened: reopened_count,
|
|
27
28
|
by_platform: base_scope.group(:platform).count,
|
|
28
29
|
top_errors: top_errors,
|
|
29
30
|
# Trend visualizations
|
|
@@ -55,6 +56,7 @@ module RailsErrorDashboard
|
|
|
55
56
|
total_month: 0,
|
|
56
57
|
unresolved: 0,
|
|
57
58
|
resolved: 0,
|
|
59
|
+
reopened: 0,
|
|
58
60
|
by_platform: {},
|
|
59
61
|
top_errors: {},
|
|
60
62
|
errors_trend_7d: {},
|
|
@@ -93,6 +95,12 @@ module RailsErrorDashboard
|
|
|
93
95
|
scope
|
|
94
96
|
end
|
|
95
97
|
|
|
98
|
+
def reopened_count
|
|
99
|
+
return 0 unless ErrorLog.column_names.include?("reopened_at")
|
|
100
|
+
|
|
101
|
+
base_scope.where.not(reopened_at: nil).count
|
|
102
|
+
end
|
|
103
|
+
|
|
96
104
|
def top_errors
|
|
97
105
|
base_scope.where("occurred_at >= ?", 7.days.ago)
|
|
98
106
|
.group(:error_type)
|
|
@@ -39,6 +39,7 @@ module RailsErrorDashboard
|
|
|
39
39
|
query = filter_by_assignment(query)
|
|
40
40
|
query = filter_by_priority(query)
|
|
41
41
|
query = filter_by_snoozed(query)
|
|
42
|
+
query = filter_by_reopened(query)
|
|
42
43
|
query
|
|
43
44
|
end
|
|
44
45
|
|
|
@@ -195,6 +196,13 @@ module RailsErrorDashboard
|
|
|
195
196
|
end
|
|
196
197
|
end
|
|
197
198
|
|
|
199
|
+
def filter_by_reopened(query)
|
|
200
|
+
return query unless @filters[:reopened] == "true"
|
|
201
|
+
return query unless ErrorLog.column_names.include?("reopened_at")
|
|
202
|
+
|
|
203
|
+
query.where.not(reopened_at: nil)
|
|
204
|
+
end
|
|
205
|
+
|
|
198
206
|
def filter_by_timeframe(query)
|
|
199
207
|
return query unless @filters[:timeframe].present?
|
|
200
208
|
|
|
@@ -14,6 +14,19 @@ module RailsErrorDashboard
|
|
|
14
14
|
new(backtrace_string).parse
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Convert Thread::Backtrace::Location objects to frame hashes
|
|
18
|
+
# Uses structured data directly — no regex needed.
|
|
19
|
+
# @param locations [Array<Thread::Backtrace::Location>, nil] Backtrace locations
|
|
20
|
+
# @return [Array<Hash>] Parsed frames with same structure as .parse
|
|
21
|
+
def self.from_locations(locations)
|
|
22
|
+
return [] if locations.nil? || locations.empty?
|
|
23
|
+
|
|
24
|
+
new(nil).send(:convert_locations, locations)
|
|
25
|
+
rescue => e
|
|
26
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BacktraceParser.from_locations failed: #{e.message}")
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
|
|
17
30
|
def initialize(backtrace_string)
|
|
18
31
|
@backtrace_string = backtrace_string
|
|
19
32
|
end
|
|
@@ -29,6 +42,24 @@ module RailsErrorDashboard
|
|
|
29
42
|
|
|
30
43
|
private
|
|
31
44
|
|
|
45
|
+
def convert_locations(locations)
|
|
46
|
+
locations.map.with_index do |loc, index|
|
|
47
|
+
file_path = loc.absolute_path || loc.path
|
|
48
|
+
line_number = loc.lineno
|
|
49
|
+
method_name = loc.label || "(unknown)"
|
|
50
|
+
|
|
51
|
+
{
|
|
52
|
+
index: index,
|
|
53
|
+
file_path: file_path,
|
|
54
|
+
line_number: line_number,
|
|
55
|
+
method_name: method_name,
|
|
56
|
+
category: categorize_frame(file_path),
|
|
57
|
+
full_line: loc.to_s,
|
|
58
|
+
short_path: shorten_path(file_path)
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
32
63
|
def parse_frame(line, index)
|
|
33
64
|
match = line.match(FRAME_PATTERN)
|
|
34
65
|
return nil unless match
|
|
@@ -29,8 +29,14 @@ module RailsErrorDashboard
|
|
|
29
29
|
# Extracts file paths and method names, ignoring line numbers,
|
|
30
30
|
# then produces an order-independent SHA256 digest.
|
|
31
31
|
# @param backtrace [String, Array<String>, nil] The backtrace
|
|
32
|
+
# @param locations [Array<Thread::Backtrace::Location>, nil] Optional structured locations
|
|
32
33
|
# @return [String, nil] 16-character hex signature
|
|
33
|
-
def self.calculate_signature(backtrace)
|
|
34
|
+
def self.calculate_signature(backtrace, locations: nil)
|
|
35
|
+
# Try structured locations first (more reliable, no regex)
|
|
36
|
+
if locations && !locations.empty?
|
|
37
|
+
return signature_from_locations(locations)
|
|
38
|
+
end
|
|
39
|
+
|
|
34
40
|
return nil if backtrace.blank?
|
|
35
41
|
|
|
36
42
|
lines = backtrace.is_a?(String) ? backtrace.split("\n") : backtrace
|
|
@@ -47,6 +53,30 @@ module RailsErrorDashboard
|
|
|
47
53
|
file_paths = frames.map { |frame| frame.split(":").first }.sort
|
|
48
54
|
Digest::SHA256.hexdigest(file_paths.join("|"))[0..15]
|
|
49
55
|
end
|
|
56
|
+
|
|
57
|
+
# Calculate signature directly from Location objects
|
|
58
|
+
# @param locations [Array<Thread::Backtrace::Location>] Backtrace locations
|
|
59
|
+
# @return [String, nil] 16-character hex signature
|
|
60
|
+
def self.signature_from_locations(locations)
|
|
61
|
+
frames = locations.first(20).map do |loc|
|
|
62
|
+
path = loc.absolute_path || loc.path
|
|
63
|
+
next nil unless path&.end_with?(".rb")
|
|
64
|
+
file_name = File.basename(path)
|
|
65
|
+
method_name = loc.label
|
|
66
|
+
method_name ? "#{file_name}:#{method_name}" : file_name
|
|
67
|
+
end.compact.uniq
|
|
68
|
+
|
|
69
|
+
return nil if frames.empty?
|
|
70
|
+
|
|
71
|
+
file_paths = frames.map { |frame| frame.split(":").first }.sort
|
|
72
|
+
Digest::SHA256.hexdigest(file_paths.join("|"))[0..15]
|
|
73
|
+
rescue => e
|
|
74
|
+
RailsErrorDashboard::Logger.debug(
|
|
75
|
+
"[RailsErrorDashboard] signature_from_locations failed: #{e.message}"
|
|
76
|
+
)
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
private_class_method :signature_from_locations
|
|
50
80
|
end
|
|
51
81
|
end
|
|
52
82
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure algorithm: Extract the exception cause chain from an exception.
|
|
6
|
+
#
|
|
7
|
+
# Ruby exceptions can have a `cause` (set automatically by `raise` inside a `rescue`).
|
|
8
|
+
# This service walks the chain recursively with a depth limit to prevent
|
|
9
|
+
# infinite loops from circular cause references.
|
|
10
|
+
#
|
|
11
|
+
# Returns a JSON string of cause entries, or nil if no cause exists.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# CauseChainExtractor.call(exception)
|
|
15
|
+
# # => '[{"class_name":"OriginalError","message":"connection refused","backtrace":["app/models/user.rb:10"]}]'
|
|
16
|
+
class CauseChainExtractor
|
|
17
|
+
MAX_DEPTH = 5
|
|
18
|
+
MAX_MESSAGE_LENGTH = 1000
|
|
19
|
+
MAX_BACKTRACE_LINES = 20
|
|
20
|
+
|
|
21
|
+
# @param exception [Exception] The exception to extract cause chain from
|
|
22
|
+
# @return [String, nil] JSON string of cause chain, or nil if no cause
|
|
23
|
+
def self.call(exception)
|
|
24
|
+
return nil unless exception.respond_to?(:cause) && exception.cause
|
|
25
|
+
|
|
26
|
+
chain = []
|
|
27
|
+
current = exception.cause
|
|
28
|
+
seen = Set.new
|
|
29
|
+
depth = 0
|
|
30
|
+
|
|
31
|
+
while current && depth < MAX_DEPTH
|
|
32
|
+
# Guard against circular cause references
|
|
33
|
+
break if seen.include?(current.object_id)
|
|
34
|
+
seen.add(current.object_id)
|
|
35
|
+
|
|
36
|
+
chain << {
|
|
37
|
+
class_name: current.class.name,
|
|
38
|
+
message: current.message&.to_s&.slice(0, MAX_MESSAGE_LENGTH),
|
|
39
|
+
backtrace: truncate_backtrace(current.backtrace)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
current = current.respond_to?(:cause) ? current.cause : nil
|
|
43
|
+
depth += 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
chain.empty? ? nil : chain.to_json
|
|
47
|
+
rescue => e
|
|
48
|
+
# SAFETY: Never let cause chain extraction break error logging
|
|
49
|
+
RailsErrorDashboard::Logger.debug(
|
|
50
|
+
"[RailsErrorDashboard] CauseChainExtractor failed: #{e.class} - #{e.message}"
|
|
51
|
+
)
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.truncate_backtrace(backtrace)
|
|
56
|
+
return nil unless backtrace.is_a?(Array)
|
|
57
|
+
backtrace.first(MAX_BACKTRACE_LINES)
|
|
58
|
+
end
|
|
59
|
+
private_class_method :truncate_backtrace
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure algorithm: Capture runtime environment info at boot time
|
|
6
|
+
#
|
|
7
|
+
# Snapshots Ruby version, Rails version, loaded gem versions, web server,
|
|
8
|
+
# and database adapter. Memoized — computed once per process lifetime.
|
|
9
|
+
# Stored as JSON on each error so historical errors show the environment
|
|
10
|
+
# that was running when they occurred (not the current environment).
|
|
11
|
+
class EnvironmentSnapshot
|
|
12
|
+
TRACKED_GEMS = %w[
|
|
13
|
+
activerecord actionpack sidekiq solid_queue puma unicorn
|
|
14
|
+
passenger redis pg mysql2 sqlite3 good_job
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
# Return cached environment snapshot (frozen hash)
|
|
18
|
+
# @return [Hash] Environment info
|
|
19
|
+
def self.snapshot
|
|
20
|
+
@cached_snapshot ||= new.capture.freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Clear cached snapshot (for testing)
|
|
24
|
+
def self.reset!
|
|
25
|
+
@cached_snapshot = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Capture current environment info
|
|
29
|
+
# @return [Hash] Environment snapshot
|
|
30
|
+
def capture
|
|
31
|
+
{
|
|
32
|
+
ruby_version: RUBY_VERSION,
|
|
33
|
+
ruby_engine: RUBY_ENGINE,
|
|
34
|
+
ruby_platform: RUBY_PLATFORM,
|
|
35
|
+
rails_version: rails_version,
|
|
36
|
+
rails_env: rails_env,
|
|
37
|
+
gem_versions: detect_gem_versions,
|
|
38
|
+
server: detect_server,
|
|
39
|
+
database_adapter: detect_database_adapter
|
|
40
|
+
}
|
|
41
|
+
rescue => e
|
|
42
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] EnvironmentSnapshot.capture failed: #{e.message}")
|
|
43
|
+
{ ruby_version: RUBY_VERSION, ruby_engine: RUBY_ENGINE }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def rails_version
|
|
49
|
+
Rails.version
|
|
50
|
+
rescue
|
|
51
|
+
"unknown"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def rails_env
|
|
55
|
+
Rails.env.to_s
|
|
56
|
+
rescue
|
|
57
|
+
"unknown"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def detect_gem_versions
|
|
61
|
+
TRACKED_GEMS.each_with_object({}) do |gem_name, hash|
|
|
62
|
+
spec = Gem.loaded_specs[gem_name]
|
|
63
|
+
hash[gem_name] = spec.version.to_s if spec
|
|
64
|
+
end
|
|
65
|
+
rescue
|
|
66
|
+
{}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def detect_server
|
|
70
|
+
return "puma" if defined?(Puma)
|
|
71
|
+
return "unicorn" if defined?(Unicorn)
|
|
72
|
+
return "passenger" if defined?(PhusionPassenger)
|
|
73
|
+
"unknown"
|
|
74
|
+
rescue
|
|
75
|
+
"unknown"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def detect_database_adapter
|
|
79
|
+
ActiveRecord::Base.connection_db_config.adapter
|
|
80
|
+
rescue
|
|
81
|
+
"unknown"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -20,10 +20,15 @@ module RailsErrorDashboard
|
|
|
20
20
|
# @param controller_name [String, nil] Controller context
|
|
21
21
|
# @param action_name [String, nil] Action context
|
|
22
22
|
# @param application_id [Integer, nil] Application for per-app deduplication
|
|
23
|
+
# @param context [Hash] Full error context (passed to custom fingerprint lambda)
|
|
23
24
|
# @return [String] 16-character hex hash
|
|
24
|
-
def self.call(exception, controller_name: nil, action_name: nil, application_id: nil)
|
|
25
|
+
def self.call(exception, controller_name: nil, action_name: nil, application_id: nil, context: {})
|
|
26
|
+
# Check for custom fingerprint lambda
|
|
27
|
+
custom = try_custom_fingerprint(exception, context)
|
|
28
|
+
return custom if custom
|
|
29
|
+
|
|
25
30
|
normalized_message = normalize_message(exception.message)
|
|
26
|
-
file_path = extract_app_frame(exception.backtrace)
|
|
31
|
+
file_path = extract_app_frame_from_locations(exception) || extract_app_frame(exception.backtrace)
|
|
27
32
|
|
|
28
33
|
digest_input = [
|
|
29
34
|
exception.class.name,
|
|
@@ -74,6 +79,27 @@ module RailsErrorDashboard
|
|
|
74
79
|
&.gsub(/'[^']*'/, "''") # Replace single-quoted strings
|
|
75
80
|
end
|
|
76
81
|
|
|
82
|
+
# Extract first meaningful app code frame using backtrace_locations
|
|
83
|
+
# More reliable than string parsing — uses Location#absolute_path directly.
|
|
84
|
+
# @param exception [Exception] The exception with backtrace_locations
|
|
85
|
+
# @return [String, nil] File path of first app code frame, or nil
|
|
86
|
+
def self.extract_app_frame_from_locations(exception)
|
|
87
|
+
locations = exception.backtrace_locations
|
|
88
|
+
return nil if locations.nil? || locations.empty?
|
|
89
|
+
|
|
90
|
+
first_app_location = locations.find { |loc|
|
|
91
|
+
path = loc.absolute_path || loc.path
|
|
92
|
+
!path&.include?("/gems/")
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
first_app_location && (first_app_location.absolute_path || first_app_location.path)
|
|
96
|
+
rescue => e
|
|
97
|
+
RailsErrorDashboard::Logger.debug(
|
|
98
|
+
"[RailsErrorDashboard] extract_app_frame_from_locations failed: #{e.message}"
|
|
99
|
+
)
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
77
103
|
# Extract first meaningful app code frame from backtrace
|
|
78
104
|
# @param backtrace [Array<String>, nil] Exception backtrace
|
|
79
105
|
# @return [String, nil] File path of first app code frame
|
|
@@ -86,6 +112,28 @@ module RailsErrorDashboard
|
|
|
86
112
|
|
|
87
113
|
first_app_frame&.split(":")&.first
|
|
88
114
|
end
|
|
115
|
+
|
|
116
|
+
# Try custom fingerprint lambda if configured
|
|
117
|
+
# Returns 16-char hex hash from custom key, or nil to fall back to default
|
|
118
|
+
# @param exception [Exception] The exception
|
|
119
|
+
# @param context [Hash] Error context
|
|
120
|
+
# @return [String, nil] 16-character hex hash or nil
|
|
121
|
+
def self.try_custom_fingerprint(exception, context)
|
|
122
|
+
fingerprint_fn = RailsErrorDashboard.configuration.custom_fingerprint
|
|
123
|
+
return nil unless fingerprint_fn
|
|
124
|
+
|
|
125
|
+
result = fingerprint_fn.call(exception, context)
|
|
126
|
+
return nil unless result.is_a?(String) && !result.empty?
|
|
127
|
+
|
|
128
|
+
Digest::SHA256.hexdigest(result)[0..15]
|
|
129
|
+
rescue => e
|
|
130
|
+
RailsErrorDashboard::Logger.error(
|
|
131
|
+
"[RailsErrorDashboard] Custom fingerprint lambda failed: #{e.class} - #{e.message}. " \
|
|
132
|
+
"Falling back to default hash."
|
|
133
|
+
)
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
private_class_method :try_custom_fingerprint
|
|
89
137
|
end
|
|
90
138
|
end
|
|
91
139
|
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Pure algorithm: Throttle error notifications to prevent alert fatigue
|
|
6
|
+
#
|
|
7
|
+
# Checks severity minimum, per-error cooldown, and threshold milestones.
|
|
8
|
+
# Uses in-memory cache (same pattern as BaselineAlertThrottler).
|
|
9
|
+
# Thread-safe via Mutex. Fail-open: returns true on any error.
|
|
10
|
+
class NotificationThrottler
|
|
11
|
+
# Severity levels ranked from lowest to highest
|
|
12
|
+
SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 }.freeze
|
|
13
|
+
|
|
14
|
+
@last_notification_times = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Should we send a notification for this error?
|
|
19
|
+
# Checks: severity minimum + cooldown period
|
|
20
|
+
# @param error_log [ErrorLog] The error to check
|
|
21
|
+
# @return [Boolean] true if notification should be sent
|
|
22
|
+
def should_notify?(error_log)
|
|
23
|
+
return false unless severity_meets_minimum?(error_log)
|
|
24
|
+
|
|
25
|
+
cooldown_ok?(error_log)
|
|
26
|
+
rescue => e
|
|
27
|
+
# Fail-open: if throttler breaks, allow notification
|
|
28
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] NotificationThrottler.should_notify? failed: #{e.message}")
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Does the error's severity meet the configured minimum?
|
|
33
|
+
# @param error_log [ErrorLog] The error to check
|
|
34
|
+
# @return [Boolean] true if severity is at or above minimum
|
|
35
|
+
def severity_meets_minimum?(error_log)
|
|
36
|
+
config = RailsErrorDashboard.configuration
|
|
37
|
+
minimum = config.notification_minimum_severity || :low
|
|
38
|
+
severity = SeverityClassifier.classify(error_log.error_type)
|
|
39
|
+
|
|
40
|
+
(SEVERITY_RANK[severity] || 0) >= (SEVERITY_RANK[minimum] || 0)
|
|
41
|
+
rescue => e
|
|
42
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] NotificationThrottler.severity_meets_minimum? failed: #{e.message}")
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Has the error's occurrence count reached a configured threshold milestone?
|
|
47
|
+
# @param error_log [ErrorLog] The error to check
|
|
48
|
+
# @return [Boolean] true if occurrence_count matches a threshold
|
|
49
|
+
def threshold_reached?(error_log)
|
|
50
|
+
thresholds = RailsErrorDashboard.configuration.notification_threshold_alerts
|
|
51
|
+
return false if thresholds.nil? || thresholds.empty?
|
|
52
|
+
|
|
53
|
+
thresholds.include?(error_log.occurrence_count)
|
|
54
|
+
rescue => e
|
|
55
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] NotificationThrottler.threshold_reached? failed: #{e.message}")
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Record that a notification was sent for this error
|
|
60
|
+
# @param error_log [ErrorLog] The error that was notified about
|
|
61
|
+
def record_notification(error_log)
|
|
62
|
+
key = error_log.error_hash
|
|
63
|
+
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
@last_notification_times[key] = Time.current
|
|
66
|
+
end
|
|
67
|
+
rescue => e
|
|
68
|
+
RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] NotificationThrottler.record_notification failed: #{e.message}")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Clear all throttle state (for testing)
|
|
72
|
+
def clear!
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
@last_notification_times.clear
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Remove old entries to prevent memory growth
|
|
79
|
+
# @param max_age_hours [Integer] Remove entries older than this (default: 24)
|
|
80
|
+
def cleanup!(max_age_hours: 24)
|
|
81
|
+
cutoff_time = max_age_hours.hours.ago
|
|
82
|
+
|
|
83
|
+
@mutex.synchronize do
|
|
84
|
+
@last_notification_times.delete_if { |_, time| time < cutoff_time }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Is the error outside the cooldown window?
|
|
91
|
+
# @param error_log [ErrorLog] The error to check
|
|
92
|
+
# @return [Boolean] true if not in cooldown (ok to notify)
|
|
93
|
+
def cooldown_ok?(error_log)
|
|
94
|
+
cooldown_minutes = RailsErrorDashboard.configuration.notification_cooldown_minutes
|
|
95
|
+
return true if cooldown_minutes.nil? || cooldown_minutes <= 0
|
|
96
|
+
|
|
97
|
+
key = error_log.error_hash
|
|
98
|
+
|
|
99
|
+
@mutex.synchronize do
|
|
100
|
+
last_time = @last_notification_times[key]
|
|
101
|
+
return true if last_time.nil?
|
|
102
|
+
|
|
103
|
+
Time.current > (last_time + cooldown_minutes.minutes)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "browser"
|
|
4
|
-
|
|
5
3
|
module RailsErrorDashboard
|
|
6
4
|
module Services
|
|
7
|
-
# Detects the platform (iOS/Android/API) from user agent string
|
|
5
|
+
# Detects the platform (iOS/Android/API) from user agent string.
|
|
6
|
+
# Uses the browser gem when available, falls back to regex matching.
|
|
8
7
|
class PlatformDetector
|
|
9
8
|
def self.detect(user_agent)
|
|
10
9
|
new(user_agent).detect
|
|
@@ -17,6 +16,16 @@ module RailsErrorDashboard
|
|
|
17
16
|
def detect
|
|
18
17
|
return "API" if @user_agent.blank?
|
|
19
18
|
|
|
19
|
+
if defined?(Browser)
|
|
20
|
+
detect_with_browser_gem
|
|
21
|
+
else
|
|
22
|
+
detect_with_regex
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def detect_with_browser_gem
|
|
20
29
|
browser = Browser.new(@user_agent)
|
|
21
30
|
|
|
22
31
|
if browser.device.iphone? || browser.device.ipad?
|
|
@@ -24,18 +33,34 @@ module RailsErrorDashboard
|
|
|
24
33
|
elsif browser.platform.android?
|
|
25
34
|
"Android"
|
|
26
35
|
elsif @user_agent&.include?("Expo")
|
|
27
|
-
|
|
28
|
-
if @user_agent.include?("iOS")
|
|
29
|
-
"iOS"
|
|
30
|
-
elsif @user_agent.include?("Android")
|
|
31
|
-
"Android"
|
|
32
|
-
else
|
|
33
|
-
"Mobile"
|
|
34
|
-
end
|
|
36
|
+
detect_expo_platform
|
|
35
37
|
else
|
|
36
38
|
"API"
|
|
37
39
|
end
|
|
38
40
|
end
|
|
41
|
+
|
|
42
|
+
def detect_with_regex
|
|
43
|
+
case @user_agent
|
|
44
|
+
when /iPhone|iPad|iPod/i
|
|
45
|
+
"iOS"
|
|
46
|
+
when /Android/i
|
|
47
|
+
"Android"
|
|
48
|
+
when /Expo/
|
|
49
|
+
detect_expo_platform
|
|
50
|
+
else
|
|
51
|
+
"API"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def detect_expo_platform
|
|
56
|
+
if @user_agent.include?("iOS")
|
|
57
|
+
"iOS"
|
|
58
|
+
elsif @user_agent.include?("Android")
|
|
59
|
+
"Android"
|
|
60
|
+
else
|
|
61
|
+
"Mobile"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
39
64
|
end
|
|
40
65
|
end
|
|
41
66
|
end
|