rails_error_dashboard 0.13.0 → 0.14.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
  3. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +54 -0
  4. data/app/jobs/rails_error_dashboard/storm_flush_job.rb +7 -4
  5. data/app/models/rails_error_dashboard/error_log.rb +10 -0
  6. data/app/models/rails_error_dashboard/event_count.rb +132 -0
  7. data/app/models/rails_error_dashboard/event_timing_gap.rb +55 -0
  8. data/app/views/layouts/rails_error_dashboard.html.erb +47 -2
  9. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +2 -0
  10. data/app/views/rails_error_dashboard/errors/overview.html.erb +12 -0
  11. data/app/views/rails_error_dashboard/errors/show.html.erb +3 -3
  12. data/config/locales/de.yml +2 -0
  13. data/config/locales/en.yml +2 -0
  14. data/config/locales/es.yml +2 -0
  15. data/config/locales/fr.yml +2 -0
  16. data/config/locales/it.yml +2 -0
  17. data/config/locales/ja.yml +2 -0
  18. data/config/locales/pl.yml +2 -0
  19. data/config/locales/pt-BR.yml +2 -0
  20. data/config/locales/ru.yml +2 -0
  21. data/config/locales/uk.yml +2 -0
  22. data/config/locales/zh-CN.yml +2 -0
  23. data/db/migrate/20260919000001_create_event_counts.rb +71 -0
  24. data/db/migrate/20260920000001_add_buckets_incomplete_to_storm_events.rb +25 -0
  25. data/db/migrate/20260920000002_create_event_timing_gaps.rb +55 -0
  26. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +70 -4
  27. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +225 -8
  28. data/lib/rails_error_dashboard/commands/log_error.rb +213 -21
  29. data/lib/rails_error_dashboard/configuration.rb +20 -0
  30. data/lib/rails_error_dashboard/engine.rb +13 -0
  31. data/lib/rails_error_dashboard/manual_error_reporter.rb +16 -5
  32. data/lib/rails_error_dashboard/queries/analytics_stats.rb +85 -27
  33. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +167 -30
  34. data/lib/rails_error_dashboard/queries/event_volume.rb +503 -0
  35. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +23 -0
  36. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +64 -6
  37. data/lib/rails_error_dashboard/services/variable_serializer.rb +125 -10
  38. data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +111 -0
  39. data/lib/rails_error_dashboard/value_objects/error_context.rb +37 -2
  40. data/lib/rails_error_dashboard/version.rb +1 -1
  41. data/lib/rails_error_dashboard.rb +3 -0
  42. metadata +8 -2
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Time buckets for storm-shed event volume.
4
+ #
5
+ # Every time-window figure on the dashboard ("errors today", the daily trend,
6
+ # the hourly chart, the error rate) used to filter error_logs by occurred_at
7
+ # and then SUM(occurrence_count). occurred_at is FIRST-SEEN and is never
8
+ # rewritten on recurrence, so that sum reports the lifetime volume of the
9
+ # groups born inside the window -- not the events that happened in it. An
10
+ # error first seen at 23:59 and recurring at 00:01 reported "0 errors today"
11
+ # and put both events on yesterday.
12
+ #
13
+ # The obvious fix -- count error_occurrences rows in the window -- is wrong on
14
+ # its own. Storm protection deliberately writes NO occurrence row while
15
+ # shedding: it folds N events into occurrence_count and nothing else. Counting
16
+ # occurrence rows would therefore erase storm volume from every time window,
17
+ # which is exactly when the numbers matter most.
18
+ #
19
+ # So shed volume needs its own timestamp, and this is it: one row per
20
+ # (error group, 15-minute bucket), holding how many shed events landed in it.
21
+ # Window volume is then
22
+ #
23
+ # occurrence rows in the window + shed bucket counts in the window
24
+ #
25
+ # which is correct for both ordinary and shed events.
26
+ #
27
+ # Small by construction: a row is written only while a storm is actually
28
+ # shedding, at most one per group per quarter hour.
29
+ #
30
+ # Cleanup follows the GROUP, not the bucket's own age: RetentionCleanupJob
31
+ # deletes the buckets of groups it expires, and ErrorLog has_many :event_counts
32
+ # with dependent: :delete_all covers an explicit destroy. Buckets of a
33
+ # still-active group are deliberately kept -- pruning them by bucket_at alone
34
+ # would silently redistribute those events onto the group's first-seen date,
35
+ # because EventVolume falls back to the group's lifetime count for any group
36
+ # with no per-event record left.
37
+ #
38
+ # (An earlier version of this comment claimed pruning "on bucket_at" that was
39
+ # never implemented. Both mechanisms above are now covered by
40
+ # spec/models/rails_error_dashboard/event_count_cleanup_spec.rb.)
41
+ class CreateEventCounts < ActiveRecord::Migration[7.0]
42
+ def change
43
+ # Guard against a squashed schema migration having already created this
44
+ # table -- without it, every later migration is silently cancelled.
45
+ return if table_exists?(:rails_error_dashboard_event_counts)
46
+
47
+ create_table :rails_error_dashboard_event_counts do |t|
48
+ t.bigint :error_log_id, null: false
49
+ # Truncated to a 15-MINUTE bucket, in UTC (EventCount::BUCKET_SECONDS,
50
+ # shared with the producer). Not the hour: every UTC offset in use
51
+ # divides into 15 minutes -- including +05:30 and +05:45 -- so a local
52
+ # midnight falls on a bucket EDGE and a day's total is exact. The row
53
+ # count stays bounded even through a long storm: at most one row per
54
+ # group per quarter hour, and only while shedding.
55
+ t.datetime :bucket_at, null: false
56
+ t.bigint :count, null: false, default: 0
57
+ t.timestamps
58
+ end
59
+
60
+ # The upsert target: one bucket per group per quarter hour. Named explicitly --
61
+ # an auto-generated name here would exceed PostgreSQL's 63-character limit
62
+ # and fail during the HOST app's deploy (see mailboxer#480).
63
+ add_index :rails_error_dashboard_event_counts, [ :error_log_id, :bucket_at ],
64
+ unique: true,
65
+ name: "index_red_event_counts_on_group_and_bucket"
66
+
67
+ # Window queries scan by time; retention prunes on the same column.
68
+ add_index :rails_error_dashboard_event_counts, :bucket_at,
69
+ name: "index_red_event_counts_on_bucket_at"
70
+ end
71
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Records that a storm episode lost its per-bucket TIMING evidence.
4
+ #
5
+ # When the rollup table is permanently unavailable (never migrated, or an
6
+ # adapter that refuses the statement), FlushStormCounts deliberately degrades:
7
+ # it keeps the authoritative lifetime occurrence_count and skips the bucket.
8
+ # That is the right trade -- losing a time bucket beats losing a count -- but
9
+ # it leaves every time-window figure for that episode resting on the group's
10
+ # own occurred_at rather than on per-event evidence.
11
+ #
12
+ # The flag was returned in the flush result and then dropped on the floor: no
13
+ # caller persisted it, no query read it, and nothing on the dashboard said so.
14
+ # A replay lost it entirely. Persisting it on the episode is what lets the
15
+ # Overview report an incomplete dimension instead of presenting a figure of
16
+ # unknown completeness as fact -- exactly as affected_users_incomplete already
17
+ # does for storm-shed occurrence rows.
18
+ class AddBucketsIncompleteToStormEvents < ActiveRecord::Migration[7.0]
19
+ def change
20
+ return unless table_exists?(:rails_error_dashboard_storm_events)
21
+ return if column_exists?(:rails_error_dashboard_storm_events, :buckets_incomplete)
22
+
23
+ add_column :rails_error_dashboard_storm_events, :buckets_incomplete, :boolean, default: false
24
+ end
25
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ # One row per interval whose per-event TIMING evidence was lost.
4
+ #
5
+ # When the rollup table is permanently unavailable, FlushStormCounts keeps the
6
+ # authoritative lifetime occurrence_count and skips the bucket. The counts stay
7
+ # exact; only their placement in time becomes approximate. The dashboard has to
8
+ # say so, or it presents a figure of unknown completeness as fact.
9
+ #
10
+ # Three earlier attempts to carry that fact failed, each for a structural
11
+ # reason, and this table exists to fix all three at once:
12
+ #
13
+ # 1. A boolean in the command's RETURN HASH. A background job's return value
14
+ # never reaches the dashboard.
15
+ # 2. A flag on the storm EPISODE. The episode is OPTIONAL -- the gate can
16
+ # shed events with its breaker closed and pass episode: nil -- so the
17
+ # warning silently vanished exactly when no episode existed.
18
+ # 3. Written by upsert_storm_event, which runs AFTER the counts transaction
19
+ # commits and rescues its own failures. A transient save failure lost the
20
+ # marker while the batch ledger recorded the batch as applied, so the
21
+ # replay was suppressed and the gap was never recorded.
22
+ #
23
+ # So: its own table, written INSIDE the same transaction as the counts (either
24
+ # both land or neither does), keyed by the interval it describes rather than by
25
+ # an episode that may not exist.
26
+ #
27
+ # Bounded by construction: one row per (application, bucket) per degraded
28
+ # flush, and only while the rollup is actually unusable -- which is a
29
+ # misconfiguration, not a steady state. Retention prunes it by covered_until.
30
+ class CreateEventTimingGaps < ActiveRecord::Migration[7.0]
31
+ def change
32
+ return if table_exists?(:rails_error_dashboard_event_timing_gaps)
33
+
34
+ create_table :rails_error_dashboard_event_timing_gaps do |t|
35
+ # Nullable: a gap can predate application scoping, and a NULL here means
36
+ # "applies to every application" rather than "unknown".
37
+ t.bigint :application_id
38
+ # The interval whose timing is unreliable. covered_from is the earliest
39
+ # event in the degraded flush, covered_until the latest.
40
+ t.datetime :covered_from, null: false
41
+ t.datetime :covered_until, null: false
42
+ # How many events lost their timestamps, so the UI can say how much of
43
+ # the window is affected rather than only that something is.
44
+ t.bigint :events_affected, null: false, default: 0
45
+ t.timestamps
46
+ end
47
+
48
+ # The read is "does any gap overlap the window I am displaying", which
49
+ # scans on covered_until. Named explicitly -- an auto-generated name here
50
+ # would exceed PostgreSQL's 63-character limit and fail during the HOST
51
+ # app's deploy (see mailboxer#480).
52
+ add_index :rails_error_dashboard_event_timing_gaps, :covered_until,
53
+ name: "index_red_timing_gaps_on_covered_until"
54
+ end
55
+ end
@@ -137,21 +137,78 @@ module RailsErrorDashboard
137
137
  # leaves both the snapshot and its provenance alone -- otherwise the row
138
138
  # would claim a fresh capture time for evidence from an older event,
139
139
  # which is precisely the confusion this exists to remove.
140
- def context_provenance(refreshed)
140
+ def context_provenance(refreshed, error = nil)
141
141
  return {} unless refreshed.any? || refreshed_request_identity?
142
142
  return {} unless ErrorLog.column_names.include?("context_captured_at")
143
143
 
144
144
  provenance = { context_captured_at: @attributes[:occurred_at] || Time.current }
145
145
  if ErrorLog.column_names.include?("context_fidelity")
146
- provenance[:context_fidelity] = @attributes[:_context_fidelity].presence || "full"
146
+ provenance[:context_fidelity] =
147
+ @attributes[:_context_fidelity].presence || snapshot_fidelity(error)
147
148
  end
148
149
  provenance
149
150
  end
150
151
 
152
+ # "full" means every displayed field came from THIS occurrence. When the
153
+ # `||` chain keeps an older value beside a newly refreshed one, the row
154
+ # is showing a mixture of two events, and calling that a fresh full
155
+ # capture is what made the snapshot unreadable: a new request URL sat
156
+ # beside a previous occurrence's user and locals under one timestamp.
157
+ #
158
+ # Keeping the older value is still the right behaviour -- a useful
159
+ # exemplar beats a blank one -- so only the LABEL changes.
160
+ def snapshot_fidelity(error)
161
+ return "full" if error.nil?
162
+
163
+ retains_older_value?(error) ? "partial" : "full"
164
+ end
165
+
166
+ # Every field whose stored value this capture could RETAIN from an
167
+ # earlier occurrence. Defined once, so a field cannot join the displayed
168
+ # snapshot without joining the provenance policy -- which is exactly how
169
+ # local variables came to be shown beside a "full" label and a newer
170
+ # timestamp while belonging to a different event.
171
+ #
172
+ # Request identity plus the context payloads: both are subject to the
173
+ # same `||` chain, and a reader cannot tell them apart on the page.
174
+ PROVENANCE_TRACKED = (REFRESHED_REQUEST_IDENTITY + REFRESHED_CONTEXT).uniq.freeze
175
+
176
+ # True when the row already holds a displayed value that this occurrence
177
+ # did NOT supply, so the `||` chain is about to keep it and the stored
178
+ # snapshot will describe two different events.
179
+ def retains_older_value?(error)
180
+ PROVENANCE_TRACKED.any? do |key|
181
+ next false unless ErrorLog.column_names.include?(key.to_s)
182
+
183
+ @attributes[key].nil? && previous_value(error, key).present?
184
+ end
185
+ end
186
+
187
+ # read_attribute, never public_send: :instance_variables would otherwise
188
+ # resolve to Ruby's own Object#instance_variables if the generated
189
+ # attribute method were ever absent (ignored_columns, load order), and
190
+ # silently compare an Array of symbols against captured context.
191
+ def previous_value(error, key)
192
+ error.read_attribute(key)
193
+ rescue StandardError
194
+ nil
195
+ end
196
+
151
197
  def refreshed_request_identity?
152
198
  REFRESHED_REQUEST_IDENTITY.any? { |key| !@attributes[key].nil? }
153
199
  end
154
200
 
201
+ # Inside the 24-hour matching window find_unresolved uses, so the group
202
+ # this creates can still be found by its own recurrences.
203
+ def clamped_group_time(time)
204
+ return Time.current if time.blank?
205
+
206
+ floor = 24.hours.ago + 1.minute
207
+ time < floor ? floor : time
208
+ rescue StandardError
209
+ Time.current
210
+ end
211
+
155
212
  # A group first seen during a storm has a MINIMAL exemplar: the flush job
156
213
  # could only record the first app frame, because a counted-only event
157
214
  # captures no backtrace. The comment there promises the next occurrence
@@ -205,7 +262,7 @@ module RailsErrorDashboard
205
262
  user_agent: @attributes[:user_agent] || error.user_agent,
206
263
  ip_address: @attributes[:ip_address] || error.ip_address,
207
264
  **refreshed,
208
- **context_provenance(refreshed),
265
+ **context_provenance(refreshed, error),
209
266
  **backtrace_upgrade(error),
210
267
  **environment_adoption(error)
211
268
  )
@@ -225,7 +282,7 @@ module RailsErrorDashboard
225
282
  user_agent: @attributes[:user_agent] || error.user_agent,
226
283
  ip_address: @attributes[:ip_address] || error.ip_address,
227
284
  **(refreshed = latest_context),
228
- **context_provenance(refreshed),
285
+ **context_provenance(refreshed, error),
229
286
  **backtrace_upgrade(error),
230
287
  **environment_adoption(error)
231
288
  }
@@ -245,6 +302,15 @@ module RailsErrorDashboard
245
302
  attrs = @attributes.reject { |key, _| key.to_s.start_with?("_") }
246
303
  attrs = attrs.reverse_merge(resolved: false)
247
304
 
305
+ # The GROUP's occurred_at is clamped into the matching window, even
306
+ # when the EVENT is older. find_unresolved matches on
307
+ # `occurred_at >= 24.hours.ago`, so a backdated report (a mobile client
308
+ # flushing a queue it collected offline) would otherwise create a row
309
+ # that can never be matched again -- every recurrence making yet
310
+ # another group. The event's true time is preserved on its occurrence
311
+ # row, which is what the time-window queries read.
312
+ attrs[:occurred_at] = clamped_group_time(attrs[:occurred_at])
313
+
248
314
  if ErrorLog.column_names.include?("context_captured_at")
249
315
  attrs[:context_captured_at] ||= @attributes[:occurred_at] || Time.current
250
316
  end
@@ -16,6 +16,12 @@ module RailsErrorDashboard
16
16
  # Counts are exact. Notifications are NOT dispatched from here — during a
17
17
  # storm they're suppressed by design; the storm notification covers it.
18
18
  class FlushStormCounts
19
+ # A bucket write failed in a way that may succeed on retry. Raised so the
20
+ # per-entry rescue in #call classifies it exactly as it classifies a
21
+ # transient COUNT failure: roll the batch back, leave the ledger
22
+ # unclaimed, let the job retry the whole batch intact.
23
+ class EventCountWriteFailed < StandardError; end
24
+
19
25
  def self.call(entries:, overflow: 0, episode: nil, batch_id: nil)
20
26
  new(entries: entries, overflow: overflow, episode: episode, batch_id: batch_id).call
21
27
  end
@@ -26,6 +32,10 @@ module RailsErrorDashboard
26
32
  # exemplar becomes an ErrorLog row, and its message is matched by regex.
27
33
  @entries = Array(Services::EncodingSanitizer.scrub_deep(entries))
28
34
  @overflow = overflow.to_i
35
+ # Set when a bucket write was permanently unavailable. The counts are
36
+ # still correct; only their placement in TIME is missing, and a caller
37
+ # reading a time window deserves to know that.
38
+ @buckets_incomplete = false
29
39
  @episode = episode
30
40
  @batch_id = batch_id
31
41
  end
@@ -34,6 +44,7 @@ module RailsErrorDashboard
34
44
  application = resolve_application
35
45
  counted = 0
36
46
  failed = 0
47
+ aborted = false
37
48
 
38
49
  # Counts are applied additively (occurrence_count + N), which is not
39
50
  # idempotent: delivering the same snapshot twice counted it twice. The
@@ -55,6 +66,20 @@ module RailsErrorDashboard
55
66
  @entries.each do |entry|
56
67
  entry = entry.with_indifferent_access if entry.respond_to?(:with_indifferent_access)
57
68
  counted += reconcile_entry(entry, application)
69
+ rescue EventCountWriteFailed, *Commands::LogError::RETRYABLE_STORE_ERRORS => e
70
+ # A transient store failure is NOT a bad entry. Claiming the batch
71
+ # here would commit the ledger row and strand every entry not yet
72
+ # applied: the retry is then suppressed as a replay and those events
73
+ # are lost for good. Re-raise so the whole transaction rolls back --
74
+ # nothing was committed, so nothing can double -- and let the job
75
+ # retry the batch intact. The generic rescue below still keeps a
76
+ # permanently malformed entry from poisoning its batch.
77
+ failed += 1
78
+ aborted = true
79
+ RailsErrorDashboard::Logger.error(
80
+ "[RailsErrorDashboard] Storm batch aborted by a transient store failure: #{e.class} - #{e.message}"
81
+ )
82
+ raise
58
83
  rescue => e
59
84
  # A corrupt (non-Hash) entry must not abort the whole batch — and the
60
85
  # log line itself must not assume `entry` is subscriptable (an Integer
@@ -70,26 +95,56 @@ module RailsErrorDashboard
70
95
  # roll the claim back and let the job retry the whole batch.
71
96
  raise ActiveRecord::Rollback if failed.positive? && counted.zero?
72
97
 
98
+ # INSIDE the transaction, deliberately.
99
+ #
100
+ # The counts and the record that their timing is unreliable have to
101
+ # land together or not at all. Writing this after the commit (as the
102
+ # storm-episode marker did) meant a transient failure lost the
103
+ # marker while the ledger had already recorded the batch as applied
104
+ # -- the replay was then suppressed and the gap was never recorded,
105
+ # so the dashboard reported completeness it could not vouch for.
106
+ #
107
+ # If this write fails, the whole batch rolls back and stays
108
+ # replayable. Counts whose unreliability we cannot record are worth
109
+ # retrying, not committing silently.
110
+ record_timing_gap!(counted) if @buckets_incomplete && counted.positive?
111
+
73
112
  finalize_batch!(ledger, counted)
74
113
  end
75
114
 
76
115
  # Every entry failed and none was written. Reporting success with
77
116
  # reconciled: 0 made a total loss indistinguishable from an empty
78
117
  # batch, so the job acknowledged counts that never reached the
79
- # database. Partial success stays successful: the entries that were
80
- # written are written, and replaying the batch would double them.
118
+ # database.
119
+ #
120
+ # Reaching here means every failure was PERMANENT -- a transient store
121
+ # failure re-raises above and rolls the whole batch back. Partial
122
+ # success over permanent failures stays successful: the entries that
123
+ # were written are written, replaying would double them, and retrying a
124
+ # corrupt payload only loops forever.
81
125
  if failed.positive? && counted.zero?
82
- return { success: false, reconciled: 0, failed: failed, overflow: @overflow,
126
+ return { success: false, retryable: false, reconciled: 0, failed: failed, overflow: @overflow,
127
+ buckets_incomplete: @buckets_incomplete,
83
128
  error: "all #{failed} entries failed to reconcile" }
84
129
  end
85
130
 
86
131
  upsert_storm_event(counted)
87
- { success: true, reconciled: counted, failed: failed, overflow: @overflow }
132
+ result = { success: true, reconciled: counted, failed: failed, overflow: @overflow }
133
+ result[:buckets_incomplete] = true if @buckets_incomplete
134
+ result
88
135
  rescue => e
89
136
  RailsErrorDashboard::Logger.error(
90
137
  "[RailsErrorDashboard] FlushStormCounts failed: #{e.class} - #{e.message}"
91
138
  )
92
- { success: false, error: "#{e.class}: #{e.message}" }
139
+ # retryable: true says "the batch is intact, replay it" -- nothing was
140
+ # committed, so the job can retry without doubling. A permanent failure
141
+ # carries no such promise.
142
+ retryable = Commands::LogError::RETRYABLE_STORE_ERRORS.any? { |klass| e.is_a?(klass) }
143
+ result = { success: false, retryable: retryable, error: "#{e.class}: #{e.message}" }
144
+ # reconciled: 0 because the transaction rolled back -- whatever this
145
+ # batch had counted in memory never reached the database.
146
+ result.merge!(reconciled: 0, failed: failed, overflow: @overflow) if aborted
147
+ result
93
148
  end
94
149
 
95
150
  private
@@ -148,7 +203,68 @@ module RailsErrorDashboard
148
203
  end
149
204
 
150
205
 
206
+ # Reconcile one buffered entry and, on every path that adds counts, give
207
+ # those shed events a TIME BUCKET as well as a total.
208
+ #
209
+ # occurrence_count alone is a lifetime counter: it says how many events
210
+ # there were but not when, so no window query can place them. Ordinary
211
+ # captures carry their own ErrorOccurrence row; shed events write none by
212
+ # design, which is what made them invisible to "errors today". The bucket
213
+ # written here is what Queries::EventVolume adds to the occurrence rows.
151
214
  def reconcile_entry(entry, application)
215
+ error_log_id = nil
216
+ count = reconcile_entry_count(entry, application) { |id| error_log_id = id }
217
+ write_event_buckets(entry, error_log_id, count) if count.positive? && error_log_id
218
+ count
219
+ end
220
+
221
+ # Write one row per BUCKET the producer recorded, not one row for the
222
+ # whole entry.
223
+ #
224
+ # The buffer tallies events per 15-minute bucket precisely so this does
225
+ # not have to guess: assigning the entry's whole total to last_seen_at's
226
+ # bucket put an event from 23:59:59 and one from 00:00:01 on the same
227
+ # day. A payload from an older release carries no buckets, so it falls
228
+ # back to the old behaviour rather than losing the count.
229
+ def write_event_buckets(entry, error_log_id, count)
230
+ buckets = entry["buckets"]
231
+ buckets = nil unless buckets.is_a?(Hash) && buckets.any?
232
+
233
+ pairs =
234
+ if buckets
235
+ buckets.map { |at, n| [ Time.zone.at(at.to_i), n.to_i ] }
236
+ else
237
+ [ [ parse_time(entry["last_seen_at"]) || Time.current, count ] ]
238
+ end
239
+
240
+ pairs.each do |bucket_at, n|
241
+ next unless n.positive?
242
+
243
+ # Three outcomes, three responses.
244
+ #
245
+ # :written -- done.
246
+ # raises -- TRANSIENT. Handled by the caller's rescue, which
247
+ # aborts and replays the batch intact. Swallowing it
248
+ # finalized the ledger with the bucket missing, so
249
+ # the replay was suppressed as already-applied and
250
+ # the time window lost those events permanently
251
+ # while the lifetime count stayed correct.
252
+ # :unavailable -- PERMANENT. Degrade: the rollup is simply not
253
+ # usable on this host (never migrated, adapter
254
+ # refuses the statement). Raising here rolled back
255
+ # the surrounding transaction and destroyed the
256
+ # authoritative lifetime count along with it --
257
+ # turning a missing time bucket into a lost count,
258
+ # which is strictly worse. Record it instead, so the
259
+ # result can say the timing evidence is incomplete.
260
+ case EventCount.accumulate(error_log_id: error_log_id, bucket_at: bucket_at, count: n)
261
+ when :written then next
262
+ else @buckets_incomplete = true
263
+ end
264
+ end
265
+ end
266
+
267
+ def reconcile_entry_count(entry, application)
152
268
  count = entry["count"].to_i
153
269
  return 0 if count <= 0
154
270
 
@@ -185,6 +301,7 @@ module RailsErrorDashboard
185
301
  "occurrence_count = occurrence_count + ?, last_seen_at = ?", count, last_seen
186
302
  ])
187
303
  end
304
+ yield target.id if block_given?
188
305
  return count
189
306
  end
190
307
 
@@ -197,18 +314,43 @@ module RailsErrorDashboard
197
314
  resolved_scope = resolved_scope.where(environment: [ env, nil ])
198
315
  .order(Arel.sql("CASE WHEN environment IS NULL THEN 1 ELSE 0 END"))
199
316
  end
200
- resolved = resolved_scope.order(last_seen_at: :desc).first
317
+ # .lock (SELECT ... FOR UPDATE) held to commit by the transaction opened
318
+ # in #call, exactly as FindOrIncrementError does for the same reopen.
319
+ # Without it this branch read occurrence_count into Ruby and wrote an
320
+ # ABSOLUTE value back, so two concurrent batches both read N and both
321
+ # wrote N+count -- one batch's events vanished while both reported
322
+ # success. The unresolved branch above is safe because it increments in
323
+ # SQL; this branch cannot use update_all because reopening is a state
324
+ # transition the dashboard must see, and update_all skips the
325
+ # after_update_commit broadcast.
326
+ resolved = resolved_scope.lock.order(last_seen_at: :desc).first
201
327
  if resolved
328
+ # The count is incremented in SQL, never read into Ruby and written
329
+ # back. This branch used to compute `resolved.occurrence_count + count`
330
+ # and write that ABSOLUTE value, so two concurrent batches both read N
331
+ # and both wrote N+count -- one batch's events vanished while both
332
+ # reported success. The .lock above serializes the pair on
333
+ # PostgreSQL/MySQL; the atomic increment below conserves the count on
334
+ # every adapter, including SQLite where FOR UPDATE is a no-op.
335
+ ErrorLog.where(id: resolved.id).update_all([
336
+ "occurrence_count = occurrence_count + ?", count
337
+ ])
338
+
339
+ # The reopen is a state transition the dashboard must see, so it stays
340
+ # an update! -- update_all would skip the after_update_commit
341
+ # broadcast. Reload first so this write does not clobber the increment
342
+ # just made with a stale in-memory occurrence_count.
343
+ resolved.reload
202
344
  attrs = {
203
345
  resolved: false,
204
346
  status: "new",
205
347
  resolved_at: nil,
206
- occurrence_count: resolved.occurrence_count + count,
207
348
  last_seen_at: last_seen
208
349
  }
209
350
  attrs[:reopened_at] = Time.current if ErrorLog.column_names.include?("reopened_at")
210
351
  attrs[:environment] = env if env && resolved.environment.blank?
211
352
  resolved.update!(attrs)
353
+ yield resolved.id if block_given?
212
354
  return count
213
355
  end
214
356
 
@@ -249,10 +391,48 @@ module RailsErrorDashboard
249
391
  if ErrorLog.column_names.include?("context_captured_at")
250
392
  create_attrs[:context_captured_at] = create_attrs[:occurred_at]
251
393
  end
252
- ErrorLog.create!(**ErrorLog.clamp_string_attributes(Services::SensitiveDataFilter.filter_attributes(create_attrs)))
394
+ begin
395
+ # requires_new opens a SAVEPOINT: on PostgreSQL a failed INSERT aborts
396
+ # its transaction, and every later statement -- including the recovery
397
+ # lookup below -- fails with InFailedSqlTransaction. The savepoint
398
+ # confines the damage to this INSERT so the batch can continue.
399
+ ErrorLog.transaction(requires_new: true) do
400
+ created = ErrorLog.create!(**ErrorLog.clamp_string_attributes(Services::SensitiveDataFilter.filter_attributes(create_attrs)))
401
+ yield created.id if block_given?
402
+ end
403
+ rescue ActiveRecord::RecordNotUnique
404
+ # Another flush created this group between our lookups and this
405
+ # INSERT -- the group-identity index objected. Two concurrent batches
406
+ # for the same fingerprint is the NORMAL storm shape (every process
407
+ # flushes its own batch), so this must not fail the entry: the counts
408
+ # exist nowhere but in this payload. Re-run the same lookups and add
409
+ # to the row that now exists, exactly as FindOrIncrementError does.
410
+ #
411
+ # Nested in its own transaction because the failed INSERT poisons the
412
+ # surrounding one on PostgreSQL.
413
+ raise unless (target = existing_target(error_hash, application, env))
414
+
415
+ ErrorLog.where(id: target.id).update_all([
416
+ "occurrence_count = occurrence_count + ?, last_seen_at = ?", count, last_seen
417
+ ])
418
+ # Yield on the RECOVERY path too: these counts are as real as the ones
419
+ # the winning INSERT wrote, so they need a time bucket as well, or a
420
+ # raced create silently loses its volume from every window query.
421
+ yield target.id if block_given?
422
+ end
253
423
  count
254
424
  end
255
425
 
426
+ # The row a retried INSERT should add to: any row holding this group
427
+ # identity, whatever its status. Deliberately wider than the
428
+ # priority-ordered lookups above -- the index has already proved a row
429
+ # with this identity exists, so refusing to match a resolved or wont_fix
430
+ # one would drop the counts instead.
431
+ def existing_target(error_hash, application, env)
432
+ scope = ErrorLog.where(error_hash: error_hash, application_id: application.id)
433
+ scope = scope.where(environment: [ env, nil ]) if env
434
+ scope.order(last_seen_at: :desc).select(:id).first
435
+ end
256
436
 
257
437
  # The unresolved row the full capture path would increment right now.
258
438
  # No time window: "won't fix" holds for as long as the row keeps the status.
@@ -326,6 +506,36 @@ module RailsErrorDashboard
326
506
  Application.find_or_create_by_name(app_name)
327
507
  end
328
508
 
509
+ # Record the interval whose per-event timing was lost.
510
+ #
511
+ # Keyed by the interval, NOT by a storm episode: the gate can shed events
512
+ # with its breaker closed and pass episode: nil, and a marker on the
513
+ # episode vanished in exactly that case. The events are just as
514
+ # untimed whether or not an episode object happens to exist.
515
+ #
516
+ # Bounds come from the entries themselves, so the gap describes when the
517
+ # events actually happened rather than when the worker got to them.
518
+ def record_timing_gap!(counted)
519
+ return unless EventTimingGap.table_exists?
520
+
521
+ times = @entries.filter_map do |entry|
522
+ entry = entry.with_indifferent_access if entry.respond_to?(:with_indifferent_access)
523
+ parse_time(entry["last_seen_at"]) || parse_time(entry["first_seen_at"])
524
+ end
525
+ first_seen = @entries.filter_map do |entry|
526
+ entry = entry.with_indifferent_access if entry.respond_to?(:with_indifferent_access)
527
+ parse_time(entry["first_seen_at"])
528
+ end
529
+
530
+ now = Time.current
531
+ EventTimingGap.create!(
532
+ application_id: resolve_application&.id,
533
+ covered_from: (first_seen + times).min || now,
534
+ covered_until: times.max || now,
535
+ events_affected: counted
536
+ )
537
+ end
538
+
329
539
  def upsert_storm_event(counted)
330
540
  return unless @episode.is_a?(Hash)
331
541
  return unless StormEvent.table_exists?
@@ -346,6 +556,13 @@ module RailsErrorDashboard
346
556
  event.fingerprints_affected = [ event.fingerprints_affected.to_i, @entries.size ].max
347
557
  event.peak_rate_per_minute = [ event.peak_rate_per_minute.to_i, @episode["peak_rate_per_minute"].to_i ].max
348
558
  event.reached_open ||= @episode["reached_open"] == true
559
+ # Sticky, like reached_open: once an episode has lost bucket timing it
560
+ # has lost it, and a later flush that happens to succeed does not make
561
+ # the earlier gap reappear. Guarded on the column so a host that has
562
+ # not run the migration yet keeps flushing normally.
563
+ if @buckets_incomplete && event.respond_to?(:buckets_incomplete)
564
+ event.buckets_incomplete = true
565
+ end
349
566
  event.top_fingerprints = top_fingerprints_json(event)
350
567
  event.ended_at = parse_time(@episode["ended_at"]) if @episode["ended_at"]
351
568
  event.save!