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
|
@@ -19,9 +19,14 @@ module RailsErrorDashboard
|
|
|
19
19
|
begin
|
|
20
20
|
Rails.cache.fetch(cache_key, expires_in: 1.minute) do
|
|
21
21
|
{
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
.
|
|
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
|
-
.
|
|
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).
|
|
127
|
-
high: scoped_errors.where(error_type: Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES).
|
|
128
|
-
medium: scoped_errors.where(error_type: Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES).
|
|
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
|
-
).
|
|
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 =
|
|
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 =
|
|
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
|
-
#
|
|
222
|
-
#
|
|
223
|
-
#
|
|
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
|
-
|
|
226
|
-
return 0.0 if
|
|
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
|
|
290
|
+
hours_today = 1.0 if hours_today < 1.0 # Avoid dividing by ~0 just after midnight
|
|
234
291
|
|
|
235
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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 =
|
|
273
|
-
yesterday =
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
#
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
64
|
+
error_class,
|
|
35
65
|
normalized_message,
|
|
36
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
|