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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -4
  3. data/app/controllers/rails_error_dashboard/errors_controller.rb +14 -4
  4. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +79 -6
  5. data/app/jobs/rails_error_dashboard/add_issue_recurrence_comment_job.rb +4 -2
  6. data/app/jobs/rails_error_dashboard/application_job.rb +56 -14
  7. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +22 -4
  8. data/app/jobs/rails_error_dashboard/close_linked_issue_job.rb +4 -2
  9. data/app/jobs/rails_error_dashboard/create_issue_job.rb +1 -1
  10. data/app/jobs/rails_error_dashboard/reopen_linked_issue_job.rb +4 -2
  11. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +25 -0
  12. data/app/jobs/rails_error_dashboard/storm_flush_job.rb +22 -4
  13. data/app/models/rails_error_dashboard/error_log.rb +47 -0
  14. data/app/models/rails_error_dashboard/storm_flush_batch.rb +50 -0
  15. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +27 -1
  16. data/app/views/rails_error_dashboard/errors/_stats.html.erb +6 -0
  17. data/app/views/rails_error_dashboard/errors/overview.html.erb +13 -1
  18. data/config/locales/de.yml +9 -0
  19. data/config/locales/en.yml +35 -0
  20. data/config/locales/es.yml +9 -0
  21. data/config/locales/fr.yml +10 -1
  22. data/config/locales/it.yml +9 -0
  23. data/config/locales/ja.yml +9 -0
  24. data/config/locales/pl.yml +9 -0
  25. data/config/locales/pt-BR.yml +9 -0
  26. data/config/locales/ru.yml +9 -0
  27. data/config/locales/uk.yml +9 -0
  28. data/config/locales/zh-CN.yml +9 -0
  29. data/db/migrate/20260915000001_add_group_identity_unique_index_to_error_logs.rb +207 -0
  30. data/db/migrate/20260915000002_add_issue_repo_identity_to_error_logs.rb +89 -0
  31. data/db/migrate/20260915000003_add_context_provenance_to_error_logs.rb +42 -0
  32. data/db/migrate/20260915000004_create_storm_flush_batches.rb +50 -0
  33. data/lib/rails_error_dashboard/commands/create_issue.rb +10 -2
  34. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +135 -4
  35. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +139 -34
  36. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +20 -2
  37. data/lib/rails_error_dashboard/commands/log_error.rb +206 -19
  38. data/lib/rails_error_dashboard/configuration.rb +4 -3
  39. data/lib/rails_error_dashboard/engine.rb +28 -0
  40. data/lib/rails_error_dashboard/integrations/tracer.rb +26 -7
  41. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +147 -61
  42. data/lib/rails_error_dashboard/queries/user_impact_summary.rb +57 -7
  43. data/lib/rails_error_dashboard/services/error_hash_generator.rb +61 -19
  44. data/lib/rails_error_dashboard/services/issue_tracker_client.rb +38 -0
  45. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +22 -3
  46. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +101 -14
  47. data/lib/rails_error_dashboard/version.rb +1 -1
  48. 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
- error.update!(
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
- **latest_context,
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!(@attributes.reverse_merge(resolved: false))
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
- raise
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
- @entries.each do |entry|
34
- entry = entry.with_indifferent_access if entry.respond_to?(:with_indifferent_access)
35
- counted += reconcile_entry(entry, application)
36
- rescue => e
37
- # A corrupt (non-Hash) entry must not abort the whole batch — and the
38
- # log line itself must not assume `entry` is subscriptable (an Integer
39
- # from a broken serializer would raise again here, escaping this rescue).
40
- error_class = entry.is_a?(Hash) ? entry["error_class"] : entry.class
41
- RailsErrorDashboard::Logger.error(
42
- "[RailsErrorDashboard] Storm count reconcile failed for #{error_class}: #{e.class} - #{e.message}"
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 is RAW — the gate stores what the exception
121
- # said so the canonical hash (computed above, from the raw message,
122
- # exactly as the full path does) still lands on the same row. It must
123
- # therefore go through the same redaction as LogError before it is
124
- # persisted; storm protection and sensitive filtering are both on by
125
- # default, and an incident is exactly when a password in a message
126
- # must not reach the database.
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
- # Mirrors ErrorHashGenerator.call exactly: same fields, same order,
167
- # same normalization — so counts land on the same ErrorLog the full
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
- digest_input = [
173
- entry["error_class"],
174
- Services::ErrorHashGenerator.normalize_message(entry["message"]),
175
- entry["first_app_frame"],
176
- entry["controller_name"],
177
- entry["action_name"],
178
- application.id.to_s
179
- ].compact.join("|")
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
- Digest::SHA256.hexdigest(digest_input)[0..15]
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
- error.update!(
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)