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.
Files changed (25) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -2
  3. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +31 -3
  4. data/app/jobs/rails_error_dashboard/baseline_calculation_job.rb +21 -0
  5. data/app/models/rails_error_dashboard/error_log.rb +4 -7
  6. data/app/models/rails_error_dashboard/error_logs_record.rb +34 -0
  7. data/config/locales/fr.yml +30 -30
  8. data/db/migrate/20260908000001_add_release_to_error_occurrences.rb +45 -0
  9. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +20 -9
  10. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +63 -28
  11. data/lib/rails_error_dashboard/commands/log_error.rb +11 -2
  12. data/lib/rails_error_dashboard/configuration.rb +7 -0
  13. data/lib/rails_error_dashboard/queries/analytics_stats.rb +4 -1
  14. data/lib/rails_error_dashboard/queries/baseline_stats.rb +39 -4
  15. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +5 -16
  16. data/lib/rails_error_dashboard/queries/release_timeline.rb +72 -1
  17. data/lib/rails_error_dashboard/services/baseline_calculator.rb +75 -62
  18. data/lib/rails_error_dashboard/services/error_hash_generator.rb +9 -0
  19. data/lib/rails_error_dashboard/services/local_variable_capturer.rb +33 -3
  20. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +83 -22
  21. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +52 -12
  22. data/lib/rails_error_dashboard/services/system_health_snapshot.rb +61 -2
  23. data/lib/rails_error_dashboard/value_objects/error_context.rb +19 -2
  24. data/lib/rails_error_dashboard/version.rb +1 -1
  25. 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
- def check_anomaly(current_count, sensitivity: 2)
89
- baseline = hourly_baseline || daily_baseline || weekly_baseline
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(current_count, sensitivity: sensitivity)
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(current_count)
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
- stats = Queries::BaselineStats.new(error_type, platform)
185
- error_count = base_scope.where(
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
- stats = Queries::BaselineStats.new(error_type, platform)
202
- error_count = base_scope.where(
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: error_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
- # Uses existing app_version and git_sha columns on error_logs — no new migration needed.
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
- # Calculate hourly baseline (last 4 weeks, by hour of day)
76
- def calculate_hourly_baseline(error_type, platform)
77
- period_start = HOURLY_LOOKBACK.ago.beginning_of_hour
78
- period_end = Time.current.beginning_of_hour
79
-
80
- # Get error counts grouped by hour
81
- hourly_counts = ErrorLog
82
- .where(error_type: error_type, platform: platform)
83
- .where("occurred_at >= ?", period_start)
84
- .group("strftime('%H', occurred_at)")
85
- .count
86
-
87
- return nil if hourly_counts.empty?
88
-
89
- counts = hourly_counts.values
90
- stats = calculate_statistics(counts)
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
- baseline = Commands::UpsertBaseline.call(
93
- error_type: error_type, platform: platform, baseline_type: "hourly",
94
- period_start: period_start, period_end: period_end,
95
- stats: stats, count: counts.sum, sample_size: counts.size
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
- @calculated_count += 1
99
- baseline
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, by day of week)
110
+ # Calculate daily baseline (last 12 weeks, one sample per calendar day)
103
111
  def calculate_daily_baseline(error_type, platform)
104
- period_start = DAILY_LOOKBACK.ago.beginning_of_day
105
- period_end = Time.current.beginning_of_day
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, by week)
116
+ # Calculate weekly baseline (last 1 year, one sample per calendar week)
130
117
  def calculate_weekly_baseline(error_type, platform)
131
- period_start = WEEKLY_LOOKBACK.ago.beginning_of_week
132
- period_end = Time.current.beginning_of_week
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
- return nil if weekly_counts.empty?
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: "weekly",
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
- return default_stats if clean_counts.empty?
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 or object references — extracts vars immediately in callback
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: snapshot! atomically swaps the whole map out via
21
- # AtomicReference, so flushing never races with recording.
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
- map = @map_ref.get
43
- entry = map[gate_key]
49
+ @lock.with_read_lock { add(gate_key, parts, 1, Time.current, Time.current) }
50
+ end
44
51
 
45
- unless entry
46
- if map.size >= max_tracked
47
- @overflow.increment
48
- return
49
- end
50
- entry = map.compute_if_absent(gate_key) do
51
- Entry.new(
52
- parts[:error_class], parts[:message], parts[:first_app_frame],
53
- parts[:controller_name], parts[:action_name], parts[:custom_hash],
54
- Concurrent::AtomicFixnum.new(0), Time.current, Time.current
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 = @map_ref.get_and_set(Concurrent::Map.new)
71
- overflow = @overflow.value
72
- @overflow.update { |v| v - overflow }
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 |_key, entry|
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