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
@@ -19,9 +19,14 @@ module RailsErrorDashboard
19
19
  begin
20
20
  Rails.cache.fetch(cache_key, expires_in: 1.minute) do
21
21
  {
22
- total_today: base_scope.where("occurred_at >= ?", Time.current.beginning_of_day).count,
23
- total_week: base_scope.where("occurred_at >= ?", 7.days.ago).count,
24
- total_month: base_scope.where("occurred_at >= ?", 30.days.ago).count,
22
+ # EVENTS, not groups. An ErrorLog row is a group whose
23
+ # occurrence_count says how many times it happened, so counting
24
+ # rows reported five users hitting one error as "1 error today".
25
+ # Summing is also exact during a storm: counted-only events never
26
+ # create occurrence rows, but they DO raise occurrence_count.
27
+ total_today: event_count_since(Time.current.beginning_of_day),
28
+ total_week: event_count_since(7.days.ago),
29
+ total_month: event_count_since(30.days.ago),
25
30
  unresolved: base_scope.unresolved.count,
26
31
  resolved: base_scope.resolved.count,
27
32
  reopened: reopened_count,
@@ -40,7 +45,13 @@ module RailsErrorDashboard
40
45
  trend_percentage: trend_percentage,
41
46
  trend_direction: trend_direction,
42
47
  top_errors_by_impact: top_errors_by_impact,
43
- average_resolution_time: average_resolution_time
48
+ average_resolution_time: average_resolution_time,
49
+ # Affected-user figures come from occurrence rows, which storm
50
+ # count-only events never create. When that happened in the
51
+ # window, the dimension is incomplete and the page says so
52
+ # rather than presenting an undercount as fact.
53
+ affected_users_incomplete: affected_users_incomplete?,
54
+ data_unavailable: false
44
55
  }
45
56
  end
46
57
  rescue => e
@@ -50,7 +61,13 @@ module RailsErrorDashboard
50
61
  RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] Backtrace: #{e.backtrace&.first(3)&.join("\n")}")
51
62
 
52
63
  # Return minimal stats hash to prevent nil errors in views
64
+ # Zero errors and "we could not read the data" are different states.
65
+ # Reporting healthy-looking zeros made a failed dashboard query
66
+ # indistinguishable from a quiet day; data_unavailable lets the page
67
+ # say which it is.
53
68
  {
69
+ data_unavailable: true,
70
+ affected_users_incomplete: false,
54
71
  total_today: 0,
55
72
  total_week: 0,
56
73
  total_month: 0,
@@ -95,6 +112,46 @@ module RailsErrorDashboard
95
112
  scope
96
113
  end
97
114
 
115
+ # Total EVENTS in a window: the sum of every matching group's
116
+ # occurrence_count. platform_comparison.rb counts the same way.
117
+ def event_count_since(since)
118
+ base_scope.where("occurred_at >= ?", since).sum(:occurrence_count)
119
+ end
120
+
121
+ def event_count_between(from, to)
122
+ base_scope.where("occurred_at >= ? AND occurred_at < ?", from, to).sum(:occurrence_count)
123
+ end
124
+
125
+ # Occurrence rows carry the user of EACH event. The group's user_id is
126
+ # mutable -- refreshed by the latest occurrence -- so counting it
127
+ # distinct over groups can only ever yield zero or one per group.
128
+ def occurrence_scope
129
+ occurrences = ErrorOccurrence.table_name
130
+ scope = ErrorOccurrence.joins(:error_log)
131
+ scope = scope.where(ErrorLog.table_name => { application_id: @application_id }) if @application_id.present?
132
+ scope
133
+ end
134
+
135
+ def occurrences_available?
136
+ defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
137
+ rescue StandardError
138
+ false
139
+ end
140
+
141
+ # True when the window contains more events than recorded occurrences --
142
+ # i.e. storm shedding dropped per-event rows, so any occurrence-derived
143
+ # dimension (affected users) is a floor, not a total.
144
+ def affected_users_incomplete?
145
+ return false unless occurrences_available?
146
+
147
+ recorded = occurrence_scope
148
+ .where("#{ErrorOccurrence.table_name}.occurred_at >= ?", Time.current.beginning_of_day)
149
+ .count
150
+ recorded < event_count_since(Time.current.beginning_of_day)
151
+ rescue StandardError
152
+ false
153
+ end
154
+
98
155
  def reopened_count
99
156
  return 0 unless ErrorLog.column_names.include?("reopened_at")
100
157
 
@@ -104,7 +161,7 @@ module RailsErrorDashboard
104
161
  def top_errors
105
162
  base_scope.where("occurred_at >= ?", 7.days.ago)
106
163
  .group(:error_type)
107
- .count
164
+ .sum(:occurrence_count)
108
165
  .sort_by { |_, count| -count }
109
166
  .first(10)
110
167
  .to_h
@@ -114,7 +171,7 @@ module RailsErrorDashboard
114
171
  def errors_trend_7d
115
172
  base_scope.where("occurred_at >= ?", 7.days.ago)
116
173
  .group_by_day(:occurred_at, range: 7.days.ago.to_date..Date.current, default_value: 0)
117
- .count
174
+ .sum(:occurrence_count)
118
175
  end
119
176
 
120
177
  # Get error counts by severity for last 7 days
@@ -123,14 +180,14 @@ module RailsErrorDashboard
123
180
  scoped_errors = base_scope.where("occurred_at >= ?", 7.days.ago)
124
181
 
125
182
  {
126
- critical: scoped_errors.where(error_type: Services::SeverityClassifier::CRITICAL_ERROR_TYPES).count,
127
- high: scoped_errors.where(error_type: Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES).count,
128
- medium: scoped_errors.where(error_type: Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES).count,
183
+ critical: scoped_errors.where(error_type: Services::SeverityClassifier::CRITICAL_ERROR_TYPES).sum(:occurrence_count),
184
+ high: scoped_errors.where(error_type: Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES).sum(:occurrence_count),
185
+ medium: scoped_errors.where(error_type: Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES).sum(:occurrence_count),
129
186
  low: scoped_errors.where.not(
130
187
  error_type: Services::SeverityClassifier::CRITICAL_ERROR_TYPES +
131
188
  Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES +
132
189
  Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES
133
- ).count
190
+ ).sum(:occurrence_count)
134
191
  }
135
192
  end
136
193
 
@@ -139,7 +196,7 @@ module RailsErrorDashboard
139
196
  def spike_detected?
140
197
  return false if errors_trend_7d.empty?
141
198
 
142
- today_count = base_scope.where("occurred_at >= ?", Time.current.beginning_of_day).count
199
+ today_count = event_count_since(Time.current.beginning_of_day)
143
200
 
144
201
  # Try baseline-based detection first
145
202
  if baseline_anomaly_detected?(today_count)
@@ -158,7 +215,7 @@ module RailsErrorDashboard
158
215
  def spike_info
159
216
  return nil unless spike_detected?
160
217
 
161
- today_count = base_scope.where("occurred_at >= ?", Time.current.beginning_of_day).count
218
+ today_count = event_count_since(Time.current.beginning_of_day)
162
219
  avg_count = (errors_trend_7d.values.sum / 7.0).round(1)
163
220
 
164
221
  info = {
@@ -218,42 +275,50 @@ module RailsErrorDashboard
218
275
  }
219
276
  end
220
277
 
221
- # Calculate error rate as a percentage
222
- # Since we don't track total requests, we'll use error count as proxy
223
- # In the future, this could be: (errors / total_requests) * 100
278
+ # Errors per hour so far today. NOT a percentage.
279
+ #
280
+ # This value was rendered with a "%" sign against a scale that mapped
281
+ # one error per hour to "1%", and was capped at 100 -- a rate of 4,000
282
+ # errors/hour displayed as "100%". There is no request denominator to
283
+ # make a real failure percentage from, so the honest figure is the rate
284
+ # itself, uncapped, labelled with its unit.
224
285
  def error_rate
225
- today_errors = base_scope.where("occurred_at >= ?", Time.current.beginning_of_day).count
226
- return 0.0 if today_errors.zero?
286
+ today_events = event_count_since(Time.current.beginning_of_day)
287
+ return 0.0 if today_events.zero?
227
288
 
228
- # For now, use a simple heuristic: errors per hour today
229
- # Assume we want < 1 error per hour = good (< 1%)
230
- # 1-5 errors per hour = warning (1-5%)
231
- # > 5 errors per hour = critical (> 5%)
232
289
  hours_today = ((Time.current - Time.current.beginning_of_day) / 1.hour).round(1)
233
- hours_today = 1.0 if hours_today < 1.0 # Avoid division by zero in early morning
290
+ hours_today = 1.0 if hours_today < 1.0 # Avoid dividing by ~0 just after midnight
234
291
 
235
- errors_per_hour = today_errors / hours_today
236
- # Convert to percentage scale (0-100)
237
- # Scale: 0 errors/hr = 0%, 1 error/hr = 1%, 10 errors/hr = 10%, etc.
238
- [ errors_per_hour, 100.0 ].min.round(1)
292
+ (today_events / hours_today).round(1)
239
293
  end
240
294
 
241
- # Count distinct users affected by errors today
295
+ # Distinct users affected today, counted from OCCURRENCE rows.
296
+ #
297
+ # The group's user_id is overwritten by each new occurrence, so counting
298
+ # it distinct across groups reported five users hitting one error as one
299
+ # affected user. Storm count-only events create no occurrence row, so
300
+ # this is a floor during a storm -- affected_users_incomplete? says when.
242
301
  def affected_users_today
243
- base_scope.where("occurred_at >= ?", Time.current.beginning_of_day)
244
- .where.not(user_id: nil)
245
- .distinct
246
- .count(:user_id)
302
+ distinct_affected_users(Time.current.beginning_of_day, nil)
247
303
  end
248
304
 
249
- # Count distinct users affected by errors yesterday
250
305
  def affected_users_yesterday
251
- base_scope.where("occurred_at >= ? AND occurred_at < ?",
252
- 1.day.ago.beginning_of_day,
253
- Time.current.beginning_of_day)
254
- .where.not(user_id: nil)
255
- .distinct
256
- .count(:user_id)
306
+ distinct_affected_users(1.day.ago.beginning_of_day, Time.current.beginning_of_day)
307
+ end
308
+
309
+ def distinct_affected_users(from, to)
310
+ unless occurrences_available?
311
+ scope = base_scope.where("occurred_at >= ?", from)
312
+ scope = scope.where("occurred_at < ?", to) if to
313
+ return scope.where.not(user_id: nil).distinct.count(:user_id)
314
+ end
315
+
316
+ occurrences = ErrorOccurrence.table_name
317
+ scope = occurrence_scope.where("#{occurrences}.occurred_at >= ?", from)
318
+ scope = scope.where("#{occurrences}.occurred_at < ?", to) if to
319
+ scope.where.not(occurrences => { user_id: nil }).distinct.count("#{occurrences}.user_id")
320
+ rescue StandardError
321
+ 0
257
322
  end
258
323
 
259
324
  # Calculate change in affected users (today vs yesterday)
@@ -269,10 +334,8 @@ module RailsErrorDashboard
269
334
 
270
335
  # Calculate percentage change in errors (today vs yesterday)
271
336
  def trend_percentage
272
- today = base_scope.where("occurred_at >= ?", Time.current.beginning_of_day).count
273
- yesterday = base_scope.where("occurred_at >= ? AND occurred_at < ?",
274
- 1.day.ago.beginning_of_day,
275
- Time.current.beginning_of_day).count
337
+ today = event_count_since(Time.current.beginning_of_day)
338
+ yesterday = event_count_between(1.day.ago.beginning_of_day, Time.current.beginning_of_day)
276
339
 
277
340
  return 0.0 if today.zero? && yesterday.zero?
278
341
  return 100.0 if yesterday.zero? && today.positive?
@@ -295,26 +358,49 @@ module RailsErrorDashboard
295
358
 
296
359
  # Get top 6 errors ranked by impact score
297
360
  # Impact = affected_users_count × occurrence_count
361
+ # Top errors by impact = distinct affected users x events.
362
+ #
363
+ # This grouped by each row's own id and counted DISTINCT user_id within
364
+ # that single row, which can only be zero or one -- so every error's
365
+ # "affected users" was 1 and the impact score was just its occurrence
366
+ # count. The user count comes from occurrence rows, where each event
367
+ # carries its own user.
298
368
  def top_errors_by_impact
299
- base_scope.where("occurred_at >= ?", 7.days.ago)
300
- .group(:error_type, :id)
301
- .select("error_type, id, message, occurred_at, occurrence_count,
302
- COUNT(DISTINCT user_id) as affected_users,
303
- COUNT(DISTINCT user_id) * occurrence_count as impact_score")
304
- .order("impact_score DESC")
305
- .limit(6)
306
- .map do |error|
307
- {
308
- id: error.id,
309
- error_type: error.error_type,
310
- message: error.message&.truncate(80),
311
- severity: Services::SeverityClassifier.classify(error.error_type),
312
- occurrence_count: error.occurrence_count,
313
- affected_users: error.affected_users.to_i,
314
- impact_score: error.impact_score.to_i,
315
- occurred_at: error.occurred_at
316
- }
317
- end
369
+ errors = base_scope.where("occurred_at >= ?", 7.days.ago)
370
+ .order(occurrence_count: :desc)
371
+ .limit(50)
372
+ .to_a
373
+ return [] if errors.empty?
374
+
375
+ users_by_error = distinct_users_by_error_log(errors.map(&:id))
376
+
377
+ errors.map { |error|
378
+ affected = users_by_error.fetch(error.id, error.user_id.present? ? 1 : 0)
379
+ {
380
+ id: error.id,
381
+ error_type: error.error_type,
382
+ message: error.message&.truncate(80),
383
+ severity: Services::SeverityClassifier.classify(error.error_type),
384
+ occurrence_count: error.occurrence_count,
385
+ affected_users: affected,
386
+ impact_score: affected * error.occurrence_count.to_i,
387
+ occurred_at: error.occurred_at
388
+ }
389
+ }.sort_by { |entry| -entry[:impact_score] }.first(6)
390
+ end
391
+
392
+ # error_log_id => distinct users with a recorded occurrence.
393
+ def distinct_users_by_error_log(error_log_ids)
394
+ return {} unless occurrences_available?
395
+ return {} if error_log_ids.empty?
396
+
397
+ ErrorOccurrence.where(error_log_id: error_log_ids)
398
+ .where.not(user_id: nil)
399
+ .group(:error_log_id)
400
+ .distinct
401
+ .count(:user_id)
402
+ rescue StandardError
403
+ {}
318
404
  end
319
405
 
320
406
  # Calculate average resolution time (MTTR) in hours for the last 30 days
@@ -37,15 +37,16 @@ module RailsErrorDashboard
37
37
  end
38
38
 
39
39
  def build_entries
40
- # Group by error_type, count distinct users and total occurrences
41
- user_counts = base_scope
42
- .group(:error_type)
43
- .distinct
44
- .count(:user_id)
40
+ # Distinct users per error type, from OCCURRENCE rows: the group's
41
+ # user_id is overwritten by each new occurrence, so counting it
42
+ # distinct across groups reported five users hitting one error as one.
43
+ user_counts = distinct_users_by_type
45
44
 
45
+ # EVENTS per type, not groups. occurrence_count is exact and includes
46
+ # storm-shed events, which create no occurrence row.
46
47
  occurrence_counts = base_scope
47
48
  .group(:error_type)
48
- .count
49
+ .sum(:occurrence_count)
49
50
 
50
51
  total_users = effective_total_users
51
52
 
@@ -73,12 +74,61 @@ module RailsErrorDashboard
73
74
  def build_summary(entries)
74
75
  {
75
76
  total_error_types_with_users: entries.size,
76
- total_unique_users_affected: entries.sum { |e| e[:unique_users] },
77
+ # Summing per-type distinct counts counted one user once per type
78
+ # they hit. One distinct count across the whole window is the actual
79
+ # number of people affected.
80
+ total_unique_users_affected: total_distinct_users(entries),
77
81
  most_impactful: entries.first&.dig(:error_type),
78
82
  total_users: effective_total_users
79
83
  }
80
84
  end
81
85
 
86
+ def occurrences_available?
87
+ defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
88
+ rescue StandardError
89
+ false
90
+ end
91
+
92
+ # error_type => distinct users who actually experienced it.
93
+ def distinct_users_by_type
94
+ unless occurrences_available?
95
+ return base_scope.group(:error_type).distinct.count(:user_id)
96
+ end
97
+
98
+ occurrences = ErrorOccurrence.table_name
99
+ logs = ErrorLog.table_name
100
+ scope = ErrorOccurrence.joins(:error_log)
101
+ .where("#{occurrences}.occurred_at >= ?", @start_date)
102
+ .where.not(occurrences => { user_id: nil })
103
+ scope = scope.where(logs => { application_id: @application_id }) if @application_id.present?
104
+
105
+ counts = scope.group("#{logs}.error_type").distinct.count("#{occurrences}.user_id")
106
+ # An error whose events predate occurrence tracking still belongs in
107
+ # the list; fall back to the group's own user for those types.
108
+ fallback = base_scope.group(:error_type).distinct.count(:user_id)
109
+ fallback.merge(counts) { |_type, old_count, new_count| [ old_count, new_count ].max }
110
+ rescue StandardError
111
+ base_scope.group(:error_type).distinct.count(:user_id)
112
+ end
113
+
114
+ # One distinct count over the whole window, not a sum of per-type counts.
115
+ def total_distinct_users(entries)
116
+ unless occurrences_available?
117
+ return base_scope.distinct.count(:user_id)
118
+ end
119
+
120
+ occurrences = ErrorOccurrence.table_name
121
+ logs = ErrorLog.table_name
122
+ scope = ErrorOccurrence.joins(:error_log)
123
+ .where("#{occurrences}.occurred_at >= ?", @start_date)
124
+ .where.not(occurrences => { user_id: nil })
125
+ scope = scope.where(logs => { application_id: @application_id }) if @application_id.present?
126
+
127
+ [ scope.distinct.count("#{occurrences}.user_id"), entries.map { |e| e[:unique_users] }.max.to_i ].max
128
+ rescue StandardError
129
+ entries.sum { |e| e[:unique_users] }
130
+ end
131
+
82
132
  def effective_total_users
83
133
  RailsErrorDashboard.configuration.effective_total_users
84
134
  rescue => e
@@ -27,21 +27,61 @@ module RailsErrorDashboard
27
27
  custom = try_custom_fingerprint(exception, context)
28
28
  return custom if custom
29
29
 
30
- normalized_message = normalize_message(exception.message)
31
- file_path = extract_app_frame_from_locations(exception) || extract_app_frame(exception.backtrace)
30
+ complete(
31
+ opaque_identity(
32
+ error_class: exception.class.name,
33
+ normalized_message: normalize_message(exception.message),
34
+ frames: extract_app_frame_from_locations(exception) || extract_app_frame(exception.backtrace),
35
+ controller_name: controller_name,
36
+ action_name: action_name
37
+ ),
38
+ application_id
39
+ )
40
+ end
32
41
 
42
+ # The half of the fingerprint that needs no database.
43
+ #
44
+ # WHY THE HASH IS TWO-STAGE: capture identity has to be computed from the
45
+ # RAW message (redacting first would re-group every error whose message
46
+ # contains a filtered key), but the async and storm paths compute it on
47
+ # the request thread and hand it to a worker over a queue. Shipping the
48
+ # raw message to do that put secrets in the queue's backing store, its
49
+ # backups and any job-argument logging -- the error row was redacted, the
50
+ # payload was not.
51
+ #
52
+ # Hashing the identity parts here makes the value that crosses the queue
53
+ # opaque and irreversible, so no message text needs to travel for
54
+ # grouping to work. The worker calls .complete with application_id, which
55
+ # it can resolve because it is allowed to touch the database.
56
+ #
57
+ # application_id is NOT part of this digest: resolving it means
58
+ # Application.find_or_create_by_name, a write, and the capture path
59
+ # promises no I/O on the request thread.
60
+ #
61
+ # @return [String] 16-character hex digest of the non-application parts
62
+ def self.opaque_identity(error_class:, normalized_message:, frames:, controller_name: nil, action_name: nil)
33
63
  digest_input = [
34
- exception.class.name,
64
+ error_class,
35
65
  normalized_message,
36
- file_path,
66
+ frames,
37
67
  controller_name,
38
- action_name,
39
- application_id.to_s
68
+ action_name
40
69
  ].compact.join("|")
41
70
 
42
71
  Digest::SHA256.hexdigest(digest_input)[0..15]
43
72
  end
44
73
 
74
+ # Combine an opaque identity with the application to get the canonical
75
+ # error_hash. Every path -- sync capture, async worker, storm flush --
76
+ # finishes here, so a fingerprint is the same value however it travelled.
77
+ #
78
+ # @param opaque [String] from .opaque_identity (or a custom fingerprint)
79
+ # @param application_id [Integer, nil]
80
+ # @return [String] 16-character hex hash
81
+ def self.complete(opaque, application_id)
82
+ Digest::SHA256.hexdigest("#{opaque}|#{application_id}")[0..15]
83
+ end
84
+
45
85
  # Generate hash from error attributes (used by ErrorLog model callback)
46
86
  # Uses ErrorNormalizer for smarter normalization and significant frame extraction
47
87
  # @param error_type [String] The error class name
@@ -52,19 +92,21 @@ module RailsErrorDashboard
52
92
  # @param application_id [Integer, nil] Application for per-app deduplication
53
93
  # @return [String] 16-character hex hash
54
94
  def self.from_attributes(error_type:, message: nil, backtrace: nil, controller_name: nil, action_name: nil, application_id: nil)
55
- normalized_message = ErrorNormalizer.normalize(message)
56
- significant_frames = ErrorNormalizer.extract_significant_frames(backtrace, count: 3)
57
-
58
- digest_input = [
59
- error_type,
60
- normalized_message,
61
- significant_frames,
62
- controller_name,
63
- action_name,
64
- application_id.to_s
65
- ].compact.join("|")
66
-
67
- Digest::SHA256.hexdigest(digest_input)[0..15]
95
+ # Deliberately a DIFFERENT recipe from .call: ErrorNormalizer's smarter
96
+ # normalization and three significant frames, rather than a prefix of
97
+ # the message and one app frame. The two entry points have always
98
+ # produced different hashes for the same error; unifying them would
99
+ # re-group every existing row. They share only the two-stage shape.
100
+ complete(
101
+ opaque_identity(
102
+ error_class: error_type,
103
+ normalized_message: ErrorNormalizer.normalize(message),
104
+ frames: ErrorNormalizer.extract_significant_frames(backtrace, count: 3),
105
+ controller_name: controller_name,
106
+ action_name: action_name
107
+ ),
108
+ application_id
109
+ )
68
110
  end
69
111
 
70
112
  # Only this many leading characters of the RAW message take part in the
@@ -63,6 +63,44 @@ module RailsErrorDashboard
63
63
  nil
64
64
  end
65
65
 
66
+ # Build a client for the identity an ERROR is actually linked to, rather
67
+ # than whatever the global configuration currently points at.
68
+ #
69
+ # An outbound comment or close must reach the repository the issue was
70
+ # opened in. With the repo taken from configuration, re-pointing
71
+ # issue_tracker_repo (or running one database across several apps) sent
72
+ # those calls to the wrong repository -- against an issue number that
73
+ # exists in both.
74
+ #
75
+ # Falls back to the configured repo when the error predates the recorded
76
+ # identity, which is the previous behaviour.
77
+ #
78
+ # @param error [ErrorLog]
79
+ # @return [IssueTrackerClient, nil]
80
+ def self.for_error(error)
81
+ config = RailsErrorDashboard.configuration
82
+ return nil unless config.enable_issue_tracking
83
+
84
+ provider = error.external_issue_provider.presence || config.effective_issue_tracker_provider
85
+ return nil unless provider
86
+
87
+ repo = nil
88
+ if error.respond_to?(:external_issue_repo)
89
+ repo = error.external_issue_repo.presence
90
+ end
91
+ repo ||= config.effective_issue_tracker_repo
92
+
93
+ token = config.effective_issue_tracker_token
94
+ return nil unless token && repo
95
+
96
+ self.for(provider, token: token, repo: repo, api_url: config.effective_issue_tracker_api_url)
97
+ rescue => e
98
+ RailsErrorDashboard::Logger.debug(
99
+ "[RailsErrorDashboard] IssueTrackerClient.for_error failed: #{e.class} - #{e.message}"
100
+ )
101
+ nil
102
+ end
103
+
66
104
  def initialize(token:, repo:, api_url: nil)
67
105
  @token = token
68
106
  @repo = repo
@@ -29,7 +29,7 @@ module RailsErrorDashboard
29
29
  Entry = Struct.new(
30
30
  :error_class, :message, :first_app_frame,
31
31
  :controller_name, :action_name, :custom_hash, :environment,
32
- :count, :first_seen_at, :last_seen_at
32
+ :opaque_identity, :count, :first_seen_at, :last_seen_at
33
33
  )
34
34
 
35
35
  def initialize
@@ -97,13 +97,31 @@ module RailsErrorDashboard
97
97
  "action_name" => entry.action_name,
98
98
  "custom_hash" => entry.custom_hash,
99
99
  "environment" => entry.environment,
100
+ # The fingerprint, hashed from the RAW message at the gate.
101
+ # "message" above is REDACTED before it is buffered, so the flush
102
+ # job CANNOT recompute this -- it has to travel, or a storm count
103
+ # lands on a different row than the full capture path would.
104
+ "opaque_identity" => entry.opaque_identity,
100
105
  "count" => entry.count.value,
101
106
  "first_seen_at" => entry.first_seen_at.iso8601,
102
107
  "last_seen_at" => entry.last_seen_at.iso8601
103
108
  }
104
109
  end
105
110
 
106
- { entries: entries, overflow: overflow }
111
+ # A unique identity for THIS swap of the buffer.
112
+ #
113
+ # The batch digest is built from the entries and their counts, which
114
+ # makes a replay recognisable -- but it also makes two SEPARATE
115
+ # batches indistinguishable when they happen to carry identical
116
+ # counts for the same fingerprints (timestamps are second-granular,
117
+ # so a storm flushing twice inside one second collides). The second
118
+ # batch would then be dropped as a replay, losing real counts.
119
+ #
120
+ # Minted here because this is the moment a batch comes into
121
+ # existence: every entry in it was removed from the buffer by this
122
+ # swap and appears in no other batch. A retry of the same batch
123
+ # carries the same id, which is exactly what the ledger must catch.
124
+ { entries: entries, overflow: overflow, batch_id: SecureRandom.uuid }
107
125
  end
108
126
 
109
127
  private
@@ -124,6 +142,7 @@ module RailsErrorDashboard
124
142
  Entry.new(
125
143
  parts[:error_class], parts[:message], parts[:first_app_frame],
126
144
  parts[:controller_name], parts[:action_name], parts[:custom_hash], parts[:environment],
145
+ parts[:opaque_identity],
127
146
  Concurrent::AtomicFixnum.new(0), first_seen_at || Time.current, last_seen_at || Time.current
128
147
  )
129
148
  end
@@ -139,7 +158,7 @@ module RailsErrorDashboard
139
158
  error_class: entry["error_class"], message: entry["message"],
140
159
  first_app_frame: entry["first_app_frame"], controller_name: entry["controller_name"],
141
160
  action_name: entry["action_name"], custom_hash: entry["custom_hash"],
142
- environment: entry["environment"]
161
+ environment: entry["environment"], opaque_identity: entry["opaque_identity"]
143
162
  }
144
163
  end
145
164