rails_error_dashboard 0.13.0 → 0.14.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/rails_error_dashboard/async_error_logging_job.rb +10 -0
  3. data/app/jobs/rails_error_dashboard/retention_cleanup_job.rb +54 -0
  4. data/app/jobs/rails_error_dashboard/storm_flush_job.rb +7 -4
  5. data/app/models/rails_error_dashboard/error_log.rb +10 -0
  6. data/app/models/rails_error_dashboard/event_count.rb +132 -0
  7. data/app/models/rails_error_dashboard/event_timing_gap.rb +55 -0
  8. data/app/views/layouts/rails_error_dashboard.html.erb +47 -2
  9. data/app/views/rails_error_dashboard/errors/_request_context.html.erb +2 -0
  10. data/app/views/rails_error_dashboard/errors/overview.html.erb +12 -0
  11. data/app/views/rails_error_dashboard/errors/show.html.erb +3 -3
  12. data/config/locales/de.yml +2 -0
  13. data/config/locales/en.yml +2 -0
  14. data/config/locales/es.yml +2 -0
  15. data/config/locales/fr.yml +2 -0
  16. data/config/locales/it.yml +2 -0
  17. data/config/locales/ja.yml +2 -0
  18. data/config/locales/pl.yml +2 -0
  19. data/config/locales/pt-BR.yml +2 -0
  20. data/config/locales/ru.yml +2 -0
  21. data/config/locales/uk.yml +2 -0
  22. data/config/locales/zh-CN.yml +2 -0
  23. data/db/migrate/20260919000001_create_event_counts.rb +71 -0
  24. data/db/migrate/20260920000001_add_buckets_incomplete_to_storm_events.rb +25 -0
  25. data/db/migrate/20260920000002_create_event_timing_gaps.rb +55 -0
  26. data/lib/rails_error_dashboard/commands/find_or_increment_error.rb +70 -4
  27. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +225 -8
  28. data/lib/rails_error_dashboard/commands/log_error.rb +213 -21
  29. data/lib/rails_error_dashboard/configuration.rb +20 -0
  30. data/lib/rails_error_dashboard/engine.rb +13 -0
  31. data/lib/rails_error_dashboard/manual_error_reporter.rb +16 -5
  32. data/lib/rails_error_dashboard/queries/analytics_stats.rb +85 -27
  33. data/lib/rails_error_dashboard/queries/dashboard_stats.rb +167 -30
  34. data/lib/rails_error_dashboard/queries/event_volume.rb +503 -0
  35. data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +23 -0
  36. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +64 -6
  37. data/lib/rails_error_dashboard/services/variable_serializer.rb +125 -10
  38. data/lib/rails_error_dashboard/subscribers/breadcrumb_subscriber.rb +111 -0
  39. data/lib/rails_error_dashboard/value_objects/error_context.rb +37 -2
  40. data/lib/rails_error_dashboard/version.rb +1 -1
  41. data/lib/rails_error_dashboard.rb +3 -0
  42. 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: event_count_since(7.days.ago),
32
- total_month: event_count_since(30.days.ago),
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: the sum of every matching group's
120
- # occurrence_count. platform_comparison.rb counts the same way.
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
- base_scope.where("occurred_at >= ?", since).sum(:occurrence_count)
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
- base_scope.where("occurred_at >= ? AND occurred_at < ?", from, to).sum(:occurrence_count)
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
- base_scope.where("occurred_at >= ?", 7.days.ago)
167
- .group(:error_type)
168
- .sum(:occurrence_count)
169
- .sort_by { |_, count| -count }
170
- .first(10)
171
- .to_h
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
- @errors_trend_7d ||=
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
- scoped_errors = base_scope.where("occurred_at >= ?", 7.days.ago)
186
-
187
- {
188
- critical: scoped_errors.where(error_type: Services::SeverityClassifier::CRITICAL_ERROR_TYPES).sum(:occurrence_count),
189
- high: scoped_errors.where(error_type: Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES).sum(:occurrence_count),
190
- medium: scoped_errors.where(error_type: Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES).sum(:occurrence_count),
191
- low: scoped_errors.where.not(
192
- error_type: Services::SeverityClassifier::CRITICAL_ERROR_TYPES +
193
- Services::SeverityClassifier::HIGH_SEVERITY_ERROR_TYPES +
194
- Services::SeverityClassifier::MEDIUM_SEVERITY_ERROR_TYPES
195
- ).sum(:occurrence_count)
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 ||= event_count_since(Time.current.beginning_of_day)
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 = event_count_between(1.day.ago.beginning_of_day, Time.current.beginning_of_day)
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?