rails_error_dashboard 0.11.4 → 0.11.6
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 +3 -2
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +31 -3
- data/app/jobs/rails_error_dashboard/baseline_calculation_job.rb +21 -0
- data/app/models/rails_error_dashboard/error_log.rb +4 -7
- data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
- data/config/locales/fr.yml +30 -30
- data/db/migrate/20260908000001_add_release_to_error_occurrences.rb +45 -0
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +20 -9
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +63 -28
- data/lib/rails_error_dashboard/commands/log_error.rb +11 -2
- data/lib/rails_error_dashboard/configuration.rb +7 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +4 -1
- data/lib/rails_error_dashboard/queries/baseline_stats.rb +39 -4
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +5 -16
- data/lib/rails_error_dashboard/queries/release_timeline.rb +72 -1
- data/lib/rails_error_dashboard/services/baseline_calculator.rb +75 -62
- data/lib/rails_error_dashboard/services/error_hash_generator.rb +9 -0
- data/lib/rails_error_dashboard/services/local_variable_capturer.rb +33 -3
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +83 -22
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +52 -12
- data/lib/rails_error_dashboard/services/system_health_snapshot.rb +61 -2
- data/lib/rails_error_dashboard/value_objects/error_context.rb +19 -2
- data/lib/rails_error_dashboard/version.rb +1 -1
- metadata +4 -2
|
@@ -85,23 +85,58 @@ module RailsErrorDashboard
|
|
|
85
85
|
# @param current_count [Integer] Current error count
|
|
86
86
|
# @param sensitivity [Integer] Standard deviations threshold (default: 2)
|
|
87
87
|
# @return [Hash] { anomaly: true/false, level: Symbol, baseline_type: String }
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
# Compare a count against the first available baseline, in matching
|
|
89
|
+
# units: the current HOUR's count against the hourly baseline, TODAY's
|
|
90
|
+
# against the daily, THIS WEEK's against the weekly. A bare positional
|
|
91
|
+
# count is compared against whichever baseline is found (legacy callers).
|
|
92
|
+
#
|
|
93
|
+
# @param current_count [Integer, nil] legacy: one count used for any baseline
|
|
94
|
+
# @param hourly [Integer, nil] events so far this hour
|
|
95
|
+
# @param daily [Integer, nil] events so far today
|
|
96
|
+
# @param weekly [Integer, nil] events so far this week
|
|
97
|
+
def check_anomaly(current_count = nil, sensitivity: 2, hourly: nil, daily: nil, weekly: nil)
|
|
98
|
+
candidates = [
|
|
99
|
+
[ hourly_baseline, hourly || current_count ],
|
|
100
|
+
[ daily_baseline, daily || current_count ],
|
|
101
|
+
[ weekly_baseline, weekly || current_count ]
|
|
102
|
+
]
|
|
103
|
+
baseline, count = candidates.find { |b, c| b && c }
|
|
90
104
|
|
|
91
105
|
if baseline.nil?
|
|
92
106
|
return { anomaly: false, level: nil, baseline_type: nil, message: "No baseline available" }
|
|
93
107
|
end
|
|
94
108
|
|
|
95
|
-
level = baseline.anomaly_level(
|
|
109
|
+
level = baseline.anomaly_level(count, sensitivity: sensitivity)
|
|
96
110
|
|
|
97
111
|
{
|
|
98
112
|
anomaly: level.present?,
|
|
99
113
|
level: level,
|
|
100
114
|
baseline_type: baseline.baseline_type,
|
|
115
|
+
current_count: count,
|
|
101
116
|
threshold: baseline.threshold(sensitivity: sensitivity),
|
|
102
|
-
std_devs_above: baseline.std_devs_above_mean(
|
|
117
|
+
std_devs_above: baseline.std_devs_above_mean(count)
|
|
103
118
|
}
|
|
104
119
|
end
|
|
120
|
+
|
|
121
|
+
# Events so far in the current hour / day / week, counted in the same
|
|
122
|
+
# units the baselines were built from (Services::BaselineCalculator).
|
|
123
|
+
# @return [Hash] { hourly: Integer, daily: Integer, weekly: Integer }
|
|
124
|
+
def current_counts(application_id: nil)
|
|
125
|
+
relation = Services::BaselineCalculator.counting_relation(@error_type, @platform, application_id: application_id)
|
|
126
|
+
column = Services::BaselineCalculator.time_column
|
|
127
|
+
now = Time.current
|
|
128
|
+
{
|
|
129
|
+
hourly: relation.where("#{column} >= ?", now.beginning_of_hour).count,
|
|
130
|
+
daily: relation.where("#{column} >= ?", now.beginning_of_day).count,
|
|
131
|
+
weekly: relation.where("#{column} >= ?", now.beginning_of_week).count
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The anomaly check for right now: current counts against their baselines.
|
|
136
|
+
# @param application_id [Integer, nil] scope the current counts to one application
|
|
137
|
+
def check_current_anomaly(sensitivity: 2, application_id: nil)
|
|
138
|
+
check_anomaly(sensitivity: sensitivity, **current_counts(application_id: application_id))
|
|
139
|
+
end
|
|
105
140
|
end
|
|
106
141
|
end
|
|
107
142
|
end
|
|
@@ -181,14 +181,8 @@ module RailsErrorDashboard
|
|
|
181
181
|
|
|
182
182
|
# Check most common error types for anomalies
|
|
183
183
|
base_scope.distinct.pluck(:error_type, :platform).compact.any? do |(error_type, platform)|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
error_type: error_type,
|
|
187
|
-
platform: platform
|
|
188
|
-
).where("occurred_at >= ?", Time.current.beginning_of_day).count
|
|
189
|
-
|
|
190
|
-
result = stats.check_anomaly(error_count, sensitivity: 2)
|
|
191
|
-
result[:anomaly]
|
|
184
|
+
Queries::BaselineStats.new(error_type, platform)
|
|
185
|
+
.check_current_anomaly(sensitivity: 2, application_id: @application_id)[:anomaly]
|
|
192
186
|
end
|
|
193
187
|
end
|
|
194
188
|
|
|
@@ -198,19 +192,14 @@ module RailsErrorDashboard
|
|
|
198
192
|
|
|
199
193
|
# Find the most anomalous error type
|
|
200
194
|
anomalies = base_scope.distinct.pluck(:error_type, :platform).compact.map do |(error_type, platform)|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
error_type: error_type,
|
|
204
|
-
platform: platform
|
|
205
|
-
).where("occurred_at >= ?", Time.current.beginning_of_day).count
|
|
206
|
-
|
|
207
|
-
result = stats.check_anomaly(error_count, sensitivity: 2)
|
|
195
|
+
result = Queries::BaselineStats.new(error_type, platform)
|
|
196
|
+
.check_current_anomaly(sensitivity: 2, application_id: @application_id)
|
|
208
197
|
next unless result[:anomaly]
|
|
209
198
|
|
|
210
199
|
{
|
|
211
200
|
error_type: error_type,
|
|
212
201
|
platform: platform,
|
|
213
|
-
count:
|
|
202
|
+
count: result[:current_count],
|
|
214
203
|
level: result[:level],
|
|
215
204
|
std_devs_above: result[:std_devs_above]
|
|
216
205
|
}
|
|
@@ -4,7 +4,18 @@ module RailsErrorDashboard
|
|
|
4
4
|
module Queries
|
|
5
5
|
# Query: Build a release timeline from error data, showing per-version health stats,
|
|
6
6
|
# "new in this release" error detection, stability indicators, and release-over-release deltas.
|
|
7
|
-
#
|
|
7
|
+
#
|
|
8
|
+
# Counting unit: OCCURRENCES. An ErrorLog row is a group that keeps the
|
|
9
|
+
# release of its first capture, so counting rows attributed every later
|
|
10
|
+
# recurrence to that first release. Each occurrence carries the release it
|
|
11
|
+
# happened under (since the add_release_to_error_occurrences migration);
|
|
12
|
+
# before that migration is applied the query falls back to counting group
|
|
13
|
+
# rows as it always did. "New in this release" stays group-based on
|
|
14
|
+
# purpose: a group's first release IS where the error was new.
|
|
15
|
+
#
|
|
16
|
+
# Storm count-only events create no occurrence rows, so a release's total
|
|
17
|
+
# here is the number of captured events, which storm shedding can leave
|
|
18
|
+
# below the group's occurrence_count.
|
|
8
19
|
class ReleaseTimeline
|
|
9
20
|
def self.call(days = 30, application_id: nil)
|
|
10
21
|
new(days, application_id: application_id).call
|
|
@@ -77,6 +88,8 @@ module RailsErrorDashboard
|
|
|
77
88
|
|
|
78
89
|
# Single GROUP BY query for per-version aggregates
|
|
79
90
|
def aggregate_version_stats
|
|
91
|
+
return aggregate_occurrence_stats if occurrences_carry_release?
|
|
92
|
+
|
|
80
93
|
rows = base_scope
|
|
81
94
|
.group(:app_version)
|
|
82
95
|
.select(
|
|
@@ -119,6 +132,64 @@ module RailsErrorDashboard
|
|
|
119
132
|
{}
|
|
120
133
|
end
|
|
121
134
|
|
|
135
|
+
def occurrences_carry_release?
|
|
136
|
+
defined?(ErrorOccurrence) && ErrorOccurrence.table_exists? &&
|
|
137
|
+
ErrorOccurrence.column_names.include?("app_version")
|
|
138
|
+
rescue
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def occurrence_scope
|
|
143
|
+
occurrences = ErrorOccurrence.table_name
|
|
144
|
+
scope = ErrorOccurrence.joins(:error_log)
|
|
145
|
+
.where("#{occurrences}.occurred_at >= ?", @start_date)
|
|
146
|
+
.where.not(occurrences => { app_version: [ nil, "" ] })
|
|
147
|
+
scope = scope.where(ErrorLog.table_name => { application_id: @application_id }) if @application_id.present?
|
|
148
|
+
scope
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def aggregate_occurrence_stats
|
|
152
|
+
occurrences = ErrorOccurrence.table_name
|
|
153
|
+
logs = ErrorLog.table_name
|
|
154
|
+
|
|
155
|
+
rows = occurrence_scope
|
|
156
|
+
.group("#{occurrences}.app_version")
|
|
157
|
+
.select(
|
|
158
|
+
"#{occurrences}.app_version AS app_version",
|
|
159
|
+
"COUNT(*) AS total_count",
|
|
160
|
+
"COUNT(DISTINCT #{logs}.error_type) AS unique_types",
|
|
161
|
+
"MIN(#{occurrences}.occurred_at) AS first_seen_at",
|
|
162
|
+
"MAX(#{occurrences}.occurred_at) AS last_seen_at"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
sha_map = {}
|
|
166
|
+
occurrence_scope.where.not(occurrences => { git_sha: [ nil, "" ] })
|
|
167
|
+
.group("#{occurrences}.app_version", "#{occurrences}.git_sha")
|
|
168
|
+
.pluck("#{occurrences}.app_version", "#{occurrences}.git_sha")
|
|
169
|
+
.each do |version, sha|
|
|
170
|
+
(sha_map[version] ||= []) << sha
|
|
171
|
+
end
|
|
172
|
+
sha_map.each_value(&:uniq!)
|
|
173
|
+
|
|
174
|
+
rows.each_with_object({}) do |row, result|
|
|
175
|
+
first_seen = row.first_seen_at
|
|
176
|
+
last_seen = row.last_seen_at
|
|
177
|
+
first_seen = Time.zone.parse(first_seen) if first_seen.is_a?(String)
|
|
178
|
+
last_seen = Time.zone.parse(last_seen) if last_seen.is_a?(String)
|
|
179
|
+
|
|
180
|
+
result[row.app_version] = {
|
|
181
|
+
total_errors: row.total_count.to_i,
|
|
182
|
+
unique_error_types: row.unique_types.to_i,
|
|
183
|
+
first_seen: first_seen,
|
|
184
|
+
last_seen: last_seen,
|
|
185
|
+
git_shas: sha_map[row.app_version] || []
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
rescue => e
|
|
189
|
+
Rails.logger.error("[RailsErrorDashboard] ReleaseTimeline occurrence aggregate failed: #{e.class}: #{e.message}")
|
|
190
|
+
{}
|
|
191
|
+
end
|
|
192
|
+
|
|
122
193
|
# For each error_hash in the window, find which app_version it first appeared in.
|
|
123
194
|
# Count per version to get "new errors introduced in this release".
|
|
124
195
|
def new_errors_per_version
|
|
@@ -72,79 +72,72 @@ module RailsErrorDashboard
|
|
|
72
72
|
defined?(ErrorBaseline) && ErrorBaseline.table_exists?
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
75
|
+
# The events a baseline is built from: occurrences of this error type on
|
|
76
|
+
# this platform (one row per captured event) when the occurrences table
|
|
77
|
+
# exists, else the group rows themselves. Public so the anomaly check
|
|
78
|
+
# counts the current period in exactly the same units.
|
|
79
|
+
#
|
|
80
|
+
# @param application_id [Integer, nil] restrict to one application's
|
|
81
|
+
# events (the baselines themselves have no application dimension;
|
|
82
|
+
# the current-period observation must still be scoped, or one app's
|
|
83
|
+
# spike shows up as an anomaly on another app's dashboard)
|
|
84
|
+
def self.counting_relation(error_type, platform, application_id: nil)
|
|
85
|
+
conditions = { error_type: error_type, platform: platform }
|
|
86
|
+
conditions[:application_id] = application_id if application_id.present?
|
|
87
|
+
|
|
88
|
+
if defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
|
|
89
|
+
ErrorOccurrence.joins(:error_log).where(ErrorLog.table_name => conditions)
|
|
90
|
+
else
|
|
91
|
+
ErrorLog.where(conditions)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
91
94
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
# The timestamp column of #counting_relation, qualified for the join.
|
|
96
|
+
def self.time_column
|
|
97
|
+
if defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
|
|
98
|
+
"#{ErrorOccurrence.table_name}.occurred_at"
|
|
99
|
+
else
|
|
100
|
+
"occurred_at"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
|
|
104
|
+
# Calculate hourly baseline (last 4 weeks, one sample per calendar hour)
|
|
105
|
+
def calculate_hourly_baseline(error_type, platform)
|
|
106
|
+
calculate_baseline(error_type, platform, "hourly", :hour,
|
|
107
|
+
HOURLY_LOOKBACK.ago.beginning_of_hour, Time.current.beginning_of_hour)
|
|
100
108
|
end
|
|
101
109
|
|
|
102
|
-
# Calculate daily baseline (last 12 weeks,
|
|
110
|
+
# Calculate daily baseline (last 12 weeks, one sample per calendar day)
|
|
103
111
|
def calculate_daily_baseline(error_type, platform)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
# Get error counts grouped by day
|
|
108
|
-
daily_counts = ErrorLog
|
|
109
|
-
.where(error_type: error_type, platform: platform)
|
|
110
|
-
.where("occurred_at >= ?", period_start)
|
|
111
|
-
.group("DATE(occurred_at)")
|
|
112
|
-
.count
|
|
113
|
-
|
|
114
|
-
return nil if daily_counts.empty?
|
|
115
|
-
|
|
116
|
-
counts = daily_counts.values
|
|
117
|
-
stats = calculate_statistics(counts)
|
|
118
|
-
|
|
119
|
-
baseline = Commands::UpsertBaseline.call(
|
|
120
|
-
error_type: error_type, platform: platform, baseline_type: "daily",
|
|
121
|
-
period_start: period_start, period_end: period_end,
|
|
122
|
-
stats: stats, count: counts.sum, sample_size: counts.size
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
@calculated_count += 1
|
|
126
|
-
baseline
|
|
112
|
+
calculate_baseline(error_type, platform, "daily", :day,
|
|
113
|
+
DAILY_LOOKBACK.ago.beginning_of_day, Time.current.beginning_of_day)
|
|
127
114
|
end
|
|
128
115
|
|
|
129
|
-
# Calculate weekly baseline (last 1 year,
|
|
116
|
+
# Calculate weekly baseline (last 1 year, one sample per calendar week)
|
|
130
117
|
def calculate_weekly_baseline(error_type, platform)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
# Get error counts grouped by week
|
|
135
|
-
weekly_counts = ErrorLog
|
|
136
|
-
.where(error_type: error_type, platform: platform)
|
|
137
|
-
.where("occurred_at >= ?", period_start)
|
|
138
|
-
.group("strftime('%Y-%W', occurred_at)")
|
|
139
|
-
.count
|
|
118
|
+
calculate_baseline(error_type, platform, "weekly", :week,
|
|
119
|
+
WEEKLY_LOOKBACK.ago.beginning_of_week, Time.current.beginning_of_week)
|
|
120
|
+
end
|
|
140
121
|
|
|
141
|
-
|
|
122
|
+
# One sample per DATED bucket across the whole lookback, zero buckets
|
|
123
|
+
# included, so the statistics describe "how many events in an hour /
|
|
124
|
+
# day / week" — the unit the anomaly check compares against.
|
|
125
|
+
#
|
|
126
|
+
# The previous implementation grouped four weeks by hour-of-day, which
|
|
127
|
+
# summed 28 days into at most 24 totals (seven noon failures on seven
|
|
128
|
+
# days: mean 7, sample size 1), dropped quiet periods, and embedded a
|
|
129
|
+
# SQLite-only date function so it could not run on PostgreSQL or MySQL
|
|
130
|
+
# at all. Groupdate does the bucketing per adapter.
|
|
131
|
+
def calculate_baseline(error_type, platform, baseline_type, period, period_start, period_end)
|
|
132
|
+
return nil if period_end <= period_start
|
|
133
|
+
|
|
134
|
+
counts = bucket_counts(error_type, platform, period, period_start, period_end)
|
|
135
|
+
return nil if counts.sum.zero?
|
|
142
136
|
|
|
143
|
-
counts = weekly_counts.values
|
|
144
137
|
stats = calculate_statistics(counts)
|
|
145
138
|
|
|
146
139
|
baseline = Commands::UpsertBaseline.call(
|
|
147
|
-
error_type: error_type, platform: platform, baseline_type:
|
|
140
|
+
error_type: error_type, platform: platform, baseline_type: baseline_type,
|
|
148
141
|
period_start: period_start, period_end: period_end,
|
|
149
142
|
stats: stats, count: counts.sum, sample_size: counts.size
|
|
150
143
|
)
|
|
@@ -153,6 +146,23 @@ module RailsErrorDashboard
|
|
|
153
146
|
baseline
|
|
154
147
|
end
|
|
155
148
|
|
|
149
|
+
# @return [Array<Integer>] one count per bucket in [period_start, period_end)
|
|
150
|
+
#
|
|
151
|
+
# Week boundaries and time zone are passed explicitly so the buckets
|
|
152
|
+
# line up with the Rails `beginning_of_week` / `Time.current` calls the
|
|
153
|
+
# period bounds and the anomaly check use. Groupdate's own default week
|
|
154
|
+
# starts on Sunday; Rails' starts on Monday, and with the two disagreeing
|
|
155
|
+
# a Sunday event and a Monday event landed in one bucket instead of two.
|
|
156
|
+
def bucket_counts(error_type, platform, period, period_start, period_end)
|
|
157
|
+
options = { range: period_start...period_end, time_zone: Time.zone }
|
|
158
|
+
options[:week_start] = Date.beginning_of_week if period == :week # groupdate rejects it for other periods
|
|
159
|
+
|
|
160
|
+
self.class.counting_relation(error_type, platform)
|
|
161
|
+
.group_by_period(period, self.class.time_column, **options)
|
|
162
|
+
.count
|
|
163
|
+
.values
|
|
164
|
+
end
|
|
165
|
+
|
|
156
166
|
# === Pure algorithm methods (no database access) ===
|
|
157
167
|
# These can be called directly as class methods for testability
|
|
158
168
|
|
|
@@ -168,9 +178,12 @@ module RailsErrorDashboard
|
|
|
168
178
|
def self.calculate_statistics(counts)
|
|
169
179
|
return default_stats if counts.empty?
|
|
170
180
|
|
|
171
|
-
# Remove outliers
|
|
181
|
+
# Remove outliers (a storm hour must not become the baseline) — but
|
|
182
|
+
# never the whole signal: for a rare error most zero-filled buckets
|
|
183
|
+
# are 0, the few 1s sit many sigmas out, and trimming them would
|
|
184
|
+
# leave a baseline that says the error never happens.
|
|
172
185
|
clean_counts = remove_outliers(counts)
|
|
173
|
-
|
|
186
|
+
clean_counts = counts if clean_counts.empty? || (clean_counts.sum.zero? && counts.sum.positive?)
|
|
174
187
|
|
|
175
188
|
mean = clean_counts.sum.to_f / clean_counts.size
|
|
176
189
|
variance = clean_counts.map { |c| (c - mean)**2 }.sum / clean_counts.size
|
|
@@ -67,11 +67,20 @@ module RailsErrorDashboard
|
|
|
67
67
|
Digest::SHA256.hexdigest(digest_input)[0..15]
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# Only this many leading characters of the RAW message take part in the
|
|
71
|
+
# hash. The storm gate stores a bounded exemplar (it must stay cheap and
|
|
72
|
+
# memory-bounded per fingerprint), so the full path has to hash the same
|
|
73
|
+
# prefix or a long-message error would change identity the moment the
|
|
74
|
+
# breaker changed state. Truncation happens BEFORE normalization so both
|
|
75
|
+
# paths see byte-identical input.
|
|
76
|
+
HASH_MESSAGE_LIMIT = 500
|
|
77
|
+
|
|
70
78
|
# Normalize dynamic values in error messages for consistent hashing
|
|
71
79
|
# @param message [String, nil] The error message
|
|
72
80
|
# @return [String, nil] Normalized message
|
|
73
81
|
def self.normalize_message(message)
|
|
74
82
|
message
|
|
83
|
+
&.[](0, HASH_MESSAGE_LIMIT)
|
|
75
84
|
&.gsub(/0x[0-9a-f]+/i, "HEX") # Replace hex addresses (before numbers)
|
|
76
85
|
&.gsub(/#<[^>]+>/, "#<OBJ>") # Replace object inspections
|
|
77
86
|
&.gsub(/\d+/, "N") # Replace numbers
|
|
@@ -10,7 +10,10 @@ module RailsErrorDashboard
|
|
|
10
10
|
#
|
|
11
11
|
# Safety contract:
|
|
12
12
|
# - Default OFF (opt-in via config.enable_local_variables / enable_instance_variables)
|
|
13
|
-
# - Never stores Binding objects
|
|
13
|
+
# - Never stores Binding objects — extracts vars immediately in callback
|
|
14
|
+
# - Strings, arrays and hashes are snapshotted (shallow copy, bounded by the
|
|
15
|
+
# serializer's limits) at raise time; other objects are kept by reference,
|
|
16
|
+
# so their state is whatever it is when the error is serialized
|
|
14
17
|
# - Every callback wrapped in rescue => e (never raises)
|
|
15
18
|
# - Per-variable rescue in extraction
|
|
16
19
|
# - Skips SystemExit, SignalException, Interrupt
|
|
@@ -117,6 +120,33 @@ module RailsErrorDashboard
|
|
|
117
120
|
end
|
|
118
121
|
end
|
|
119
122
|
|
|
123
|
+
# A value as it is NOW, for the kinds of value that are cheap to copy.
|
|
124
|
+
#
|
|
125
|
+
# The exception is serialized later, after the stack has unwound —
|
|
126
|
+
# and ensure blocks and rescue handlers run in between, mutating the
|
|
127
|
+
# very state worth seeing (`state["phase"] = "cleanup"`, `@conn = nil`).
|
|
128
|
+
# A reference would show the post-cleanup value and call it the value
|
|
129
|
+
# "at raise". Strings, arrays and hashes get a one-level copy, bounded
|
|
130
|
+
# by what the serializer will keep anyway (+1 so it still knows the
|
|
131
|
+
# original was longer). Anything else is left as a reference: copying
|
|
132
|
+
# arbitrary application objects is neither safe nor cheap. Nested
|
|
133
|
+
# containers inside the copy are therefore still references.
|
|
134
|
+
def snapshot_value(value)
|
|
135
|
+
config = RailsErrorDashboard.configuration
|
|
136
|
+
case value
|
|
137
|
+
when String
|
|
138
|
+
value.frozen? ? value : value[0, (config.local_variable_max_string_length || 200) + 1]
|
|
139
|
+
when Array
|
|
140
|
+
value.first((config.local_variable_max_array_items || 10) + 1)
|
|
141
|
+
when Hash
|
|
142
|
+
value.first((config.local_variable_max_hash_items || 20) + 1).to_h
|
|
143
|
+
else
|
|
144
|
+
value
|
|
145
|
+
end
|
|
146
|
+
rescue
|
|
147
|
+
value
|
|
148
|
+
end
|
|
149
|
+
|
|
120
150
|
# Check if the path should be skipped (gem code, vendor, stdlib, this gem)
|
|
121
151
|
def skip_path?(path)
|
|
122
152
|
path.include?("/gems/") ||
|
|
@@ -142,7 +172,7 @@ module RailsErrorDashboard
|
|
|
142
172
|
|
|
143
173
|
locals = {}
|
|
144
174
|
var_names.each do |name|
|
|
145
|
-
locals[name] = binding_obj.local_variable_get(name)
|
|
175
|
+
locals[name] = snapshot_value(binding_obj.local_variable_get(name))
|
|
146
176
|
rescue => e
|
|
147
177
|
locals[name] = "(extraction error: #{e.class.name})"
|
|
148
178
|
end
|
|
@@ -189,7 +219,7 @@ module RailsErrorDashboard
|
|
|
189
219
|
|
|
190
220
|
# Extract each instance variable (per-variable rescue)
|
|
191
221
|
ivar_names.each do |name|
|
|
192
|
-
result[name] = obj.instance_variable_get(name)
|
|
222
|
+
result[name] = snapshot_value(obj.instance_variable_get(name))
|
|
193
223
|
rescue => e
|
|
194
224
|
result[name] = "(extraction error: #{e.class.name})"
|
|
195
225
|
end
|
|
@@ -17,12 +17,18 @@ module RailsErrorDashboard
|
|
|
17
17
|
# Memory: bounded map; beyond the cap events land in a single overflow
|
|
18
18
|
# counter (still exact in total, anonymous in identity).
|
|
19
19
|
#
|
|
20
|
-
# Concurrency:
|
|
21
|
-
#
|
|
20
|
+
# Concurrency: a read/write lock. Every record holds the READ lock for
|
|
21
|
+
# the few instructions between reading the map reference and bumping
|
|
22
|
+
# the entry's counter; snapshot! takes the WRITE lock only to swap the
|
|
23
|
+
# map out. So once the swap returns no writer can still be holding the
|
|
24
|
+
# old map, and the old map is quiescent while it is serialized outside
|
|
25
|
+
# the lock. AtomicReference alone was not enough: a writer that read the
|
|
26
|
+
# old reference just before the swap would increment a map nobody would
|
|
27
|
+
# ever read again, and the "exact" count lost an event.
|
|
22
28
|
class CountBuffer
|
|
23
29
|
Entry = Struct.new(
|
|
24
30
|
:error_class, :message, :first_app_frame,
|
|
25
|
-
:controller_name, :action_name, :custom_hash,
|
|
31
|
+
:controller_name, :action_name, :custom_hash, :environment,
|
|
26
32
|
:count, :first_seen_at, :last_seen_at
|
|
27
33
|
)
|
|
28
34
|
|
|
@@ -31,6 +37,7 @@ module RailsErrorDashboard
|
|
|
31
37
|
end
|
|
32
38
|
|
|
33
39
|
def reset!
|
|
40
|
+
@lock = Concurrent::ReadWriteLock.new
|
|
34
41
|
@map_ref = Concurrent::AtomicReference.new(Concurrent::Map.new)
|
|
35
42
|
@overflow = Concurrent::AtomicFixnum.new(0)
|
|
36
43
|
end
|
|
@@ -39,25 +46,31 @@ module RailsErrorDashboard
|
|
|
39
46
|
# @param gate_key [String] cheap in-process bucketing key
|
|
40
47
|
# @param parts [Hash] identity parts captured at the gate
|
|
41
48
|
def record(gate_key, parts)
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
@lock.with_read_lock { add(gate_key, parts, 1, Time.current, Time.current) }
|
|
50
|
+
end
|
|
44
51
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
52
|
+
# Put a snapshot BACK when its handoff failed (the flush job could not
|
|
53
|
+
# be enqueued). Counts merge into whatever accumulated since the swap;
|
|
54
|
+
# first_seen_at keeps the earliest of the two; beyond the map cap the
|
|
55
|
+
# events land in the overflow bucket — exact in total, as always.
|
|
56
|
+
# @param entries [Array<Hash>] entries from #snapshot!
|
|
57
|
+
# @param overflow [Integer] overflow from #snapshot!
|
|
58
|
+
def restore(entries, overflow = 0)
|
|
59
|
+
@lock.with_read_lock do
|
|
60
|
+
Array(entries).each do |entry|
|
|
61
|
+
entry = entry.with_indifferent_access if entry.respond_to?(:with_indifferent_access)
|
|
62
|
+
next unless entry.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
add(
|
|
65
|
+
entry["gate_key"],
|
|
66
|
+
parts_from(entry),
|
|
67
|
+
entry["count"].to_i,
|
|
68
|
+
parse_time(entry["first_seen_at"]),
|
|
69
|
+
parse_time(entry["last_seen_at"])
|
|
55
70
|
)
|
|
56
71
|
end
|
|
72
|
+
@overflow.increment(overflow.to_i) if overflow.to_i.positive?
|
|
57
73
|
end
|
|
58
|
-
|
|
59
|
-
entry.count.increment
|
|
60
|
-
entry.last_seen_at = Time.current
|
|
61
74
|
end
|
|
62
75
|
|
|
63
76
|
def any?
|
|
@@ -67,19 +80,23 @@ module RailsErrorDashboard
|
|
|
67
80
|
# Atomically swap the buffer out and return serializable entry hashes.
|
|
68
81
|
# @return [Hash] { entries: Array<Hash>, overflow: Integer }
|
|
69
82
|
def snapshot!
|
|
70
|
-
old_map = @
|
|
71
|
-
|
|
72
|
-
|
|
83
|
+
old_map, overflow = @lock.with_write_lock do
|
|
84
|
+
taken = @overflow.value
|
|
85
|
+
@overflow.update { |v| v - taken }
|
|
86
|
+
[ @map_ref.get_and_set(Concurrent::Map.new), taken ]
|
|
87
|
+
end
|
|
73
88
|
|
|
74
89
|
entries = []
|
|
75
|
-
old_map.each_pair do |
|
|
90
|
+
old_map.each_pair do |key, entry|
|
|
76
91
|
entries << {
|
|
92
|
+
"gate_key" => key,
|
|
77
93
|
"error_class" => entry.error_class,
|
|
78
94
|
"message" => entry.message,
|
|
79
95
|
"first_app_frame" => entry.first_app_frame,
|
|
80
96
|
"controller_name" => entry.controller_name,
|
|
81
97
|
"action_name" => entry.action_name,
|
|
82
98
|
"custom_hash" => entry.custom_hash,
|
|
99
|
+
"environment" => entry.environment,
|
|
83
100
|
"count" => entry.count.value,
|
|
84
101
|
"first_seen_at" => entry.first_seen_at.iso8601,
|
|
85
102
|
"last_seen_at" => entry.last_seen_at.iso8601
|
|
@@ -91,6 +108,50 @@ module RailsErrorDashboard
|
|
|
91
108
|
|
|
92
109
|
private
|
|
93
110
|
|
|
111
|
+
# Callers hold the read lock.
|
|
112
|
+
def add(gate_key, parts, count, first_seen_at, last_seen_at)
|
|
113
|
+
return if count <= 0
|
|
114
|
+
|
|
115
|
+
map = @map_ref.get
|
|
116
|
+
entry = map[gate_key]
|
|
117
|
+
|
|
118
|
+
unless entry
|
|
119
|
+
if map.size >= max_tracked
|
|
120
|
+
@overflow.increment(count)
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
entry = map.compute_if_absent(gate_key) do
|
|
124
|
+
Entry.new(
|
|
125
|
+
parts[:error_class], parts[:message], parts[:first_app_frame],
|
|
126
|
+
parts[:controller_name], parts[:action_name], parts[:custom_hash], parts[:environment],
|
|
127
|
+
Concurrent::AtomicFixnum.new(0), first_seen_at || Time.current, last_seen_at || Time.current
|
|
128
|
+
)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
entry.count.increment(count)
|
|
133
|
+
entry.last_seen_at = [ entry.last_seen_at, last_seen_at ].compact.max
|
|
134
|
+
entry.first_seen_at = [ entry.first_seen_at, first_seen_at ].compact.min
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def parts_from(entry)
|
|
138
|
+
{
|
|
139
|
+
error_class: entry["error_class"], message: entry["message"],
|
|
140
|
+
first_app_frame: entry["first_app_frame"], controller_name: entry["controller_name"],
|
|
141
|
+
action_name: entry["action_name"], custom_hash: entry["custom_hash"],
|
|
142
|
+
environment: entry["environment"]
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def parse_time(value)
|
|
147
|
+
return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
|
|
148
|
+
return nil if value.blank?
|
|
149
|
+
|
|
150
|
+
Time.zone.parse(value.to_s)
|
|
151
|
+
rescue ArgumentError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
94
155
|
def max_tracked
|
|
95
156
|
RailsErrorDashboard.configuration.storm_max_tracked_fingerprints.to_i
|
|
96
157
|
end
|