rails_error_dashboard 0.15.0 → 0.16.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 +7 -3
- 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/overview.html.erb +10 -7
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +16 -11
- 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 +19 -0
- data/config/locales/en.yml +21 -0
- data/config/locales/es.yml +20 -0
- data/config/locales/fr.yml +20 -0
- data/config/locales/it.yml +20 -0
- data/config/locales/ja.yml +18 -0
- data/config/locales/pl.yml +21 -0
- data/config/locales/pt-BR.yml +20 -0
- data/config/locales/ru.yml +21 -0
- data/config/locales/uk.yml +21 -0
- data/config/locales/zh-CN.yml +18 -0
- 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 +19 -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 +14 -6
- 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
|
@@ -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
|
|
@@ -160,18 +160,25 @@ module RailsErrorDashboard
|
|
|
160
160
|
def platform_stability_scores
|
|
161
161
|
platforms = base_scope.distinct.pluck(:platform).compact
|
|
162
162
|
error_rates = error_rate_by_platform
|
|
163
|
+
# A score compares platforms. With fewer than two that have errors in
|
|
164
|
+
# the window there is nothing to compare against, so none is rated (a
|
|
165
|
+
# lone platform used to come out Critical every time). The count is of
|
|
166
|
+
# platforms with errors in the window: the list above is every
|
|
167
|
+
# platform ever seen.
|
|
168
|
+
return {} if error_rates.size < 2
|
|
163
169
|
resolution_times = resolution_time_by_platform
|
|
164
170
|
|
|
165
|
-
|
|
166
|
-
max_errors = error_rates.values.max || 1
|
|
171
|
+
total_errors = error_rates.values.sum
|
|
167
172
|
max_resolution_time = resolution_times.values.compact.max || 1
|
|
168
173
|
|
|
169
174
|
platforms.each_with_object({}) do |platform, result|
|
|
170
175
|
error_count = error_rates[platform] || 0
|
|
171
176
|
avg_resolution = resolution_times[platform] || 0
|
|
172
177
|
|
|
173
|
-
#
|
|
174
|
-
|
|
178
|
+
# This platform's share of all errors, inverted. Against the busiest
|
|
179
|
+
# platform instead, the busiest always scored 0 here and so was always
|
|
180
|
+
# Critical.
|
|
181
|
+
error_score = total_errors.positive? ? 1.0 - (error_count.to_f / total_errors) : 1.0
|
|
175
182
|
resolution_score = avg_resolution.positive? ? 1.0 - (avg_resolution / max_resolution_time) : 1.0
|
|
176
183
|
|
|
177
184
|
# Weight: 70% error count, 30% resolution time
|
|
@@ -252,9 +259,10 @@ module RailsErrorDashboard
|
|
|
252
259
|
critical_errors: critical_errors,
|
|
253
260
|
unresolved_errors: unresolved_errors,
|
|
254
261
|
resolution_rate: resolution_rate,
|
|
255
|
-
|
|
262
|
+
# nil and :not_rated when there are fewer than two platforms
|
|
263
|
+
stability_score: stability_scores[platform],
|
|
256
264
|
error_velocity: velocity, # Positive = increasing, negative = decreasing
|
|
257
|
-
health_status: determine_health_status(stability_scores[platform]
|
|
265
|
+
health_status: stability_scores.key?(platform) ? determine_health_status(stability_scores[platform], velocity) : :not_rated
|
|
258
266
|
}
|
|
259
267
|
end
|
|
260
268
|
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
|
|
@@ -23,6 +23,7 @@ begin; require "turbo-rails"; rescue LoadError; end
|
|
|
23
23
|
# Core library files
|
|
24
24
|
require "rails_error_dashboard/value_objects/error_context"
|
|
25
25
|
require "rails_error_dashboard/value_objects/llm_call_event"
|
|
26
|
+
require "rails_error_dashboard/value_objects/occurrence_snapshot"
|
|
26
27
|
require "rails_error_dashboard/integrations/o_tel"
|
|
27
28
|
require "rails_error_dashboard/integrations/tracer"
|
|
28
29
|
require "rails_error_dashboard/integrations/llm_span_processor"
|
|
@@ -59,6 +60,7 @@ require "rails_error_dashboard/services/source_code_reader"
|
|
|
59
60
|
require "rails_error_dashboard/services/git_blame_reader"
|
|
60
61
|
require "rails_error_dashboard/services/git_head_reader"
|
|
61
62
|
require "rails_error_dashboard/services/solid_queue_config_check"
|
|
63
|
+
require "rails_error_dashboard/services/repository_url"
|
|
62
64
|
require "rails_error_dashboard/services/github_link_generator"
|
|
63
65
|
require "rails_error_dashboard/services/cause_chain_extractor"
|
|
64
66
|
require "rails_error_dashboard/services/environment_snapshot"
|
|
@@ -116,6 +118,7 @@ require "rails_error_dashboard/queries/error_cascades"
|
|
|
116
118
|
require "rails_error_dashboard/queries/baseline_stats"
|
|
117
119
|
require "rails_error_dashboard/queries/platform_comparison"
|
|
118
120
|
require "rails_error_dashboard/queries/error_correlation"
|
|
121
|
+
require "rails_error_dashboard/commands/record_occurrence_context"
|
|
119
122
|
require "rails_error_dashboard/commands/log_error"
|
|
120
123
|
require "rails_error_dashboard/commands/resolve_error"
|
|
121
124
|
require "rails_error_dashboard/commands/create_issue"
|
|
@@ -226,6 +229,19 @@ module RailsErrorDashboard
|
|
|
226
229
|
remove_instance_variable(:@detected_git_sha) if defined?(@detected_git_sha)
|
|
227
230
|
end
|
|
228
231
|
|
|
232
|
+
# The name errors are filed under: config.application_name, then the
|
|
233
|
+
# APPLICATION_NAME variable, then the Rails app's module name. Capture, the
|
|
234
|
+
# storm and event flushers, and the backfill all use this one chain, or the
|
|
235
|
+
# same app's rows end up under two Applications, or under none.
|
|
236
|
+
#
|
|
237
|
+
# @return [String]
|
|
238
|
+
def self.resolved_application_name
|
|
239
|
+
configuration.application_name.presence ||
|
|
240
|
+
ENV["APPLICATION_NAME"].presence ||
|
|
241
|
+
(defined?(Rails) && Rails.application&.class&.module_parent_name) ||
|
|
242
|
+
"Rails Application"
|
|
243
|
+
end
|
|
244
|
+
|
|
229
245
|
# Register a plugin
|
|
230
246
|
# @param plugin [Plugin] The plugin instance to register
|
|
231
247
|
# @return [Boolean] True if registered successfully, false otherwise
|
|
@@ -335,9 +335,8 @@ namespace :error_dashboard do
|
|
|
335
335
|
|
|
336
336
|
desc "Backfill application_id for existing errors"
|
|
337
337
|
task backfill_application: :environment do
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
"Legacy Application"
|
|
338
|
+
# APP_NAME overrides; otherwise the name capture files new errors under
|
|
339
|
+
app_name = ENV["APP_NAME"].presence || RailsErrorDashboard.resolved_application_name
|
|
341
340
|
|
|
342
341
|
puts "\n" + "=" * 80
|
|
343
342
|
puts "RAILS ERROR DASHBOARD - BACKFILL APPLICATION"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails_error_dashboard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Anjan Jagirdar
|
|
@@ -312,6 +312,7 @@ files:
|
|
|
312
312
|
- app/models/rails_error_dashboard/error_log.rb
|
|
313
313
|
- app/models/rails_error_dashboard/error_logs_record.rb
|
|
314
314
|
- app/models/rails_error_dashboard/error_occurrence.rb
|
|
315
|
+
- app/models/rails_error_dashboard/error_occurrence_context.rb
|
|
315
316
|
- app/models/rails_error_dashboard/event_count.rb
|
|
316
317
|
- app/models/rails_error_dashboard/event_timing_gap.rb
|
|
317
318
|
- app/models/rails_error_dashboard/missing_translation.rb
|
|
@@ -326,6 +327,7 @@ files:
|
|
|
326
327
|
- app/views/rails_error_dashboard/error_notification_mailer/error_alert.text.erb
|
|
327
328
|
- app/views/rails_error_dashboard/errors/_ai_help_panel.html.erb
|
|
328
329
|
- app/views/rails_error_dashboard/errors/_breadcrumbs_group.html.erb
|
|
330
|
+
- app/views/rails_error_dashboard/errors/_captured_occurrences.html.erb
|
|
329
331
|
- app/views/rails_error_dashboard/errors/_co_occurring_errors.html.erb
|
|
330
332
|
- app/views/rails_error_dashboard/errors/_discussion.html.erb
|
|
331
333
|
- app/views/rails_error_dashboard/errors/_error_cascades.html.erb
|
|
@@ -336,6 +338,7 @@ files:
|
|
|
336
338
|
- app/views/rails_error_dashboard/errors/_llm_summary.html.erb
|
|
337
339
|
- app/views/rails_error_dashboard/errors/_local_variables.html.erb
|
|
338
340
|
- app/views/rails_error_dashboard/errors/_modals.html.erb
|
|
341
|
+
- app/views/rails_error_dashboard/errors/_occurrence_banner.html.erb
|
|
339
342
|
- app/views/rails_error_dashboard/errors/_pattern_insights.html.erb
|
|
340
343
|
- app/views/rails_error_dashboard/errors/_request_context.html.erb
|
|
341
344
|
- app/views/rails_error_dashboard/errors/_show_scripts.html.erb
|
|
@@ -432,6 +435,8 @@ files:
|
|
|
432
435
|
- db/migrate/20260920000001_add_buckets_incomplete_to_storm_events.rb
|
|
433
436
|
- db/migrate/20260920000002_create_event_timing_gaps.rb
|
|
434
437
|
- db/migrate/20261010000001_create_missing_translations.rb
|
|
438
|
+
- db/migrate/20261015000001_create_rails_error_dashboard_error_occurrence_contexts.rb
|
|
439
|
+
- db/migrate/20261015000002_add_capture_source_to_error_occurrences_and_error_logs.rb
|
|
435
440
|
- lib/generators/rails_error_dashboard/install/install_generator.rb
|
|
436
441
|
- lib/generators/rails_error_dashboard/install/templates/README
|
|
437
442
|
- lib/generators/rails_error_dashboard/install/templates/initializer.rb
|
|
@@ -459,6 +464,7 @@ files:
|
|
|
459
464
|
- lib/rails_error_dashboard/commands/link_existing_issue.rb
|
|
460
465
|
- lib/rails_error_dashboard/commands/log_error.rb
|
|
461
466
|
- lib/rails_error_dashboard/commands/mute_error.rb
|
|
467
|
+
- lib/rails_error_dashboard/commands/record_occurrence_context.rb
|
|
462
468
|
- lib/rails_error_dashboard/commands/resolve_error.rb
|
|
463
469
|
- lib/rails_error_dashboard/commands/scrub_invalid_encoding.rb
|
|
464
470
|
- lib/rails_error_dashboard/commands/snooze_error.rb
|
|
@@ -573,6 +579,7 @@ files:
|
|
|
573
579
|
- lib/rails_error_dashboard/services/platform_detector.rb
|
|
574
580
|
- lib/rails_error_dashboard/services/priority_score_calculator.rb
|
|
575
581
|
- lib/rails_error_dashboard/services/rack_attack_tracker.rb
|
|
582
|
+
- lib/rails_error_dashboard/services/repository_url.rb
|
|
576
583
|
- lib/rails_error_dashboard/services/resolution_callbacks.rb
|
|
577
584
|
- lib/rails_error_dashboard/services/rspec_generator.rb
|
|
578
585
|
- lib/rails_error_dashboard/services/sensitive_data_filter.rb
|
|
@@ -605,6 +612,7 @@ files:
|
|
|
605
612
|
- lib/rails_error_dashboard/translation.rb
|
|
606
613
|
- lib/rails_error_dashboard/value_objects/error_context.rb
|
|
607
614
|
- lib/rails_error_dashboard/value_objects/llm_call_event.rb
|
|
615
|
+
- lib/rails_error_dashboard/value_objects/occurrence_snapshot.rb
|
|
608
616
|
- lib/rails_error_dashboard/version.rb
|
|
609
617
|
- lib/tasks/error_dashboard.rake
|
|
610
618
|
- lib/tasks/rails_error_dashboard_tasks.rake
|
|
@@ -620,7 +628,7 @@ metadata:
|
|
|
620
628
|
funding_uri: https://github.com/sponsors/AnjanJ
|
|
621
629
|
post_install_message: |
|
|
622
630
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
623
|
-
RED (Rails Error Dashboard) v0.
|
|
631
|
+
RED (Rails Error Dashboard) v0.16.0
|
|
624
632
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
625
633
|
|
|
626
634
|
First install:
|