rails_error_dashboard 0.13.0 → 0.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
- data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +54 -0
- data/app/jobs/rails_error_dashboard/storm_flush_job.rb +7 -4
- data/app/models/rails_error_dashboard/error_log.rb +10 -0
- data/app/models/rails_error_dashboard/event_count.rb +132 -0
- data/app/models/rails_error_dashboard/event_timing_gap.rb +55 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +47 -2
- data/app/views/rails_error_dashboard/errors/_request_context.html.erb +2 -0
- data/app/views/rails_error_dashboard/errors/overview.html.erb +12 -0
- data/app/views/rails_error_dashboard/errors/show.html.erb +3 -3
- data/config/locales/de.yml +2 -0
- data/config/locales/en.yml +2 -0
- data/config/locales/es.yml +2 -0
- data/config/locales/fr.yml +2 -0
- data/config/locales/it.yml +2 -0
- data/config/locales/ja.yml +2 -0
- data/config/locales/pl.yml +2 -0
- data/config/locales/pt-BR.yml +2 -0
- data/config/locales/ru.yml +2 -0
- data/config/locales/uk.yml +2 -0
- data/config/locales/zh-CN.yml +2 -0
- data/db/migrate/20260919000001_create_event_counts.rb +71 -0
- data/db/migrate/20260920000001_add_buckets_incomplete_to_storm_events.rb +25 -0
- data/db/migrate/20260920000002_create_event_timing_gaps.rb +55 -0
- data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +70 -4
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +225 -8
- data/lib/rails_error_dashboard/commands/log_error.rb +213 -21
- data/lib/rails_error_dashboard/configuration.rb +20 -0
- data/lib/rails_error_dashboard/engine.rb +13 -0
- data/lib/rails_error_dashboard/manual_error_reporter.rb +16 -5
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +85 -27
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +167 -30
- data/lib/rails_error_dashboard/queries/error_correlation.rb +36 -27
- data/lib/rails_error_dashboard/queries/event_volume.rb +503 -0
- data/lib/rails_error_dashboard/queries/platform_comparison.rb +119 -108
- data/lib/rails_error_dashboard/queries/recurring_issues.rb +44 -25
- data/lib/rails_error_dashboard/queries/user_impact_summary.rb +29 -10
- data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +23 -0
- data/lib/rails_error_dashboard/services/digest_builder.rb +44 -15
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +64 -6
- data/lib/rails_error_dashboard/services/variable_serializer.rb +125 -10
- data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +111 -0
- data/lib/rails_error_dashboard/value_objects/error_context.rb +37 -2
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +3 -0
- metadata +8 -2
|
@@ -5,6 +5,10 @@ module RailsErrorDashboard
|
|
|
5
5
|
# Query: Fetch dashboard statistics
|
|
6
6
|
# This is a read operation that aggregates error data for the dashboard
|
|
7
7
|
class DashboardStats
|
|
8
|
+
# The widest window any figure on the Overview covers (total_month).
|
|
9
|
+
# The completeness predicate is matched to this, not to "today".
|
|
10
|
+
WIDEST_DISPLAYED_WINDOW = 30.days
|
|
11
|
+
|
|
8
12
|
# One instance answers ONE call: several aggregates (today's event count,
|
|
9
13
|
# the 7-day trend, spike detection) are memoised on it so that they are
|
|
10
14
|
# computed once per call rather than once per card that shows them.
|
|
@@ -28,8 +32,8 @@ module RailsErrorDashboard
|
|
|
28
32
|
# Summing is also exact during a storm: counted-only events never
|
|
29
33
|
# create occurrence rows, but they DO raise occurrence_count.
|
|
30
34
|
total_today: today_event_count,
|
|
31
|
-
total_week:
|
|
32
|
-
total_month:
|
|
35
|
+
total_week: week_event_counts.values.sum,
|
|
36
|
+
total_month: month_event_count,
|
|
33
37
|
unresolved: base_scope.unresolved.count,
|
|
34
38
|
resolved: base_scope.resolved.count,
|
|
35
39
|
reopened: reopened_count,
|
|
@@ -54,6 +58,13 @@ module RailsErrorDashboard
|
|
|
54
58
|
# window, the dimension is incomplete and the page says so
|
|
55
59
|
# rather than presenting an undercount as fact.
|
|
56
60
|
affected_users_incomplete: affected_users_incomplete?,
|
|
61
|
+
# A storm episode in this window lost its per-bucket TIMING
|
|
62
|
+
# evidence, so every time-window figure for it rests on the
|
|
63
|
+
# group's own occurred_at rather than on per-event records.
|
|
64
|
+
# Reported for the same reason as affected_users_incomplete:
|
|
65
|
+
# an incomplete dimension should say so rather than present a
|
|
66
|
+
# figure of unknown completeness as fact.
|
|
67
|
+
event_timing_incomplete: event_timing_incomplete?,
|
|
57
68
|
data_unavailable: false
|
|
58
69
|
}
|
|
59
70
|
end
|
|
@@ -71,6 +82,7 @@ module RailsErrorDashboard
|
|
|
71
82
|
{
|
|
72
83
|
data_unavailable: true,
|
|
73
84
|
affected_users_incomplete: false,
|
|
85
|
+
event_timing_incomplete: false,
|
|
74
86
|
total_today: 0,
|
|
75
87
|
total_week: 0,
|
|
76
88
|
total_month: 0,
|
|
@@ -116,14 +128,29 @@ module RailsErrorDashboard
|
|
|
116
128
|
scope
|
|
117
129
|
end
|
|
118
130
|
|
|
119
|
-
# Total EVENTS in a window
|
|
120
|
-
#
|
|
131
|
+
# Total EVENTS in a window -- the events that HAPPENED in it, not the
|
|
132
|
+
# lifetime volume of the groups first seen in it.
|
|
133
|
+
#
|
|
134
|
+
# This used to filter groups by occurred_at and sum occurrence_count.
|
|
135
|
+
# occurred_at is first-seen and is never rewritten on recurrence, so an
|
|
136
|
+
# error first seen at 23:59 that recurred at 00:01 reported zero errors
|
|
137
|
+
# today and two yesterday. Queries::EventVolume counts per-event records
|
|
138
|
+
# instead: occurrence rows, plus storm-shed time buckets, plus the
|
|
139
|
+
# remainder of any group that has neither.
|
|
121
140
|
def event_count_since(since)
|
|
122
|
-
|
|
141
|
+
Queries::EventVolume.in_window(base_scope, since)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The widest-window total, memoised: the stats hash shows it and the
|
|
145
|
+
# completeness predicate needs the same number. One instance answers one
|
|
146
|
+
# call, and this runs on the capture path via the stats broadcast, so a
|
|
147
|
+
# second identical aggregate here is pure waste.
|
|
148
|
+
def month_event_count
|
|
149
|
+
@month_event_count ||= event_count_since(WIDEST_DISPLAYED_WINDOW.ago)
|
|
123
150
|
end
|
|
124
151
|
|
|
125
152
|
def event_count_between(from, to)
|
|
126
|
-
|
|
153
|
+
Queries::EventVolume.in_window(base_scope, from, to)
|
|
127
154
|
end
|
|
128
155
|
|
|
129
156
|
# Occurrence rows carry the user of EACH event. The group's user_id is
|
|
@@ -156,44 +183,143 @@ module RailsErrorDashboard
|
|
|
156
183
|
false
|
|
157
184
|
end
|
|
158
185
|
|
|
186
|
+
# True when a storm episode overlapping this window degraded without
|
|
187
|
+
# writing its time buckets.
|
|
188
|
+
#
|
|
189
|
+
# Read from the persisted episode rather than recomputed: the flush that
|
|
190
|
+
# knew is long over by the time the dashboard renders, and the flag has
|
|
191
|
+
# to survive a replay. Every guard here is deliberate -- the storm table
|
|
192
|
+
# may not exist, and the column may not be migrated yet on an older host.
|
|
193
|
+
# True when any recorded timing gap overlaps a window this page shows.
|
|
194
|
+
#
|
|
195
|
+
# The page displays today, 7-day AND 30-day figures, so the predicate has
|
|
196
|
+
# to match the WIDEST of them. An earlier version asked only about today
|
|
197
|
+
# and dropped the warning while the weekly and monthly numbers on the
|
|
198
|
+
# same screen still contained the affected events.
|
|
199
|
+
#
|
|
200
|
+
# Read from EventTimingGap rather than the storm episode: the episode is
|
|
201
|
+
# optional (the gate can shed with its breaker closed), and it is written
|
|
202
|
+
# after the counts transaction commits. See the migration for the full
|
|
203
|
+
# history of why this moved.
|
|
204
|
+
def event_timing_incomplete?
|
|
205
|
+
# Nothing left to qualify, so no warning.
|
|
206
|
+
#
|
|
207
|
+
# Gaps are deliberately retained past their own retention while the
|
|
208
|
+
# figures they describe are still shown (see the cleanup job), so a gap
|
|
209
|
+
# can outlive every event it covered -- a group expiring is what
|
|
210
|
+
# removes those events. Warning about a window whose subject matter is
|
|
211
|
+
# gone is noise, and noise trains people to ignore the banner.
|
|
212
|
+
#
|
|
213
|
+
# Asked of surviving GROUPS, not of the event aggregate. The aggregate
|
|
214
|
+
# is the wrong witness here: losing the time buckets is exactly what
|
|
215
|
+
# makes events invisible to it, so an old group that recurs today
|
|
216
|
+
# reports zero for every window (EventVolume can only place an
|
|
217
|
+
# untracked remainder at the group's own occurred_at, which precedes
|
|
218
|
+
# the window -- see untracked_groups) while the events are real, the
|
|
219
|
+
# group is alive and a current gap records them. Reading that zero as
|
|
220
|
+
# "nothing happened" suppressed the banner in precisely the state it
|
|
221
|
+
# exists to announce.
|
|
222
|
+
#
|
|
223
|
+
# last_seen_at is the right evidence because it is what retention
|
|
224
|
+
# deletes on: a group is expired only once it has not been seen for
|
|
225
|
+
# retention_days, so "no group seen in this window" is the same fact as
|
|
226
|
+
# "the covered events are gone" -- and it is indexed, and immune to the
|
|
227
|
+
# timing loss itself.
|
|
228
|
+
#
|
|
229
|
+
# Suppressed only when BOTH witnesses are silent, because neither alone
|
|
230
|
+
# is sufficient and they fail in opposite directions. The aggregate
|
|
231
|
+
# misses an old group's untracked recurrence (the defect above); the
|
|
232
|
+
# liveness check misses the converse, since EventVolume windows
|
|
233
|
+
# occurrence rows and buckets on THEIR OWN timestamps against an
|
|
234
|
+
# unwindowed group set (see group_ids), so a group whose last_seen_at
|
|
235
|
+
# has fallen behind its own event rows can still put events on the page.
|
|
236
|
+
# Requiring both to be empty means the banner can never be dropped
|
|
237
|
+
# while any displayed figure is non-zero, which is the F22 invariant.
|
|
238
|
+
return false unless groups_seen_in_widest_window? || month_event_count.positive?
|
|
239
|
+
|
|
240
|
+
EventTimingGap.affecting?(WIDEST_DISPLAYED_WINDOW.ago, application_id: @application_id)
|
|
241
|
+
rescue StandardError
|
|
242
|
+
false
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Whether any error group was last seen inside the widest displayed
|
|
246
|
+
# window. Existence, not a count: one indexed row settles it, and this
|
|
247
|
+
# runs on the capture path via the stats broadcast.
|
|
248
|
+
#
|
|
249
|
+
# The NULL arm covers rows written before last_seen_at existed, whose
|
|
250
|
+
# occurred_at is the only timestamp they have -- the same COALESCE
|
|
251
|
+
# equivalence the retention job documents, written so each arm keeps its
|
|
252
|
+
# own index.
|
|
253
|
+
def groups_seen_in_widest_window?
|
|
254
|
+
cutoff = WIDEST_DISPLAYED_WINDOW.ago
|
|
255
|
+
base_scope.where(
|
|
256
|
+
"last_seen_at >= :cutoff OR (last_seen_at IS NULL AND occurred_at >= :cutoff)", cutoff: cutoff
|
|
257
|
+
).exists?
|
|
258
|
+
end
|
|
259
|
+
|
|
159
260
|
def reopened_count
|
|
160
261
|
return 0 unless ErrorLog.column_names.include?("reopened_at")
|
|
161
262
|
|
|
162
263
|
base_scope.where.not(reopened_at: nil).count
|
|
163
264
|
end
|
|
164
265
|
|
|
266
|
+
# Top error types in the last 7 days, by EVENTS.
|
|
267
|
+
#
|
|
268
|
+
# Filtering groups by first-seen and summing their LIFETIME
|
|
269
|
+
# occurrence_count answered a different question: an August group that
|
|
270
|
+
# recurred today was excluded entirely, so reopening it produced an empty
|
|
271
|
+
# list while the headline total (already on EventVolume) counted the
|
|
272
|
+
# event. Routed through the same primitive as the total so the page's
|
|
273
|
+
# parts and its whole cannot drift apart.
|
|
165
274
|
def top_errors
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
275
|
+
weekly_volume.by_group_attribute(:error_type)
|
|
276
|
+
.sort_by { |_, count| -count }
|
|
277
|
+
.first(10)
|
|
278
|
+
.to_h
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# One EventVolume for the 7-day window, shared by every weekly breakdown.
|
|
282
|
+
def weekly_volume
|
|
283
|
+
@weekly_volume ||= Queries::EventVolume.new(base_scope, 7.days.ago)
|
|
172
284
|
end
|
|
173
285
|
|
|
174
286
|
# Get 7-day error trend (daily counts)
|
|
287
|
+
# Events per day, placed on the day they HAPPENED. Grouping the ErrorLog
|
|
288
|
+
# table by its own occurred_at put a group's whole lifetime volume on the
|
|
289
|
+
# day it was first seen, so a recurrence never moved the trend.
|
|
290
|
+
#
|
|
291
|
+
# The zero-filled date range is preserved: the chart needs a point for
|
|
292
|
+
# every day in the window, not only the days that had errors.
|
|
175
293
|
def errors_trend_7d
|
|
176
|
-
|
|
177
|
-
base_scope.where("occurred_at >= ?", 7.days.ago)
|
|
178
|
-
.group_by_day(:occurred_at, range: 7.days.ago.to_date..Date.current, default_value: 0)
|
|
179
|
-
.sum(:occurrence_count)
|
|
294
|
+
week_event_counts
|
|
180
295
|
end
|
|
181
296
|
|
|
182
297
|
# Get error counts by severity for last 7 days
|
|
183
298
|
# OPTIMIZED: Use database filtering instead of loading all records into Ruby
|
|
299
|
+
# Weekly events by severity.
|
|
300
|
+
#
|
|
301
|
+
# Folded from the SAME per-type breakdown top_errors uses, rather than
|
|
302
|
+
# four independently scoped sums. Two reasons: it was summing LIFETIME
|
|
303
|
+
# counts of groups born in the window (so a reopened August group scored
|
|
304
|
+
# zero across every severity), and deriving both figures from one
|
|
305
|
+
# breakdown makes them agree by construction instead of by luck --
|
|
306
|
+
# asserted by spec/queries/dashboard_breakdowns_event_volume_spec.rb.
|
|
184
307
|
def errors_by_severity_7d
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
error_type
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
308
|
+
critical = Services::SeverityClassifier::CRITICAL_ERROR_TYPES
|
|
309
|
+
high = Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES
|
|
310
|
+
medium = Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES
|
|
311
|
+
|
|
312
|
+
totals = { critical: 0, high: 0, medium: 0, low: 0 }
|
|
313
|
+
weekly_volume.by_group_attribute(:error_type).each do |error_type, count|
|
|
314
|
+
bucket =
|
|
315
|
+
if critical.include?(error_type) then :critical
|
|
316
|
+
elsif high.include?(error_type) then :high
|
|
317
|
+
elsif medium.include?(error_type) then :medium
|
|
318
|
+
else :low
|
|
319
|
+
end
|
|
320
|
+
totals[bucket] += count
|
|
321
|
+
end
|
|
322
|
+
totals
|
|
197
323
|
end
|
|
198
324
|
|
|
199
325
|
# Detect if there's an error spike
|
|
@@ -241,8 +367,19 @@ module RailsErrorDashboard
|
|
|
241
367
|
info
|
|
242
368
|
end
|
|
243
369
|
|
|
370
|
+
# Today, yesterday and the 7-day total all come from ONE daily breakdown.
|
|
371
|
+
# Each window is three queries (occurrence rows, shed buckets, untracked
|
|
372
|
+
# remainder), and this method runs on the capture path via the stats
|
|
373
|
+
# broadcast, so asking per window multiplied the query count.
|
|
374
|
+
def week_event_counts
|
|
375
|
+
@week_event_counts ||= begin
|
|
376
|
+
counts = Queries::EventVolume.by_day(base_scope, 7.days.ago)
|
|
377
|
+
(7.days.ago.to_date..Date.current).to_h { |day| [ day, counts[day] || 0 ] }
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
244
381
|
def today_event_count
|
|
245
|
-
@today_event_count ||=
|
|
382
|
+
@today_event_count ||= week_event_counts[Date.current].to_i
|
|
246
383
|
end
|
|
247
384
|
|
|
248
385
|
# Every anomalous (error_type, platform) pair, from a fixed number of
|
|
@@ -340,7 +477,7 @@ module RailsErrorDashboard
|
|
|
340
477
|
|
|
341
478
|
def compute_trend_percentage
|
|
342
479
|
today = today_event_count
|
|
343
|
-
yesterday =
|
|
480
|
+
yesterday = week_event_counts[Date.current - 1].to_i
|
|
344
481
|
|
|
345
482
|
return 0.0 if today.zero? && yesterday.zero?
|
|
346
483
|
return 100.0 if yesterday.zero? && today.positive?
|
|
@@ -224,16 +224,14 @@ module RailsErrorDashboard
|
|
|
224
224
|
previous_start = @start_date
|
|
225
225
|
previous_end = current_start
|
|
226
226
|
|
|
227
|
-
#
|
|
228
|
-
#
|
|
229
|
-
# the one panel
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
previous_errors =
|
|
235
|
-
.where("occurred_at < ?", previous_end)
|
|
236
|
-
.count
|
|
227
|
+
# EVENTS in each period, counted where they happened, over app_scope
|
|
228
|
+
# (which carries the application filter -- querying ErrorLog directly
|
|
229
|
+
# once made this the one panel that counted every app). Counting groups
|
|
230
|
+
# BORN in each period measured how many new errors appeared, not whether
|
|
231
|
+
# the application errors more or less: a chronic error that fired all
|
|
232
|
+
# month counted as zero in both halves.
|
|
233
|
+
current_errors = Queries::EventVolume.in_window(app_scope, current_start)
|
|
234
|
+
previous_errors = Queries::EventVolume.in_window(app_scope, previous_start, previous_end)
|
|
237
235
|
|
|
238
236
|
change_percentage = if previous_errors > 0
|
|
239
237
|
((current_errors - previous_errors).to_f / previous_errors * 100).round(1)
|
|
@@ -261,25 +259,28 @@ module RailsErrorDashboard
|
|
|
261
259
|
# Get top error types by platform
|
|
262
260
|
# Shows which errors are platform-specific vs cross-platform
|
|
263
261
|
# @return [Hash] Platform => top error types
|
|
262
|
+
#
|
|
263
|
+
# EVENT ranking: each platform's error types by their events in the
|
|
264
|
+
# window, and `also_on` from where else that type had events. Counting
|
|
265
|
+
# groups first seen in the window left out every chronic error, and
|
|
266
|
+
# ranked a type with many one-off groups above one that fired all day.
|
|
264
267
|
def platform_specific_errors
|
|
265
|
-
platforms =
|
|
268
|
+
platforms = app_scope.distinct.pluck(:platform).compact
|
|
269
|
+
counts_by_platform = platforms.index_with do |platform|
|
|
270
|
+
Queries::EventVolume.by_group_attribute(app_scope.where(platform: platform), :error_type, @start_date)
|
|
271
|
+
.select { |_, count| count.positive? }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
counts_by_platform.each_with_object({}) do |(platform, counts_by_type), result|
|
|
275
|
+
next if counts_by_type.empty?
|
|
266
276
|
|
|
267
|
-
|
|
268
|
-
platform_errors = base_query.where(platform: platform)
|
|
269
|
-
top_errors = platform_errors
|
|
270
|
-
.group(:error_type)
|
|
271
|
-
.count
|
|
272
|
-
.sort_by { |_, count| -count }
|
|
273
|
-
.first(5)
|
|
277
|
+
top_errors = counts_by_type.sort_by { |error_type, count| [ -count, error_type.to_s ] }.first(5)
|
|
274
278
|
|
|
275
279
|
result[platform] = top_errors.map do |error_type, count|
|
|
276
|
-
#
|
|
277
|
-
other_platforms =
|
|
278
|
-
.
|
|
279
|
-
.
|
|
280
|
-
.distinct
|
|
281
|
-
.pluck(:platform)
|
|
282
|
-
.compact
|
|
280
|
+
# Other platforms where this type had events in the window
|
|
281
|
+
other_platforms = counts_by_platform
|
|
282
|
+
.select { |other, other_counts| other != platform && other_counts.key?(error_type) }
|
|
283
|
+
.keys
|
|
283
284
|
|
|
284
285
|
{
|
|
285
286
|
error_type: error_type,
|
|
@@ -293,12 +294,20 @@ module RailsErrorDashboard
|
|
|
293
294
|
|
|
294
295
|
private
|
|
295
296
|
|
|
296
|
-
|
|
297
|
-
|
|
297
|
+
# Every group of the application, NOT cut by date -- what EventVolume
|
|
298
|
+
# must be handed, or it never sees an older group's recurrences.
|
|
299
|
+
def app_scope
|
|
300
|
+
scope = ErrorLog.all
|
|
298
301
|
scope = scope.where(application_id: @application_id) if @application_id.present?
|
|
299
302
|
scope
|
|
300
303
|
end
|
|
301
304
|
|
|
305
|
+
# Groups FIRST SEEN in the window. Release attribution (errors by
|
|
306
|
+
# version / git SHA) is first-seen by design, so those figures use this.
|
|
307
|
+
def base_query
|
|
308
|
+
app_scope.where("occurred_at >= ?", @start_date)
|
|
309
|
+
end
|
|
310
|
+
|
|
302
311
|
# Check if app_version column exists
|
|
303
312
|
def has_version_column?
|
|
304
313
|
ErrorLog.column_names.include?("app_version")
|