rails_error_dashboard 0.11.9 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +4 -4
- data/app/controllers/rails_error_dashboard/errors_controller.rb +14 -4
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +79 -6
- data/app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/application_job.rb +56 -14
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +22 -4
- data/app/jobs/rails_error_dashboard/close_linked_issue_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/create_issue_job.rb +1 -1
- data/app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb +4 -2
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +25 -0
- data/app/jobs/rails_error_dashboard/storm_flush_job.rb +22 -4
- data/app/models/rails_error_dashboard/error_log.rb +47 -0
- data/app/models/rails_error_dashboard/storm_flush_batch.rb +50 -0
- data/app/views/rails_error_dashboard/errors/_request_context.html.erb +27 -1
- data/app/views/rails_error_dashboard/errors/_stats.html.erb +6 -0
- data/app/views/rails_error_dashboard/errors/overview.html.erb +13 -1
- data/config/locales/de.yml +9 -0
- data/config/locales/en.yml +35 -0
- data/config/locales/es.yml +9 -0
- data/config/locales/fr.yml +10 -1
- data/config/locales/it.yml +9 -0
- data/config/locales/ja.yml +9 -0
- data/config/locales/pl.yml +9 -0
- data/config/locales/pt-BR.yml +9 -0
- data/config/locales/ru.yml +9 -0
- data/config/locales/uk.yml +9 -0
- data/config/locales/zh-CN.yml +9 -0
- data/db/migrate/20260915000001_add_group_identity_unique_index_to_error_logs.rb +207 -0
- data/db/migrate/20260915000002_add_issue_repo_identity_to_error_logs.rb +89 -0
- data/db/migrate/20260915000003_add_context_provenance_to_error_logs.rb +42 -0
- data/db/migrate/20260915000004_create_storm_flush_batches.rb +50 -0
- data/lib/rails_error_dashboard/commands/create_issue.rb +10 -2
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +135 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +139 -34
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +20 -2
- data/lib/rails_error_dashboard/commands/log_error.rb +206 -19
- data/lib/rails_error_dashboard/configuration.rb +4 -3
- data/lib/rails_error_dashboard/engine.rb +28 -0
- data/lib/rails_error_dashboard/integrations/tracer.rb +26 -7
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +147 -61
- data/lib/rails_error_dashboard/queries/user_impact_summary.rb +57 -7
- data/lib/rails_error_dashboard/services/error_hash_generator.rb +61 -19
- data/lib/rails_error_dashboard/services/issue_tracker_client.rb +38 -0
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +22 -3
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +101 -14
- data/lib/rails_error_dashboard/version.rb +1 -1
- metadata +9 -3
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Say WHICH occurrence supplied the diagnostic snapshot a group displays.
|
|
4
|
+
#
|
|
5
|
+
# An ErrorLog row is a group, but its breadcrumbs, system health, locals,
|
|
6
|
+
# instance variables and request context describe ONE moment of failure. They
|
|
7
|
+
# are refreshed by each occurrence that carries them, and deliberately left
|
|
8
|
+
# alone by one that does not (a storm :lite capture, or a feature switched
|
|
9
|
+
# off) -- keeping a useful snapshot rather than blanking it is right.
|
|
10
|
+
#
|
|
11
|
+
# What was missing is provenance. The page labelled that collection as the
|
|
12
|
+
# error's context without saying which event it came from, so a row could show
|
|
13
|
+
# a new request URL beside a previous occurrence's user id and locals with
|
|
14
|
+
# nothing to indicate they came from different requests.
|
|
15
|
+
#
|
|
16
|
+
# context_captured_at when the displayed snapshot was captured
|
|
17
|
+
# context_fidelity how complete that capture was:
|
|
18
|
+
# "full" -- the normal path, everything captured
|
|
19
|
+
# "lite" -- storm shedding: error + occurrence row
|
|
20
|
+
# only, context payloads shed
|
|
21
|
+
# "minimal" -- reconstructed by the storm flush from a
|
|
22
|
+
# counted-only exemplar (no backtrace,
|
|
23
|
+
# no context at all)
|
|
24
|
+
#
|
|
25
|
+
# Both are nullable: rows captured before this migration have no recorded
|
|
26
|
+
# provenance, and the view says so rather than inventing one.
|
|
27
|
+
class AddContextProvenanceToErrorLogs < ActiveRecord::Migration[7.0]
|
|
28
|
+
TABLE = :rails_error_dashboard_error_logs
|
|
29
|
+
|
|
30
|
+
def change
|
|
31
|
+
return unless table_exists?(TABLE)
|
|
32
|
+
|
|
33
|
+
unless column_exists?(TABLE, :context_captured_at)
|
|
34
|
+
add_column TABLE, :context_captured_at, :datetime
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
unless column_exists?(TABLE, :context_fidelity)
|
|
38
|
+
# 10: "full" / "lite" / "minimal".
|
|
39
|
+
add_column TABLE, :context_fidelity, :string, limit: 10
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Batch identity for storm count reconciliation, so a replayed batch is
|
|
4
|
+
# applied exactly once.
|
|
5
|
+
#
|
|
6
|
+
# FlushStormCounts applies counts additively:
|
|
7
|
+
#
|
|
8
|
+
# UPDATE error_logs SET occurrence_count = occurrence_count + N
|
|
9
|
+
#
|
|
10
|
+
# which is not idempotent. Delivering the identical five-event snapshot twice
|
|
11
|
+
# persisted ten occurrences. The realistic replay path is the job's own
|
|
12
|
+
# retry: StormFlushJob raises when a batch reconciles nothing, and
|
|
13
|
+
# ApplicationJob retries it three times with the identical payload -- so a
|
|
14
|
+
# batch that partially applied and then failed was re-applied in full on the
|
|
15
|
+
# next attempt. A queue that redelivers does the same thing.
|
|
16
|
+
#
|
|
17
|
+
# This table is the ledger. A digest of the batch is inserted in the SAME
|
|
18
|
+
# transaction as the increments, so either both land or neither does. A
|
|
19
|
+
# replay hits the unique index, and the command reports the batch as already
|
|
20
|
+
# applied instead of counting it again.
|
|
21
|
+
#
|
|
22
|
+
# Small and self-expiring: at most one row per flush interval per process
|
|
23
|
+
# (~120/hour while a storm is actually running, none otherwise), pruned by
|
|
24
|
+
# RetentionCleanupJob alongside the rack-attack events.
|
|
25
|
+
class CreateStormFlushBatches < ActiveRecord::Migration[7.0]
|
|
26
|
+
def change
|
|
27
|
+
# Guard against a squashed schema migration having already created this
|
|
28
|
+
# table -- without it, every later migration is silently cancelled.
|
|
29
|
+
return if table_exists?(:rails_error_dashboard_storm_flush_batches)
|
|
30
|
+
|
|
31
|
+
create_table :rails_error_dashboard_storm_flush_batches do |t|
|
|
32
|
+
# SHA256 hex of the batch's identity parts. 64 chars, far inside
|
|
33
|
+
# MySQL's 3072-byte utf8mb4 index limit (64 * 4 + 2 = 258 bytes).
|
|
34
|
+
t.string :digest, null: false, limit: 64
|
|
35
|
+
# What the batch carried, for operators reading the ledger directly.
|
|
36
|
+
t.integer :entry_count, null: false, default: 0
|
|
37
|
+
t.bigint :occurrences_applied, null: false, default: 0
|
|
38
|
+
t.datetime :applied_at, null: false
|
|
39
|
+
t.timestamps
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
add_index :rails_error_dashboard_storm_flush_batches, :digest,
|
|
43
|
+
unique: true,
|
|
44
|
+
name: "index_storm_flush_batches_on_digest"
|
|
45
|
+
|
|
46
|
+
# Retention prunes on this column.
|
|
47
|
+
add_index :rails_error_dashboard_storm_flush_batches, :applied_at,
|
|
48
|
+
name: "index_storm_flush_batches_on_applied_at"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -42,11 +42,19 @@ module RailsErrorDashboard
|
|
|
42
42
|
result = client.create_issue(title: title, body: body, labels: labels)
|
|
43
43
|
|
|
44
44
|
if result[:success]
|
|
45
|
-
|
|
45
|
+
attrs = {
|
|
46
46
|
external_issue_url: result[:url],
|
|
47
47
|
external_issue_number: result[:number],
|
|
48
48
|
external_issue_provider: config.effective_issue_tracker_provider.to_s
|
|
49
|
-
|
|
49
|
+
}
|
|
50
|
+
# Record WHICH repository (or Linear team) this issue was opened in,
|
|
51
|
+
# so a later webhook, comment or close targets that one rather than
|
|
52
|
+
# whatever the global configuration happens to say at the time.
|
|
53
|
+
if ErrorLog.column_names.include?("external_issue_repo")
|
|
54
|
+
attrs[:external_issue_repo] = config.effective_issue_tracker_repo
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
error.update!(attrs)
|
|
50
58
|
{ success: true, issue_url: result[:url], issue_number: result[:number] }
|
|
51
59
|
else
|
|
52
60
|
{ success: false, error: result[:error] }
|
|
@@ -93,6 +93,71 @@ module RailsErrorDashboard
|
|
|
93
93
|
end
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
+
# The request-identity fields the detail page shows beside the context
|
|
97
|
+
# payloads. They are refreshed by the `||` chain in increment_existing,
|
|
98
|
+
# so a capture can move the displayed URL without touching any
|
|
99
|
+
# REFRESHED_CONTEXT key -- and the provenance has to follow the whole
|
|
100
|
+
# displayed snapshot, not just part of it.
|
|
101
|
+
REFRESHED_REQUEST_IDENTITY = %i[
|
|
102
|
+
user_id request_url request_params user_agent ip_address
|
|
103
|
+
].freeze
|
|
104
|
+
|
|
105
|
+
# Provenance for the snapshot the row will DISPLAY.
|
|
106
|
+
#
|
|
107
|
+
# Stamped whenever this occurrence refreshed ANY displayed field. An
|
|
108
|
+
# occurrence that carried nothing (storm :lite, feature switched off)
|
|
109
|
+
# leaves both the snapshot and its provenance alone -- otherwise the row
|
|
110
|
+
# would claim a fresh capture time for evidence from an older event,
|
|
111
|
+
# which is precisely the confusion this exists to remove.
|
|
112
|
+
def context_provenance(refreshed)
|
|
113
|
+
return {} unless refreshed.any? || refreshed_request_identity?
|
|
114
|
+
return {} unless ErrorLog.column_names.include?("context_captured_at")
|
|
115
|
+
|
|
116
|
+
provenance = { context_captured_at: @attributes[:occurred_at] || Time.current }
|
|
117
|
+
if ErrorLog.column_names.include?("context_fidelity")
|
|
118
|
+
provenance[:context_fidelity] = @attributes[:_context_fidelity].presence || "full"
|
|
119
|
+
end
|
|
120
|
+
provenance
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def refreshed_request_identity?
|
|
124
|
+
REFRESHED_REQUEST_IDENTITY.any? { |key| !@attributes[key].nil? }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# A group first seen during a storm has a MINIMAL exemplar: the flush job
|
|
128
|
+
# could only record the first app frame, because a counted-only event
|
|
129
|
+
# captures no backtrace. The comment there promises the next occurrence
|
|
130
|
+
# fills in detail -- it never did, because nothing replaced backtrace on
|
|
131
|
+
# an existing row, so the group kept a single bare path with no line
|
|
132
|
+
# number or caller frame for its whole life.
|
|
133
|
+
#
|
|
134
|
+
# A capture that HAS a real backtrace now upgrades it. Only a full one:
|
|
135
|
+
# a :lite capture sheds context by design and must not overwrite good
|
|
136
|
+
# evidence with less.
|
|
137
|
+
def backtrace_upgrade(error)
|
|
138
|
+
return {} unless @attributes[:backtrace].present?
|
|
139
|
+
return {} if storm_lite?
|
|
140
|
+
|
|
141
|
+
stored = error.backtrace.to_s
|
|
142
|
+
return {} if stored.include?("\n") # already a real stack
|
|
143
|
+
|
|
144
|
+
incoming = @attributes[:backtrace].to_s
|
|
145
|
+
return {} unless incoming.include?("\n") || incoming.length > stored.length
|
|
146
|
+
|
|
147
|
+
upgrade = { backtrace: @attributes[:backtrace] }
|
|
148
|
+
# The row is no longer a reconstructed exemplar: it now carries a real
|
|
149
|
+
# stack from a real capture, so it must stop describing itself as
|
|
150
|
+
# "minimal".
|
|
151
|
+
if ErrorLog.column_names.include?("context_fidelity") && error.context_fidelity.to_s == "minimal"
|
|
152
|
+
upgrade[:context_fidelity] = @attributes[:_context_fidelity].presence || "full"
|
|
153
|
+
end
|
|
154
|
+
upgrade
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def storm_lite?
|
|
158
|
+
@attributes[:_context_fidelity].to_s == "lite"
|
|
159
|
+
end
|
|
160
|
+
|
|
96
161
|
# {} unless this is a legacy NULL-environment row being claimed.
|
|
97
162
|
def environment_adoption(error)
|
|
98
163
|
return {} unless ErrorLog.column_names.include?("environment")
|
|
@@ -102,6 +167,7 @@ module RailsErrorDashboard
|
|
|
102
167
|
end
|
|
103
168
|
|
|
104
169
|
def increment_existing(error)
|
|
170
|
+
refreshed = latest_context
|
|
105
171
|
error.update!(
|
|
106
172
|
occurrence_count: error.occurrence_count + 1,
|
|
107
173
|
last_seen_at: Time.current,
|
|
@@ -110,7 +176,9 @@ module RailsErrorDashboard
|
|
|
110
176
|
request_params: @attributes[:request_params] || error.request_params,
|
|
111
177
|
user_agent: @attributes[:user_agent] || error.user_agent,
|
|
112
178
|
ip_address: @attributes[:ip_address] || error.ip_address,
|
|
113
|
-
**
|
|
179
|
+
**refreshed,
|
|
180
|
+
**context_provenance(refreshed),
|
|
181
|
+
**backtrace_upgrade(error),
|
|
114
182
|
**environment_adoption(error)
|
|
115
183
|
)
|
|
116
184
|
error
|
|
@@ -128,7 +196,9 @@ module RailsErrorDashboard
|
|
|
128
196
|
request_params: @attributes[:request_params] || error.request_params,
|
|
129
197
|
user_agent: @attributes[:user_agent] || error.user_agent,
|
|
130
198
|
ip_address: @attributes[:ip_address] || error.ip_address,
|
|
131
|
-
**latest_context,
|
|
199
|
+
**(refreshed = latest_context),
|
|
200
|
+
**context_provenance(refreshed),
|
|
201
|
+
**backtrace_upgrade(error),
|
|
132
202
|
**environment_adoption(error)
|
|
133
203
|
}
|
|
134
204
|
attrs[:reopened_at] = Time.current if ErrorLog.column_names.include?("reopened_at")
|
|
@@ -137,12 +207,32 @@ module RailsErrorDashboard
|
|
|
137
207
|
error
|
|
138
208
|
end
|
|
139
209
|
|
|
210
|
+
# Attributes for a brand-new group.
|
|
211
|
+
#
|
|
212
|
+
# The first occurrence IS the snapshot, so its provenance is stamped here
|
|
213
|
+
# rather than inferred later. Internal signalling keys (leading
|
|
214
|
+
# underscore) are carriers between commands, not columns -- passing them
|
|
215
|
+
# to create! raises UnknownAttributeError.
|
|
216
|
+
def new_record_attributes
|
|
217
|
+
attrs = @attributes.reject { |key, _| key.to_s.start_with?("_") }
|
|
218
|
+
attrs = attrs.reverse_merge(resolved: false)
|
|
219
|
+
|
|
220
|
+
if ErrorLog.column_names.include?("context_captured_at")
|
|
221
|
+
attrs[:context_captured_at] ||= @attributes[:occurred_at] || Time.current
|
|
222
|
+
end
|
|
223
|
+
if ErrorLog.column_names.include?("context_fidelity")
|
|
224
|
+
attrs[:context_fidelity] ||= @attributes[:_context_fidelity].presence || "full"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
attrs
|
|
228
|
+
end
|
|
229
|
+
|
|
140
230
|
def create_new_or_retry
|
|
141
231
|
# Savepoint: on PostgreSQL a unique-violation poisons the enclosing
|
|
142
232
|
# transaction, and the retry lookups below would fail with "current
|
|
143
233
|
# transaction is aborted" instead of finding the winner's row.
|
|
144
234
|
ErrorLog.transaction(requires_new: true) do
|
|
145
|
-
ErrorLog.create!(
|
|
235
|
+
ErrorLog.create!(new_record_attributes)
|
|
146
236
|
end
|
|
147
237
|
rescue ActiveRecord::RecordNotUnique
|
|
148
238
|
# Race condition: another process created the same error
|
|
@@ -173,10 +263,51 @@ module RailsErrorDashboard
|
|
|
173
263
|
if retry_resolved
|
|
174
264
|
reopen_existing(retry_resolved)
|
|
175
265
|
else
|
|
176
|
-
|
|
266
|
+
# A RecordNotUnique means a row with this exact group identity
|
|
267
|
+
# exists right now, so the only way to get here is for the two
|
|
268
|
+
# lookups above to disagree with the index: the colliding row sits
|
|
269
|
+
# outside the 24 h window (its occurred_at was moved, or the clock
|
|
270
|
+
# skewed) yet still holds this identity. Raising here would abort a
|
|
271
|
+
# capture whose group demonstrably exists -- LogError's blanket
|
|
272
|
+
# rescue turns that into a silently dropped error. Match the row
|
|
273
|
+
# the index actually objected to and increment it instead.
|
|
274
|
+
claim_conflicting_row || raise
|
|
177
275
|
end
|
|
178
276
|
end
|
|
179
277
|
end
|
|
278
|
+
|
|
279
|
+
# The unresolved row that owns this group identity, matched exactly as
|
|
280
|
+
# the unique index defines it (application, hash, environment and the
|
|
281
|
+
# immutable group_window bucket) with no time window of its own.
|
|
282
|
+
def claim_conflicting_row
|
|
283
|
+
return nil unless ErrorLog.column_names.include?("group_window")
|
|
284
|
+
|
|
285
|
+
scope = ErrorLog.unresolved
|
|
286
|
+
.where(error_hash: @error_hash)
|
|
287
|
+
.where(application_id: @attributes[:application_id])
|
|
288
|
+
scope = scope.where(environment: @attributes[:environment]) if ErrorLog.column_names.include?("environment")
|
|
289
|
+
scope = scope.where(group_window: window_for_attributes)
|
|
290
|
+
|
|
291
|
+
conflicting = scope.lock.first
|
|
292
|
+
return nil unless conflicting
|
|
293
|
+
|
|
294
|
+
conflicting.update!(
|
|
295
|
+
occurrence_count: conflicting.occurrence_count + 1,
|
|
296
|
+
last_seen_at: Time.current,
|
|
297
|
+
**latest_context,
|
|
298
|
+
**environment_adoption(conflicting)
|
|
299
|
+
)
|
|
300
|
+
conflicting
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# The bucket this capture would have been stamped with -- the same value
|
|
304
|
+
# ErrorLog#set_group_window computes.
|
|
305
|
+
def window_for_attributes
|
|
306
|
+
basis = @attributes[:occurred_at] || Time.current
|
|
307
|
+
basis.utc.strftime("%Y-%m-%d")
|
|
308
|
+
rescue StandardError
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
180
311
|
end
|
|
181
312
|
end
|
|
182
313
|
end
|
|
@@ -16,35 +16,72 @@ 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
|
-
def self.call(entries:, overflow: 0, episode: nil)
|
|
20
|
-
new(entries: entries, overflow: overflow, episode: episode).call
|
|
19
|
+
def self.call(entries:, overflow: 0, episode: nil, batch_id: nil)
|
|
20
|
+
new(entries: entries, overflow: overflow, episode: episode, batch_id: batch_id).call
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def initialize(entries:, overflow: 0, episode: nil)
|
|
23
|
+
def initialize(entries:, overflow: 0, episode: nil, batch_id: nil)
|
|
24
24
|
@entries = Array(entries)
|
|
25
25
|
@overflow = overflow.to_i
|
|
26
26
|
@episode = episode
|
|
27
|
+
@batch_id = batch_id
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
def call
|
|
30
31
|
application = resolve_application
|
|
31
32
|
counted = 0
|
|
33
|
+
failed = 0
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
# Counts are applied additively (occurrence_count + N), which is not
|
|
36
|
+
# idempotent: delivering the same snapshot twice counted it twice. The
|
|
37
|
+
# realistic replay is this job's own retry -- StormFlushJob raises when
|
|
38
|
+
# a batch reconciles nothing, and ApplicationJob retries it three times
|
|
39
|
+
# with the identical payload -- and a queue that redelivers does the
|
|
40
|
+
# same.
|
|
41
|
+
#
|
|
42
|
+
# The batch digest is inserted in the SAME transaction as the
|
|
43
|
+
# increments, so either both land or neither does. A replay violates
|
|
44
|
+
# the unique index and is reported as already applied rather than
|
|
45
|
+
# counted again.
|
|
46
|
+
ledger = batch_ledger_entry
|
|
47
|
+
return already_applied_result if ledger == :already_applied
|
|
48
|
+
|
|
49
|
+
ErrorLog.transaction do
|
|
50
|
+
claim_batch!(ledger)
|
|
51
|
+
|
|
52
|
+
@entries.each do |entry|
|
|
53
|
+
entry = entry.with_indifferent_access if entry.respond_to?(:with_indifferent_access)
|
|
54
|
+
counted += reconcile_entry(entry, application)
|
|
55
|
+
rescue => e
|
|
56
|
+
# A corrupt (non-Hash) entry must not abort the whole batch — and the
|
|
57
|
+
# log line itself must not assume `entry` is subscriptable (an Integer
|
|
58
|
+
# from a broken serializer would raise again here, escaping this rescue).
|
|
59
|
+
failed += 1
|
|
60
|
+
error_class = entry.is_a?(Hash) ? entry["error_class"] : entry.class
|
|
61
|
+
RailsErrorDashboard::Logger.error(
|
|
62
|
+
"[RailsErrorDashboard] Storm count reconcile failed for #{error_class}: #{e.class} - #{e.message}"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Nothing was written, so there is nothing to protect from a replay:
|
|
67
|
+
# roll the claim back and let the job retry the whole batch.
|
|
68
|
+
raise ActiveRecord::Rollback if failed.positive? && counted.zero?
|
|
69
|
+
|
|
70
|
+
finalize_batch!(ledger, counted)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Every entry failed and none was written. Reporting success with
|
|
74
|
+
# reconciled: 0 made a total loss indistinguishable from an empty
|
|
75
|
+
# batch, so the job acknowledged counts that never reached the
|
|
76
|
+
# database. Partial success stays successful: the entries that were
|
|
77
|
+
# written are written, and replaying the batch would double them.
|
|
78
|
+
if failed.positive? && counted.zero?
|
|
79
|
+
return { success: false, reconciled: 0, failed: failed, overflow: @overflow,
|
|
80
|
+
error: "all #{failed} entries failed to reconcile" }
|
|
44
81
|
end
|
|
45
82
|
|
|
46
83
|
upsert_storm_event(counted)
|
|
47
|
-
{ success: true, reconciled: counted, overflow: @overflow }
|
|
84
|
+
{ success: true, reconciled: counted, failed: failed, overflow: @overflow }
|
|
48
85
|
rescue => e
|
|
49
86
|
RailsErrorDashboard::Logger.error(
|
|
50
87
|
"[RailsErrorDashboard] FlushStormCounts failed: #{e.class} - #{e.message}"
|
|
@@ -54,6 +91,60 @@ module RailsErrorDashboard
|
|
|
54
91
|
|
|
55
92
|
private
|
|
56
93
|
|
|
94
|
+
# nil when the ledger is unavailable (table not migrated yet -- the
|
|
95
|
+
# command then behaves exactly as it did before), :already_applied when
|
|
96
|
+
# this exact batch is already recorded, otherwise the digest to claim.
|
|
97
|
+
def batch_ledger_entry
|
|
98
|
+
return nil unless ledger_available?
|
|
99
|
+
|
|
100
|
+
digest = StormFlushBatch.digest_for(
|
|
101
|
+
entries: @entries, overflow: @overflow, episode: @episode, batch_id: @batch_id
|
|
102
|
+
)
|
|
103
|
+
return :already_applied if StormFlushBatch.exists?(digest: digest)
|
|
104
|
+
|
|
105
|
+
digest
|
|
106
|
+
rescue => e
|
|
107
|
+
# The ledger is a safety net, not a gate: if it cannot be consulted,
|
|
108
|
+
# reconcile anyway rather than dropping counts that exist nowhere else.
|
|
109
|
+
RailsErrorDashboard::Logger.debug(
|
|
110
|
+
"[RailsErrorDashboard] Storm batch ledger unavailable: #{e.class} - #{e.message}"
|
|
111
|
+
)
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def claim_batch!(digest)
|
|
116
|
+
return unless digest.is_a?(String)
|
|
117
|
+
|
|
118
|
+
StormFlushBatch.create!(
|
|
119
|
+
digest: digest,
|
|
120
|
+
entry_count: @entries.size,
|
|
121
|
+
occurrences_applied: 0,
|
|
122
|
+
applied_at: Time.current
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Record what the batch actually applied, for an operator reading the
|
|
127
|
+
# ledger. The row already exists; this only fills in the total.
|
|
128
|
+
def finalize_batch!(digest, counted)
|
|
129
|
+
return unless digest.is_a?(String)
|
|
130
|
+
|
|
131
|
+
StormFlushBatch.where(digest: digest).update_all(occurrences_applied: counted)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def already_applied_result
|
|
135
|
+
RailsErrorDashboard::Logger.info(
|
|
136
|
+
"[RailsErrorDashboard] Storm flush batch already applied — skipping replay"
|
|
137
|
+
)
|
|
138
|
+
{ success: true, reconciled: 0, failed: 0, overflow: @overflow, already_applied: true }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def ledger_available?
|
|
142
|
+
defined?(StormFlushBatch) && StormFlushBatch.table_exists?
|
|
143
|
+
rescue StandardError
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
|
|
57
148
|
def reconcile_entry(entry, application)
|
|
58
149
|
count = entry["count"].to_i
|
|
59
150
|
return 0 if count <= 0
|
|
@@ -117,13 +208,13 @@ module RailsErrorDashboard
|
|
|
117
208
|
# from the exemplar (no backtrace/context was captured; the next
|
|
118
209
|
# occurrence after the storm fills in detail via the normal path).
|
|
119
210
|
#
|
|
120
|
-
# The exemplar message
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
#
|
|
126
|
-
#
|
|
211
|
+
# The exemplar message arrives ALREADY REDACTED: the gate filters it
|
|
212
|
+
# before buffering, because the buffer is shipped to StormFlushJob over
|
|
213
|
+
# a durable queue. Grouping does not depend on the raw text -- the gate
|
|
214
|
+
# hashed the identity from it and sent the digest along
|
|
215
|
+
# (opaque_identity) -- so this row still lands where the full capture
|
|
216
|
+
# path would put it. The filter below stays as a second line of
|
|
217
|
+
# defence for entries from an older release still in flight.
|
|
127
218
|
create_attrs = {
|
|
128
219
|
environment: env,
|
|
129
220
|
application_id: application.id,
|
|
@@ -138,6 +229,18 @@ module RailsErrorDashboard
|
|
|
138
229
|
error_hash: error_hash,
|
|
139
230
|
resolved: false
|
|
140
231
|
}.compact
|
|
232
|
+
|
|
233
|
+
# This row is reconstructed from a counted-only exemplar: there is no
|
|
234
|
+
# backtrace beyond the first app frame and no context at all. Saying so
|
|
235
|
+
# is what lets the dashboard distinguish "nothing was captured" from
|
|
236
|
+
# "nothing happened", and what lets the next full capture upgrade the
|
|
237
|
+
# backtrace instead of leaving a bare path forever.
|
|
238
|
+
if ErrorLog.column_names.include?("context_fidelity")
|
|
239
|
+
create_attrs[:context_fidelity] = "minimal"
|
|
240
|
+
end
|
|
241
|
+
if ErrorLog.column_names.include?("context_captured_at")
|
|
242
|
+
create_attrs[:context_captured_at] = create_attrs[:occurred_at]
|
|
243
|
+
end
|
|
141
244
|
ErrorLog.create!(**ErrorLog.clamp_string_attributes(Services::SensitiveDataFilter.filter_attributes(create_attrs)))
|
|
142
245
|
count
|
|
143
246
|
end
|
|
@@ -163,22 +266,24 @@ module RailsErrorDashboard
|
|
|
163
266
|
Services::SensitiveDataFilter.filter_attributes({ message: message.to_s })[:message]
|
|
164
267
|
end
|
|
165
268
|
|
|
166
|
-
#
|
|
167
|
-
#
|
|
168
|
-
# capture path would have used.
|
|
269
|
+
# Finishes the same two-stage fingerprint the full capture path uses, so
|
|
270
|
+
# counts land on the ErrorLog that path would have chosen.
|
|
169
271
|
def canonical_hash(entry, application)
|
|
170
272
|
return entry["custom_hash"] if entry["custom_hash"].present?
|
|
171
273
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
entry["
|
|
178
|
-
|
|
179
|
-
|
|
274
|
+
# The gate hashed the identity from the RAW message and buffered only
|
|
275
|
+
# the digest; the buffered "message" is redacted, so recomputing from
|
|
276
|
+
# it here would produce a DIFFERENT fingerprint and storm counts would
|
|
277
|
+
# land on a different row than the full capture path.
|
|
278
|
+
opaque = entry["opaque_identity"].presence || Services::ErrorHashGenerator.opaque_identity(
|
|
279
|
+
error_class: entry["error_class"],
|
|
280
|
+
normalized_message: Services::ErrorHashGenerator.normalize_message(entry["message"]),
|
|
281
|
+
frames: entry["first_app_frame"],
|
|
282
|
+
controller_name: entry["controller_name"],
|
|
283
|
+
action_name: entry["action_name"]
|
|
284
|
+
)
|
|
180
285
|
|
|
181
|
-
|
|
286
|
+
Services::ErrorHashGenerator.complete(opaque, application.id)
|
|
182
287
|
end
|
|
183
288
|
|
|
184
289
|
# nil when the column is not migrated yet, so every environment clause
|
|
@@ -36,11 +36,20 @@ module RailsErrorDashboard
|
|
|
36
36
|
error = ErrorLog.find(@error_id)
|
|
37
37
|
parsed = parse_issue_url(@issue_url)
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
attrs = {
|
|
40
40
|
external_issue_url: @issue_url,
|
|
41
41
|
external_issue_number: parsed[:number],
|
|
42
42
|
external_issue_provider: parsed[:provider]&.to_s
|
|
43
|
-
|
|
43
|
+
}
|
|
44
|
+
# The repository is half the issue's identity: issue 42 in acme/api and
|
|
45
|
+
# issue 42 in acme/web are different issues. It was parsed here and
|
|
46
|
+
# thrown away, so a webhook from either repository resolved whichever
|
|
47
|
+
# error happened to match on provider + number alone.
|
|
48
|
+
if ErrorLog.column_names.include?("external_issue_repo")
|
|
49
|
+
attrs[:external_issue_repo] = normalize_repo(parsed[:provider], parsed[:repo])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
error.update!(attrs)
|
|
44
53
|
|
|
45
54
|
{ success: true, issue_url: @issue_url, provider: parsed[:provider] }
|
|
46
55
|
rescue ActiveRecord::RecordNotFound
|
|
@@ -51,6 +60,15 @@ module RailsErrorDashboard
|
|
|
51
60
|
|
|
52
61
|
private
|
|
53
62
|
|
|
63
|
+
# Linear has no repository -- `repo` is the team key, and Linear renders
|
|
64
|
+
# it upper-case ("ENG-123"), so it is stored upper-case to match what a
|
|
65
|
+
# webhook reports. Git forge paths are case-sensitive and kept verbatim.
|
|
66
|
+
def normalize_repo(provider, repo)
|
|
67
|
+
return nil if repo.blank?
|
|
68
|
+
|
|
69
|
+
provider.to_s == "linear" ? repo.to_s.upcase : repo.to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
54
72
|
def parse_issue_url(url)
|
|
55
73
|
PROVIDER_PATTERNS.each do |provider, pattern|
|
|
56
74
|
match = url.match(pattern)
|