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.
- checksums.yaml +4 -4
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +54 -0
- data/app/jobs/rails_error_dashboard/storm_flush_job.rb +7 -4
- data/app/models/rails_error_dashboard/error_log.rb +10 -0
- data/app/models/rails_error_dashboard/event_count.rb +132 -0
- data/app/models/rails_error_dashboard/event_timing_gap.rb +55 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +47 -2
- data/app/views/rails_error_dashboard/errors/_request_context.html.erb +2 -0
- data/app/views/rails_error_dashboard/errors/overview.html.erb +12 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +3 -3
- data/config/locales/de.yml +2 -0
- data/config/locales/en.yml +2 -0
- data/config/locales/es.yml +2 -0
- data/config/locales/fr.yml +2 -0
- data/config/locales/it.yml +2 -0
- data/config/locales/ja.yml +2 -0
- data/config/locales/pl.yml +2 -0
- data/config/locales/pt-BR.yml +2 -0
- data/config/locales/ru.yml +2 -0
- data/config/locales/uk.yml +2 -0
- data/config/locales/zh-CN.yml +2 -0
- data/db/migrate/20260919000001_create_event_counts.rb +71 -0
- data/db/migrate/20260920000001_add_buckets_incomplete_to_storm_events.rb +25 -0
- data/db/migrate/20260920000002_create_event_timing_gaps.rb +55 -0
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +70 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +225 -8
- data/lib/rails_error_dashboard/commands/log_error.rb +213 -21
- data/lib/rails_error_dashboard/configuration.rb +20 -0
- data/lib/rails_error_dashboard/engine.rb +13 -0
- data/lib/rails_error_dashboard/manual_error_reporter.rb +16 -5
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +85 -27
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +167 -30
- data/lib/rails_error_dashboard/queries/event_volume.rb +503 -0
- data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +23 -0
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +64 -6
- data/lib/rails_error_dashboard/services/variable_serializer.rb +125 -10
- data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +111 -0
- data/lib/rails_error_dashboard/value_objects/error_context.rb +37 -2
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +3 -0
- 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] =
|
|
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.
|
|
80
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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!
|