rails_error_dashboard 0.15.0 → 0.16.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 +9 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +29 -2
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +10 -9
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +7 -14
- data/app/helpers/rails_error_dashboard/overview_helper.rb +4 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +5 -0
- data/app/models/rails_error_dashboard/error_log.rb +11 -8
- data/app/models/rails_error_dashboard/error_occurrence.rb +1 -0
- data/app/models/rails_error_dashboard/error_occurrence_context.rb +14 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +3 -1
- data/app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb +45 -0
- data/app/views/rails_error_dashboard/errors/_error_info.html.erb +10 -3
- data/app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb +12 -0
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +2 -2
- data/app/views/rails_error_dashboard/errors/overview.html.erb +14 -7
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +26 -17
- data/app/views/rails_error_dashboard/errors/settings.html.erb +6 -5
- data/app/views/rails_error_dashboard/errors/show.html.erb +7 -4
- data/config/locales/de.yml +21 -1
- data/config/locales/en.yml +23 -1
- data/config/locales/es.yml +22 -1
- data/config/locales/fr.yml +22 -1
- data/config/locales/it.yml +22 -1
- data/config/locales/ja.yml +20 -1
- data/config/locales/pl.yml +23 -1
- data/config/locales/pt-BR.yml +22 -1
- data/config/locales/ru.yml +23 -1
- data/config/locales/uk.yml +23 -1
- data/config/locales/zh-CN.yml +20 -1
- data/db/migrate/20260106094256_backfill_application_for_existing_errors.rb +3 -3
- data/db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb +35 -0
- data/db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb +46 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +14 -5
- data/lib/rails_error_dashboard/commands/batch_mute_errors.rb +11 -2
- data/lib/rails_error_dashboard/commands/batch_resolve_errors.rb +7 -2
- data/lib/rails_error_dashboard/commands/batch_unmute_errors.rb +7 -2
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +6 -0
- data/lib/rails_error_dashboard/commands/flush_missing_translations.rb +2 -4
- data/lib/rails_error_dashboard/commands/flush_rack_attack_events.rb +2 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +7 -6
- data/lib/rails_error_dashboard/commands/flush_swallowed_exceptions.rb +2 -4
- data/lib/rails_error_dashboard/commands/log_error.rb +19 -7
- data/lib/rails_error_dashboard/commands/record_occurrence_context.rb +60 -0
- data/lib/rails_error_dashboard/configuration.rb +26 -3
- data/lib/rails_error_dashboard/error_reporter.rb +4 -2
- data/lib/rails_error_dashboard/manual_error_reporter.rb +11 -1
- data/lib/rails_error_dashboard/middleware/error_catcher.rb +18 -4
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +0 -12
- data/lib/rails_error_dashboard/queries/co_occurring_errors.rb +59 -33
- data/lib/rails_error_dashboard/queries/platform_comparison.rb +55 -18
- data/lib/rails_error_dashboard/services/backtrace_processor.rb +16 -7
- data/lib/rails_error_dashboard/services/crash_capture.rb +5 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +1 -6
- data/lib/rails_error_dashboard/services/markdown_error_formatter.rb +15 -16
- data/lib/rails_error_dashboard/services/repository_url.rb +18 -0
- data/lib/rails_error_dashboard/services/similarity_calculator.rb +3 -9
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +6 -3
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +6 -1
- data/lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb +23 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +16 -0
- data/lib/tasks/error_dashboard.rake +2 -3
- metadata +10 -2
|
@@ -218,6 +218,7 @@ module RailsErrorDashboard
|
|
|
218
218
|
# Instance variable capture from tp.self (receiver object at raise time)
|
|
219
219
|
attr_accessor :enable_instance_variables # Master switch (default: false)
|
|
220
220
|
attr_accessor :instance_variable_max_count # Max ivars to capture (default: 20)
|
|
221
|
+
attr_accessor :occurrence_context_limit # Occurrences per error whose context is kept (default: 25; 0 = off)
|
|
221
222
|
attr_accessor :instance_variable_filter_patterns # Additional sensitive ivar patterns (default: [])
|
|
222
223
|
|
|
223
224
|
# Swallowed exception detection via TracePoint(:raise) + TracePoint(:rescue) (Ruby 3.3+ only)
|
|
@@ -481,6 +482,9 @@ module RailsErrorDashboard
|
|
|
481
482
|
# Instance variable capture defaults - OFF by default (opt-in)
|
|
482
483
|
@enable_instance_variables = false # Capture ivars from tp.self at raise time
|
|
483
484
|
@instance_variable_max_count = 20 # Max ivars per exception
|
|
485
|
+
# Keep the moment-of-failure context of the newest 25 occurrences of each
|
|
486
|
+
# error, not only the latest. 0 keeps none.
|
|
487
|
+
@occurrence_context_limit = 25
|
|
484
488
|
@instance_variable_filter_patterns = [] # Additional sensitive ivar name patterns
|
|
485
489
|
|
|
486
490
|
# Swallowed exception detection defaults - OFF by default (Ruby 3.3+ opt-in)
|
|
@@ -674,6 +678,13 @@ module RailsErrorDashboard
|
|
|
674
678
|
end
|
|
675
679
|
end
|
|
676
680
|
|
|
681
|
+
# Any other git_branch_strategy links to main. A warning, not an error:
|
|
682
|
+
# a link format is not worth stopping a boot.
|
|
683
|
+
if git_branch_strategy && !%w[commit_sha current_branch main].include?(git_branch_strategy.to_s)
|
|
684
|
+
warnings << "git_branch_strategy must be one of :commit_sha, :current_branch or :main " \
|
|
685
|
+
"(got: #{git_branch_strategy.inspect}). Repository links will point at main."
|
|
686
|
+
end
|
|
687
|
+
|
|
677
688
|
# Validate custom_fingerprint (must respond to .call if set)
|
|
678
689
|
if custom_fingerprint && !custom_fingerprint.respond_to?(:call)
|
|
679
690
|
errors << "custom_fingerprint must respond to .call (lambda, proc, or object with .call method)"
|
|
@@ -712,6 +723,10 @@ module RailsErrorDashboard
|
|
|
712
723
|
errors << "instance_variable_max_count must be at least 1 (got: #{instance_variable_max_count})"
|
|
713
724
|
end
|
|
714
725
|
|
|
726
|
+
unless occurrence_context_limit.is_a?(Integer) && occurrence_context_limit >= 0
|
|
727
|
+
errors << "occurrence_context_limit must be a whole number of 0 or more (got: #{occurrence_context_limit.inspect})"
|
|
728
|
+
end
|
|
729
|
+
|
|
715
730
|
# Validate swallowed exception detection settings
|
|
716
731
|
# Auto-disable on Ruby < 3.3 (warn, don't crash)
|
|
717
732
|
if detect_swallowed_exceptions && RUBY_VERSION < "3.3"
|
|
@@ -720,6 +735,13 @@ module RailsErrorDashboard
|
|
|
720
735
|
"Feature has been auto-disabled. Upgrade Ruby to use this feature."
|
|
721
736
|
@detect_swallowed_exceptions = false
|
|
722
737
|
end
|
|
738
|
+
|
|
739
|
+
# Rails 7.0.0-7.0.8.7 render request exceptions without reporting them, so
|
|
740
|
+
# RED never sees them. Rails 7.0.10 backported 7.1's reporting.
|
|
741
|
+
if defined?(ActionPack) && ActionPack.gem_version < Gem::Version.new("7.0.10")
|
|
742
|
+
warnings << "Rails #{ActionPack.gem_version} does not report the request exceptions it renders as error pages, " \
|
|
743
|
+
"so unhandled request errors are not recorded. Upgrading to Rails 7.0.10 or later fixes this."
|
|
744
|
+
end
|
|
723
745
|
# Validate sub-settings only if feature is still active after version check
|
|
724
746
|
if detect_swallowed_exceptions
|
|
725
747
|
if swallowed_exception_max_cache_size && swallowed_exception_max_cache_size < 1
|
|
@@ -1126,9 +1148,10 @@ module RailsErrorDashboard
|
|
|
1126
1148
|
return nil if effective_issue_tracker_provider == :linear
|
|
1127
1149
|
return nil if git_repository_url.blank?
|
|
1128
1150
|
|
|
1129
|
-
#
|
|
1130
|
-
|
|
1131
|
-
|
|
1151
|
+
# owner/repo from the normalized URL, so ".git", ".git/" and trailing
|
|
1152
|
+
# slashes all name the same repository the View Source links use
|
|
1153
|
+
url = Services::RepositoryUrl.normalize(git_repository_url)
|
|
1154
|
+
url.match(%r{[:/]([^/]+/[^/]+)\z})&.[](1)
|
|
1132
1155
|
end
|
|
1133
1156
|
|
|
1134
1157
|
# Resolve the issue tracker API token (supports string or lambda)
|
|
@@ -46,8 +46,10 @@ module RailsErrorDashboard
|
|
|
46
46
|
# Skip duplicate reports from our own middleware when the subscriber
|
|
47
47
|
# already captured this error with full request context above.
|
|
48
48
|
# Without this, async logging enqueues two jobs for one exception —
|
|
49
|
-
# and non-deterministic job ordering can overwrite good data.
|
|
50
|
-
|
|
49
|
+
# and non-deterministic job ordering can overwrite good data. The
|
|
50
|
+
# middleware's report also carries context[:middleware], because Rails
|
|
51
|
+
# 7.0's reporter passes subscribers no source.
|
|
52
|
+
if (source == "rack.middleware" || context[:middleware]) &&
|
|
51
53
|
Thread.current[:rails_error_dashboard_reported_errors]&.include?(error.object_id)
|
|
52
54
|
return
|
|
53
55
|
end
|
|
@@ -49,7 +49,12 @@ module RailsErrorDashboard
|
|
|
49
49
|
# believe they had set a value that was never read.
|
|
50
50
|
# @param source [String, nil] Source identifier (e.g., "frontend", "mobile_app")
|
|
51
51
|
#
|
|
52
|
-
# @return [ErrorLog, nil] The created error log record
|
|
52
|
+
# @return [ErrorLog, nil] The created or updated error log record. nil when
|
|
53
|
+
# no row is written now, or none is returned:
|
|
54
|
+
# - the error is ignored or sampled out (ignored_exceptions, sampling_rate)
|
|
55
|
+
# - storm protection only counts it (:count_only)
|
|
56
|
+
# - async_logging is on: a job writes the row later
|
|
57
|
+
# - logging failed internally (RED never raises into your code)
|
|
53
58
|
#
|
|
54
59
|
# @example Basic usage
|
|
55
60
|
# ManualErrorReporter.report(
|
|
@@ -106,6 +111,11 @@ module RailsErrorDashboard
|
|
|
106
111
|
occurred_at: occurred_at || Time.current
|
|
107
112
|
}.compact # Remove nil values
|
|
108
113
|
|
|
114
|
+
# Marks this as a client report, so nothing reads this app's source files
|
|
115
|
+
# for its backtrace. Set here, after the caller's values, and from no
|
|
116
|
+
# argument: source: is only a label.
|
|
117
|
+
context[:_capture_source] = "client"
|
|
118
|
+
|
|
109
119
|
# severity is deliberately NOT forwarded: nothing downstream reads it,
|
|
110
120
|
# and passing it on would keep the illusion that it does.
|
|
111
121
|
if severity.present?
|
|
@@ -38,15 +38,19 @@ module RailsErrorDashboard
|
|
|
38
38
|
# Report to Rails.error (will be logged by our ErrorReporter)
|
|
39
39
|
# CRITICAL: Wrap in rescue to ensure gem failures don't break the app
|
|
40
40
|
begin
|
|
41
|
-
|
|
41
|
+
options = {
|
|
42
42
|
handled: false,
|
|
43
43
|
severity: :error,
|
|
44
44
|
context: {
|
|
45
45
|
request: ActionDispatch::Request.new(env),
|
|
46
46
|
middleware: true
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
# Rails 7.0's ErrorReporter#report takes no source: keyword (7.1
|
|
50
|
+
# added it). Passing one raised ArgumentError there, which the rescue
|
|
51
|
+
# below swallowed, so on 7.0 this middleware reported nothing.
|
|
52
|
+
options[:source] = "rack.middleware" if report_takes_source?
|
|
53
|
+
Rails.error.report(exception, **options)
|
|
50
54
|
rescue => e
|
|
51
55
|
# If error reporting fails, log it but DON'T break the app
|
|
52
56
|
RailsErrorDashboard::Logger.error("[RailsErrorDashboard] Middleware error reporting failed: #{e.class} - #{e.message}")
|
|
@@ -61,6 +65,16 @@ module RailsErrorDashboard
|
|
|
61
65
|
Thread.current[:rails_error_dashboard_reported_errors] = nil
|
|
62
66
|
RailsErrorDashboard::Services::BreadcrumbCollector.clear_buffer
|
|
63
67
|
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Asked of the reporter's class, not the object: a stubbed or wrapped
|
|
72
|
+
# report method hides its real keywords.
|
|
73
|
+
def report_takes_source?
|
|
74
|
+
Rails.error.class.instance_method(:report).parameters.any? do |type, name|
|
|
75
|
+
name == :source || type == :keyrest
|
|
76
|
+
end
|
|
77
|
+
end
|
|
64
78
|
end
|
|
65
79
|
end
|
|
66
80
|
end
|
|
@@ -29,8 +29,6 @@ module RailsErrorDashboard
|
|
|
29
29
|
errors_by_hour: errors_by_hour,
|
|
30
30
|
top_users: top_affected_users,
|
|
31
31
|
resolution_rate: resolution_rate,
|
|
32
|
-
mobile_errors: mobile_errors_count,
|
|
33
|
-
api_errors: api_errors_count,
|
|
34
32
|
pattern_insights: pattern_insights
|
|
35
33
|
}
|
|
36
34
|
end
|
|
@@ -270,16 +268,6 @@ module RailsErrorDashboard
|
|
|
270
268
|
((resolved_count.to_f / total_groups) * 100).round(1)
|
|
271
269
|
end
|
|
272
270
|
|
|
273
|
-
def mobile_errors_count
|
|
274
|
-
Queries::EventVolume.in_window(base_scope.where(platform: [ "iOS", "Android" ]), @start_date)
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def api_errors_count
|
|
278
|
-
Queries::EventVolume.in_window(
|
|
279
|
-
base_scope.where("platform IS NULL OR platform = ?", "API"), @start_date
|
|
280
|
-
)
|
|
281
|
-
end
|
|
282
|
-
|
|
283
271
|
# Pattern insights for top error types
|
|
284
272
|
# Analyzes occurrence patterns and bursts for top 5 error types
|
|
285
273
|
def pattern_insights
|
|
@@ -16,6 +16,13 @@ module RailsErrorDashboard
|
|
|
16
16
|
# puts "#{result[:error].error_type} occurred #{result[:frequency]} times together"
|
|
17
17
|
# end
|
|
18
18
|
class CoOccurringErrors
|
|
19
|
+
# The newest occurrences of the target error that are compared. This
|
|
20
|
+
# renders on every error page while the feature is on, and it used to run
|
|
21
|
+
# one window query per lifetime occurrence: 50k occurrences, 50k queries.
|
|
22
|
+
TARGET_OCCURRENCES = 500
|
|
23
|
+
# At most this many occurrences of other errors are read for those windows
|
|
24
|
+
NEARBY_OCCURRENCES = 20_000
|
|
25
|
+
|
|
19
26
|
# Find co-occurring errors
|
|
20
27
|
#
|
|
21
28
|
# @param error_log_id [Integer] ID of target error
|
|
@@ -34,53 +41,72 @@ module RailsErrorDashboard
|
|
|
34
41
|
@limit = limit.to_i
|
|
35
42
|
end
|
|
36
43
|
|
|
44
|
+
# Four queries however many occurrences there are: the target exists, its
|
|
45
|
+
# newest occurrence times, other errors' occurrences in those windows, and
|
|
46
|
+
# the co-occurring errors themselves.
|
|
37
47
|
def find_co_occurring
|
|
38
|
-
|
|
39
|
-
return [] unless target_error
|
|
40
|
-
|
|
41
|
-
# Get all occurrences of the target error
|
|
42
|
-
target_occurrences = ErrorOccurrence.where(error_log_id: @error_log_id)
|
|
43
|
-
return [] if target_occurrences.empty?
|
|
44
|
-
|
|
45
|
-
# For each occurrence, find other errors in the time window
|
|
46
|
-
co_occurrence_data = Hash.new { |h, k| h[k] = { count: 0, delays: [] } }
|
|
48
|
+
return [] unless ErrorLog.exists?(id: @error_log_id)
|
|
47
49
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
end_time = occurrence.occurred_at + window
|
|
50
|
+
targets = ErrorOccurrence.where(error_log_id: @error_log_id)
|
|
51
|
+
.order(occurred_at: :desc).limit(TARGET_OCCURRENCES).pluck(:occurred_at).sort
|
|
52
|
+
return [] if targets.empty?
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
.in_time_window(start_time, end_time)
|
|
56
|
-
.where.not(error_log_id: @error_log_id)
|
|
57
|
-
.includes(:error_log)
|
|
54
|
+
window = @window_minutes.minutes
|
|
55
|
+
stats = Hash.new { |h, k| h[k] = { count: 0, delay_sum: 0.0 } }
|
|
58
56
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
nearby_occurrences(merged_windows(targets, window)).each do |error_log_id, occurred_at|
|
|
58
|
+
# Each target occurrence within the window of this one is a pair, as
|
|
59
|
+
# before. Delay is negative when this error came first.
|
|
60
|
+
index = targets.bsearch_index { |target| target >= occurred_at - window }
|
|
61
|
+
next unless index
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
while index < targets.size && targets[index] <= occurred_at + window
|
|
64
|
+
stats[error_log_id][:count] += 1
|
|
65
|
+
stats[error_log_id][:delay_sum] += (occurred_at - targets[index]).to_f
|
|
66
|
+
index += 1
|
|
66
67
|
end
|
|
67
68
|
end
|
|
68
69
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
error = ErrorLog.find(error_log_id)
|
|
72
|
-
avg_delay = data[:delays].sum / data[:delays].size
|
|
70
|
+
frequent = stats.select { |_id, data| data[:count] >= @min_frequency }
|
|
71
|
+
errors = ErrorLog.where(id: frequent.keys).index_by(&:id)
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
avg_delay_seconds: avg_delay.round(2)
|
|
78
|
-
}
|
|
73
|
+
results = frequent.filter_map do |error_log_id, data|
|
|
74
|
+
error = errors[error_log_id] or next
|
|
75
|
+
{ error: error, frequency: data[:count], avg_delay_seconds: (data[:delay_sum] / data[:count]).round(2) }
|
|
79
76
|
end
|
|
80
77
|
|
|
81
78
|
# Sort by frequency (most common first) and limit results
|
|
82
79
|
results.sort_by { |r| -r[:frequency] }.first(@limit)
|
|
83
80
|
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# The windows around each target time, overlapping ones merged, so a burst
|
|
85
|
+
# of target occurrences becomes one range
|
|
86
|
+
def merged_windows(targets, window)
|
|
87
|
+
targets.each_with_object([]) do |target, ranges|
|
|
88
|
+
from = target - window
|
|
89
|
+
to = target + window
|
|
90
|
+
if ranges.any? && from <= ranges.last[1]
|
|
91
|
+
ranges.last[1] = to
|
|
92
|
+
else
|
|
93
|
+
ranges << [ from, to ]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# [error_log_id, occurred_at] for other errors' occurrences in any window
|
|
99
|
+
def nearby_occurrences(ranges)
|
|
100
|
+
column = "#{ErrorOccurrence.table_name}.occurred_at"
|
|
101
|
+
in_a_window = ranges.map { "(#{column} BETWEEN ? AND ?)" }.join(" OR ")
|
|
102
|
+
|
|
103
|
+
ErrorOccurrence
|
|
104
|
+
.where.not(error_log_id: @error_log_id)
|
|
105
|
+
.where(in_a_window, *ranges.flatten)
|
|
106
|
+
.order(occurred_at: :desc)
|
|
107
|
+
.limit(NEARBY_OCCURRENCES)
|
|
108
|
+
.pluck(:error_log_id, :occurred_at)
|
|
109
|
+
end
|
|
84
110
|
end
|
|
85
111
|
end
|
|
86
112
|
end
|
|
@@ -65,6 +65,13 @@ module RailsErrorDashboard
|
|
|
65
65
|
@volume ||= Queries::EventVolume.new(base_scope, @start_date)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# Events per platform for the platforms with errors in the window, the
|
|
69
|
+
# only ones scored or given a health card. Errors with no platform value
|
|
70
|
+
# belong to none.
|
|
71
|
+
def active_error_rates
|
|
72
|
+
@active_error_rates ||= error_rate_by_platform.except(nil)
|
|
73
|
+
end
|
|
74
|
+
|
|
68
75
|
# platform => { error_type => events in the window }.
|
|
69
76
|
#
|
|
70
77
|
# Severity, critical counts and cross-platform totals all fold from this
|
|
@@ -158,25 +165,50 @@ module RailsErrorDashboard
|
|
|
158
165
|
# Higher score = more stable (fewer errors, faster resolution)
|
|
159
166
|
# @return [Hash] Platform => stability score
|
|
160
167
|
def platform_stability_scores
|
|
161
|
-
platforms
|
|
162
|
-
|
|
168
|
+
# Only platforms with errors in the window are scored. Every platform
|
|
169
|
+
# ever seen used to be, and one with none in the window scored 100.
|
|
170
|
+
error_rates = active_error_rates
|
|
171
|
+
# A score compares platforms. With fewer than two that have errors in
|
|
172
|
+
# the window there is nothing to compare against, so none is rated (a
|
|
173
|
+
# lone platform used to come out Critical every time).
|
|
174
|
+
return {} if error_rates.size < 2
|
|
163
175
|
resolution_times = resolution_time_by_platform
|
|
164
176
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
177
|
+
total_errors = error_rates.values.sum
|
|
178
|
+
even_share = 1.0 / error_rates.size
|
|
179
|
+
fastest_resolution = resolution_times.values_at(*error_rates.keys).compact.min
|
|
168
180
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
avg_resolution = resolution_times[platform] || 0
|
|
181
|
+
error_rates.each_with_object({}) do |(platform, error_count), result|
|
|
182
|
+
avg_resolution = resolution_times[platform]
|
|
172
183
|
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
184
|
+
# This platform's share of all errors against an even split: full
|
|
185
|
+
# credit at or below it, falling linearly to 0 when one platform has
|
|
186
|
+
# every error. Against the raw share instead, no evenly split app
|
|
187
|
+
# could be Healthy. The even-split test is done in integers so an
|
|
188
|
+
# exact split is never lost to rounding.
|
|
189
|
+
error_score = if error_count * error_rates.size <= total_errors
|
|
190
|
+
1.0
|
|
191
|
+
else
|
|
192
|
+
1.0 - (((error_count.to_f / total_errors) - even_share) / (1.0 - even_share))
|
|
193
|
+
end
|
|
176
194
|
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
|
|
195
|
+
# The fastest platform's average resolution time over this one's,
|
|
196
|
+
# each floored at one hour: the fastest and any as fast get full
|
|
197
|
+
# credit, one twice as slow gets half. Against the slowest instead,
|
|
198
|
+
# the slowest always scored 0 here. The floor keeps an average of 0.0
|
|
199
|
+
# (resolved at once) from leaving every other platform with none.
|
|
200
|
+
result[platform] = if avg_resolution
|
|
201
|
+
resolution_score = [ fastest_resolution, 1.0 ].max / [ avg_resolution, 1.0 ].max
|
|
202
|
+
|
|
203
|
+
# Weight: 70% error count, 30% resolution time
|
|
204
|
+
# Convert to 0-100 scale
|
|
205
|
+
((error_score * 0.7 + resolution_score * 0.3) * 100).round(1)
|
|
206
|
+
else
|
|
207
|
+
# Nothing resolved in the window: no resolution time to compare, so
|
|
208
|
+
# the score is the volume credit alone, on the same scale. It used
|
|
209
|
+
# to get the full resolution credit. The cards say which it is.
|
|
210
|
+
(error_score * 100).round(1)
|
|
211
|
+
end
|
|
180
212
|
end
|
|
181
213
|
end
|
|
182
214
|
|
|
@@ -220,12 +252,13 @@ module RailsErrorDashboard
|
|
|
220
252
|
# Get platform health summary
|
|
221
253
|
# @return [Hash] Platform => health metrics
|
|
222
254
|
def platform_health_summary
|
|
223
|
-
error_rates =
|
|
255
|
+
error_rates = active_error_rates
|
|
224
256
|
stability_scores = platform_stability_scores
|
|
225
257
|
severities = severity_distribution_by_platform
|
|
226
258
|
midpoint = @start_date + (@days / 2.0).days
|
|
227
259
|
|
|
228
|
-
|
|
260
|
+
# A card for each platform with errors in the window, as the scores
|
|
261
|
+
error_rates.each_key.with_object({}) do |platform, result|
|
|
229
262
|
platform_scope = base_scope.where(platform: platform)
|
|
230
263
|
groups_in_window = platform_scope.where("occurred_at >= ?", @start_date)
|
|
231
264
|
|
|
@@ -252,9 +285,13 @@ module RailsErrorDashboard
|
|
|
252
285
|
critical_errors: critical_errors,
|
|
253
286
|
unresolved_errors: unresolved_errors,
|
|
254
287
|
resolution_rate: resolution_rate,
|
|
255
|
-
|
|
288
|
+
# false when nothing from the window is resolved, so the stability
|
|
289
|
+
# score is volume alone
|
|
290
|
+
resolution_measured: resolved_errors.positive?,
|
|
291
|
+
# nil and :not_rated when there are fewer than two platforms
|
|
292
|
+
stability_score: stability_scores[platform],
|
|
256
293
|
error_velocity: velocity, # Positive = increasing, negative = decreasing
|
|
257
|
-
health_status: determine_health_status(stability_scores[platform]
|
|
294
|
+
health_status: stability_scores.key?(platform) ? determine_health_status(stability_scores[platform], velocity) : :not_rated
|
|
258
295
|
}
|
|
259
296
|
end
|
|
260
297
|
end
|
|
@@ -99,13 +99,7 @@ module RailsErrorDashboard
|
|
|
99
99
|
return nil if backtrace.blank?
|
|
100
100
|
|
|
101
101
|
lines = backtrace.is_a?(String) ? backtrace.split("\n") : backtrace
|
|
102
|
-
frames = lines.first(20).map
|
|
103
|
-
if line =~ %r{([^/]+\.rb):.*?in `(.+)'$}
|
|
104
|
-
"#{Regexp.last_match(1)}:#{Regexp.last_match(2)}"
|
|
105
|
-
elsif line =~ %r{([^/]+\.rb)}
|
|
106
|
-
Regexp.last_match(1)
|
|
107
|
-
end
|
|
108
|
-
end.compact.uniq
|
|
102
|
+
frames = lines.first(20).map { |line| frame_key(line) }.compact.uniq
|
|
109
103
|
|
|
110
104
|
return nil if frames.empty?
|
|
111
105
|
|
|
@@ -113,6 +107,21 @@ module RailsErrorDashboard
|
|
|
113
107
|
Digest::SHA256.hexdigest(file_paths.join("|"))[0..15]
|
|
114
108
|
end
|
|
115
109
|
|
|
110
|
+
# "file.rb:method" for one backtrace line, the unit similar-error matching
|
|
111
|
+
# compares. It accepts Ruby 3.3's in `save' and Ruby 3.4's in 'User#save',
|
|
112
|
+
# and drops 3.4's class qualifier ("block in User#save" -> "block in
|
|
113
|
+
# save"), so a frame matches whichever Ruby recorded it.
|
|
114
|
+
#
|
|
115
|
+
# @param line [String]
|
|
116
|
+
# @return [String, nil]
|
|
117
|
+
def self.frame_key(line)
|
|
118
|
+
if line =~ %r{([^/]+\.rb):.*?in [`'](.+)'$}
|
|
119
|
+
"#{Regexp.last_match(1)}:#{Regexp.last_match(2).gsub(/\b[A-Z][\w:]*[#.]/, "")}"
|
|
120
|
+
elsif line =~ %r{([^/]+\.rb)}
|
|
121
|
+
Regexp.last_match(1)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
116
125
|
# Calculate signature directly from Location objects
|
|
117
126
|
# @param locations [Array<Thread::Backtrace::Location>] Backtrace locations
|
|
118
127
|
# @return [String, nil] 16-character hex signature
|
|
@@ -267,6 +267,11 @@ module RailsErrorDashboard
|
|
|
267
267
|
attributes[:exception_cause] = cause_json
|
|
268
268
|
end
|
|
269
269
|
|
|
270
|
+
# The at_exit hook wrote this backtrace from the crashed process
|
|
271
|
+
if ErrorLog.column_names.include?("backtrace_source")
|
|
272
|
+
attributes[:backtrace_source] = "server"
|
|
273
|
+
end
|
|
274
|
+
|
|
270
275
|
if ErrorLog.column_names.include?("error_hash")
|
|
271
276
|
attributes[:error_hash] = Services::ErrorHashGenerator.from_attributes(
|
|
272
277
|
error_type: attributes[:error_type],
|
|
@@ -77,14 +77,9 @@ module RailsErrorDashboard
|
|
|
77
77
|
|
|
78
78
|
private
|
|
79
79
|
|
|
80
|
-
# Normalize repository URL (remove .git suffix, trailing slashes, etc.)
|
|
81
|
-
#
|
|
82
80
|
# @return [String]
|
|
83
81
|
def normalize_repository_url
|
|
84
|
-
|
|
85
|
-
url = url.chomp(".git")
|
|
86
|
-
url = url.chomp("/")
|
|
87
|
-
url
|
|
82
|
+
RepositoryUrl.normalize(repository_url)
|
|
88
83
|
end
|
|
89
84
|
|
|
90
85
|
# Host of the repository URL, downcased, or nil when the value is not an
|
|
@@ -64,33 +64,32 @@ module RailsErrorDashboard
|
|
|
64
64
|
raw = @error.backtrace
|
|
65
65
|
return nil if raw.blank?
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
app_lines =
|
|
69
|
-
app_lines = lines.first(MAX_BACKTRACE_LINES) if app_lines.empty?
|
|
67
|
+
app_lines = app_frames.map { |frame| frame[:full_line] }
|
|
68
|
+
app_lines = raw.split("\n") if app_lines.empty?
|
|
70
69
|
app_lines = app_lines.first(MAX_BACKTRACE_LINES)
|
|
71
70
|
|
|
72
71
|
"## Backtrace\n\n```\n#{app_lines.join("\n")}\n```"
|
|
73
72
|
end
|
|
74
73
|
|
|
74
|
+
# The application's frames, as the error page classifies them. RED stores
|
|
75
|
+
# frames shortened ("gems/…", "ruby/…"), so a "/gems/" substring test
|
|
76
|
+
# kept every gem frame.
|
|
77
|
+
def app_frames
|
|
78
|
+
@app_frames ||= Services::BacktraceParser.parse(@error.backtrace.to_s).select { |frame| frame[:category] == :app }
|
|
79
|
+
end
|
|
80
|
+
|
|
75
81
|
def source_code_section
|
|
76
82
|
return nil unless defined?(RailsErrorDashboard) &&
|
|
77
83
|
RailsErrorDashboard.configuration.enable_source_code_integration
|
|
78
84
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
lines = raw.split("\n")
|
|
83
|
-
app_lines = lines.reject { |l| l.include?("/gems/") || l.include?("/ruby/") || l.include?("/vendor/") }
|
|
84
|
-
return nil if app_lines.empty?
|
|
85
|
+
return nil if @error.backtrace.blank? || app_frames.empty?
|
|
86
|
+
# A client's frames describe code that ran on the client
|
|
87
|
+
return nil if @error.client_backtrace?
|
|
85
88
|
|
|
86
89
|
snippets = []
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
next unless match
|
|
91
|
-
|
|
92
|
-
file_path = match[1]
|
|
93
|
-
line_number = match[2].to_i
|
|
90
|
+
app_frames.first(3).each do |frame|
|
|
91
|
+
file_path = frame[:file_path]
|
|
92
|
+
line_number = frame[:line_number]
|
|
94
93
|
|
|
95
94
|
reader = Services::SourceCodeReader.new(file_path, line_number)
|
|
96
95
|
source_lines = reader.read_lines(context: 3)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# The one normal form of a configured repository URL, shared by the View
|
|
6
|
+
# Source links and the issue tracker: no surrounding whitespace, no trailing
|
|
7
|
+
# slashes, no ".git". The slashes go first, or ".git/" keeps its ".git".
|
|
8
|
+
module RepositoryUrl
|
|
9
|
+
# @param url [String, nil]
|
|
10
|
+
# @return [String, nil]
|
|
11
|
+
def self.normalize(url)
|
|
12
|
+
return nil if url.nil?
|
|
13
|
+
|
|
14
|
+
url.to_s.strip.sub(%r{/+\z}, "").delete_suffix(".git").sub(%r{/+\z}, "")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -80,19 +80,13 @@ module RailsErrorDashboard
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
# Extract meaningful frames from backtrace
|
|
83
|
-
#
|
|
83
|
+
# "file.rb:123:in `name'" (Ruby 3.3) or "file.rb:123:in 'Class#name'"
|
|
84
|
+
# (Ruby 3.4) => "file.rb:name", via BacktraceProcessor.frame_key
|
|
84
85
|
def extract_frames(backtrace)
|
|
85
86
|
return [] if backtrace.blank?
|
|
86
87
|
|
|
87
88
|
lines = backtrace.is_a?(String) ? backtrace.split("\n") : backtrace
|
|
88
|
-
lines.first(20).map
|
|
89
|
-
# Extract file path and method name, ignore line numbers
|
|
90
|
-
if line =~ %r{([^/]+\.rb):.*?in `(.+)'$}
|
|
91
|
-
"#{Regexp.last_match(1)}:#{Regexp.last_match(2)}"
|
|
92
|
-
elsif line =~ %r{([^/]+\.rb)}
|
|
93
|
-
Regexp.last_match(1)
|
|
94
|
-
end
|
|
95
|
-
end.compact.uniq
|
|
89
|
+
lines.first(20).map { |line| BacktraceProcessor.frame_key(line) }.compact.uniq # Only consider first 20 frames
|
|
96
90
|
end
|
|
97
91
|
|
|
98
92
|
# Normalize message for comparison
|
|
@@ -38,7 +38,8 @@ module RailsErrorDashboard
|
|
|
38
38
|
Entry = Struct.new(
|
|
39
39
|
:error_class, :message, :first_app_frame,
|
|
40
40
|
:controller_name, :action_name, :custom_hash, :environment,
|
|
41
|
-
:opaque_identity, :count, :first_seen_at, :last_seen_at, :buckets
|
|
41
|
+
:opaque_identity, :count, :first_seen_at, :last_seen_at, :buckets,
|
|
42
|
+
:capture_source
|
|
42
43
|
)
|
|
43
44
|
|
|
44
45
|
# The bucket an instant belongs to, as an epoch second.
|
|
@@ -120,6 +121,7 @@ module RailsErrorDashboard
|
|
|
120
121
|
# job CANNOT recompute this -- it has to travel, or a storm count
|
|
121
122
|
# lands on a different row than the full capture path would.
|
|
122
123
|
"opaque_identity" => entry.opaque_identity,
|
|
124
|
+
"capture_source" => entry.capture_source,
|
|
123
125
|
"count" => entry.count.value,
|
|
124
126
|
"first_seen_at" => entry.first_seen_at.iso8601,
|
|
125
127
|
"last_seen_at" => entry.last_seen_at.iso8601,
|
|
@@ -167,7 +169,7 @@ module RailsErrorDashboard
|
|
|
167
169
|
parts[:controller_name], parts[:action_name], parts[:custom_hash], parts[:environment],
|
|
168
170
|
parts[:opaque_identity],
|
|
169
171
|
Concurrent::AtomicFixnum.new(0), first_seen_at || Time.current, last_seen_at || Time.current,
|
|
170
|
-
Concurrent::Map.new
|
|
172
|
+
Concurrent::Map.new, parts[:capture_source]
|
|
171
173
|
)
|
|
172
174
|
end
|
|
173
175
|
end
|
|
@@ -216,7 +218,8 @@ module RailsErrorDashboard
|
|
|
216
218
|
error_class: entry["error_class"], message: entry["message"],
|
|
217
219
|
first_app_frame: entry["first_app_frame"], controller_name: entry["controller_name"],
|
|
218
220
|
action_name: entry["action_name"], custom_hash: entry["custom_hash"],
|
|
219
|
-
environment: entry["environment"], opaque_identity: entry["opaque_identity"]
|
|
221
|
+
environment: entry["environment"], opaque_identity: entry["opaque_identity"],
|
|
222
|
+
capture_source: entry["capture_source"]
|
|
220
223
|
}
|
|
221
224
|
end
|
|
222
225
|
|
|
@@ -279,7 +279,12 @@ module RailsErrorDashboard
|
|
|
279
279
|
# Only an EXPLICIT environment from the caller. nil means "the
|
|
280
280
|
# worker's own environment", resolved at flush time exactly as
|
|
281
281
|
# LogError resolves it for a full capture.
|
|
282
|
-
environment: context[:environment].to_s.strip.presence&.[](0, 64)
|
|
282
|
+
environment: context[:environment].to_s.strip.presence&.[](0, 64),
|
|
283
|
+
# Whose backtrace first_app_frame came from, for the row the flush
|
|
284
|
+
# may build from it. Not part of the identity or the gate key: a
|
|
285
|
+
# client report and a server capture of one error share an entry,
|
|
286
|
+
# which keeps the first event's frame, and so its source.
|
|
287
|
+
capture_source: context[:_capture_source] == "client" ? "client" : "server"
|
|
283
288
|
)
|
|
284
289
|
end
|
|
285
290
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module ValueObjects
|
|
5
|
+
# The error as it looked at one occurrence: a read-only copy of the error
|
|
6
|
+
# row whose breadcrumbs, system health and variables are that occurrence's
|
|
7
|
+
# stored context. Every other field keeps the row's latest value.
|
|
8
|
+
#
|
|
9
|
+
# A real ErrorLog rather than a delegator, so every way a card reads a
|
|
10
|
+
# piece sees the occurrence's value: the instance-variables card goes
|
|
11
|
+
# through read_attribute and error.class.column_names, because
|
|
12
|
+
# Object#instance_variables shadows the attribute reader.
|
|
13
|
+
module OccurrenceSnapshot
|
|
14
|
+
# @param error [ErrorLog] the error the context belongs to
|
|
15
|
+
# @param context [ErrorOccurrenceContext] one of its occurrences' contexts
|
|
16
|
+
# @return [ErrorLog] read-only; a piece the occurrence did not capture is nil
|
|
17
|
+
def self.build(error, context)
|
|
18
|
+
pieces = Commands::RecordOccurrenceContext::PIECES.to_h { |piece| [ piece.to_s, context.read_attribute(piece) ] }
|
|
19
|
+
error.class.instantiate(error.attributes_before_type_cast.merge(pieces)).tap(&:readonly!)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|