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
@@ -0,0 +1,503 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Queries
5
+ # How many EVENTS happened inside a time window.
6
+ #
7
+ # The distinction this class exists to make: an ErrorLog row is a GROUP,
8
+ # and its occurred_at is FIRST-SEEN -- written once at creation and never
9
+ # rewritten when the error recurs. Filtering groups by occurred_at and
10
+ # summing occurrence_count therefore answers "how much lifetime volume do
11
+ # the groups born in this window carry?", which is not the same question as
12
+ # "how many events happened in this window?". An error first seen at 23:59
13
+ # that recurs at 00:01 reported zero errors today and two yesterday.
14
+ #
15
+ # An event lands in exactly one of three places, and the window total is
16
+ # the sum of all three:
17
+ #
18
+ # 1. an ErrorOccurrence row -- ordinary captures, with a real
19
+ # per-event timestamp
20
+ # 2. an EventCount bucket -- storm-shed events, which write no
21
+ # occurrence row by design; the bucket
22
+ # is their timestamp
23
+ # 3. the group's own count -- rows from before occurrence tracking
24
+ # existed, which have neither of the
25
+ # above. Counted against the group's
26
+ # occurred_at, which for such a row is
27
+ # the best (and only) timestamp there is
28
+ #
29
+ # Nothing is double counted: (1) and (2) are written by mutually exclusive
30
+ # paths, and (3) only ever covers the remainder of a group that has no
31
+ # per-event record at all.
32
+ class EventVolume
33
+ # @param scope [ActiveRecord::Relation] an ErrorLog scope (already
34
+ # filtered by application, if applicable)
35
+ # @param from [Time]
36
+ # @param to [Time, nil] exclusive upper bound; open-ended when nil
37
+ # @return [Integer]
38
+ def self.in_window(scope, from, to = nil)
39
+ new(scope, from, to).count
40
+ end
41
+
42
+ # Same window, bucketed by day: { Date => Integer }.
43
+ def self.by_day(scope, from, to = nil)
44
+ new(scope, from, to).by_day
45
+ end
46
+
47
+ # Same window, grouped by a column on the GROUP row (error_type,
48
+ # platform, environment): { value => Integer }.
49
+ #
50
+ # Every breakdown has to sum the same three terms as the headline total,
51
+ # or a page's parts stop adding up to its whole. Exposing one primitive
52
+ # is what stops each call site reimplementing that sum and drifting --
53
+ # which is precisely how the Analytics page came to disagree with the
54
+ # Overview.
55
+ def self.by_group_attribute(scope, column, from, to = nil)
56
+ new(scope, from, to).by_group_attribute(column)
57
+ end
58
+
59
+ def initialize(scope, from, to = nil)
60
+ @scope = scope
61
+ @from = from
62
+ @to = to
63
+ end
64
+
65
+ def count
66
+ occurrence_events + bucketed_events + untracked_events
67
+ end
68
+
69
+ def by_day
70
+ totals = Hash.new(0)
71
+ occurrence_events_by_day.each { |day, n| totals[day] += n }
72
+ bucketed_events_by_day.each { |day, n| totals[day] += n }
73
+ untracked_events_by_day.each { |day, n| totals[day] += n }
74
+ totals
75
+ end
76
+
77
+ # Events in the window, bucketed by hour-of-day (0..23) against each
78
+ # event's OWN timestamp -- the diurnal curve, not a time series.
79
+ # The hour is LOCAL for the same reason the day is: an operator asking
80
+ # when their errors peak means their own clock. Bucketed in Ruby from the
81
+ # windowed, already-aggregated rows so the offset used is the one in
82
+ # force at each instant.
83
+ def by_hour_of_day
84
+ zone = self.class.reporting_zone
85
+ totals = Hash.new(0)
86
+ (0..23).each { |h| totals[h] = 0 }
87
+
88
+ occurrence_events_by_hour.each { |ts, n| totals[local_hour(ts, zone)] += n }
89
+ bucketed_events_by_hour.each { |ts, n| totals[local_hour(ts, zone)] += n }
90
+ untracked_groups.each do |_id, remainder, occurred_at|
91
+ totals[occurred_at.in_time_zone(zone).hour] += remainder if occurred_at
92
+ end
93
+ totals
94
+ end
95
+
96
+ # Events in the window, grouped by a column on the ErrorLog row.
97
+ #
98
+ # All three terms are joined back to their group so they can be grouped
99
+ # by the group's own attribute: occurrence rows and buckets carry no
100
+ # error_type of their own. Aggregation stays in SQL (NFR-6) -- the only
101
+ # thing loaded into Ruby is the grouped result.
102
+ def by_group_attribute(column)
103
+ totals = Hash.new(0)
104
+ occurrence_events_by_attribute(column).each { |k, n| totals[k] += n }
105
+ bucketed_events_by_attribute(column).each { |k, n| totals[k] += n }
106
+ untracked_events_by_attribute(column).each { |k, n| totals[k] += n }
107
+ totals
108
+ end
109
+
110
+ private
111
+
112
+ # A SUBQUERY, not a plucked array of ids: the group set is unbounded and
113
+ # loading it into Ruby to pass back as an IN list is the shape that has
114
+ # caused unbounded-memory bugs in this codebase before. The database
115
+ # keeps the id set on its own side.
116
+ def group_ids
117
+ @group_ids ||= @scope.select(:id)
118
+ end
119
+
120
+ # (1) Ordinary captures.
121
+ def occurrence_events
122
+ return 0 unless occurrences_available?
123
+
124
+ window(ErrorOccurrence.where(error_log_id: group_ids), ErrorOccurrence.table_name).count
125
+ end
126
+
127
+ def occurrence_events_by_day
128
+ return {} unless occurrences_available?
129
+
130
+ rows = window(ErrorOccurrence.where(error_log_id: group_ids), ErrorOccurrence.table_name)
131
+ .group(day_expression(ErrorOccurrence.table_name, "occurred_at")).count
132
+
133
+ ruby_side_day_bucketing? ? group_by_local_day(rows) : rows.transform_keys { |k| to_date(k) }
134
+ end
135
+
136
+ # (2) Storm-shed events.
137
+ def bucketed_events
138
+ return 0 unless buckets_available?
139
+
140
+ window(EventCount.where(error_log_id: group_ids), EventCount.table_name, column: "bucket_at")
141
+ .sum(:count)
142
+ end
143
+
144
+ def bucketed_events_by_day
145
+ return {} unless buckets_available?
146
+
147
+ rows = window(EventCount.where(error_log_id: group_ids), EventCount.table_name, column: "bucket_at")
148
+ .group(day_expression(EventCount.table_name, "bucket_at")).sum(:count)
149
+
150
+ ruby_side_day_bucketing? ? group_by_local_day(rows) : rows.transform_keys { |k| to_date(k) }
151
+ end
152
+
153
+ # (3) Groups with no per-event record of any kind: rows created before
154
+ # occurrence tracking, and rows written directly. Their occurrence_count
155
+ # is the only evidence the events happened, and the group's own
156
+ # occurred_at is the only timestamp available for them.
157
+ def untracked_groups
158
+ @untracked_groups ||= begin
159
+ # Only groups whose own occurred_at falls in the window can
160
+ # contribute here at all, so the scan is bounded by the window rather
161
+ # than by the whole table.
162
+ #
163
+ # One SELECT with two correlated sub-selects, rather than three
164
+ # separate round trips: this runs on the capture path (the stats
165
+ # broadcast recomputes it) and the dashboard asks for several windows
166
+ # per render, so a per-window query count multiplies quickly.
167
+ rows = ErrorLog.connection.select_all(untracked_sql(window(@scope, ErrorLog.table_name)))
168
+ rows.filter_map do |row|
169
+ remainder = row["occurrence_count"].to_i - row["tracked"].to_i
170
+ next if remainder <= 0
171
+
172
+ [ row["id"], remainder, to_time(row["occurred_at"]) ]
173
+ end
174
+ end
175
+ end
176
+
177
+ def untracked_sql(candidates)
178
+ logs = ErrorLog.table_name
179
+ occurrence_term =
180
+ if occurrences_available?
181
+ "(SELECT COUNT(*) FROM #{ErrorOccurrence.table_name} o " \
182
+ "WHERE o.error_log_id = #{logs}.id)"
183
+ else
184
+ "0"
185
+ end
186
+ bucket_term =
187
+ if buckets_available?
188
+ "(SELECT COALESCE(SUM(b.count), 0) FROM #{EventCount.table_name} b " \
189
+ "WHERE b.error_log_id = #{logs}.id)"
190
+ else
191
+ "0"
192
+ end
193
+
194
+ candidates
195
+ .select(Arel.sql("#{logs}.id, #{logs}.occurrence_count, #{logs}.occurred_at, " \
196
+ "#{occurrence_term} + #{bucket_term} AS tracked"))
197
+ .to_sql
198
+ end
199
+
200
+ def to_time(value)
201
+ return value if value.respond_to?(:to_date) && !value.is_a?(String)
202
+
203
+ Time.zone ? Time.zone.parse(value.to_s) : Time.parse(value.to_s)
204
+ rescue StandardError
205
+ nil
206
+ end
207
+
208
+ # Grouped by the HOUR, never by the raw timestamp.
209
+ #
210
+ # Grouping by the raw timestamp returned one row per distinct instant:
211
+ # 1,000 events inside one second produced 1,000 intermediate entries in
212
+ # Ruby to compute 24 bins. Memory has to be bounded by the reporting
213
+ # WINDOW, not by how many distinct timestamps happen to be in it -- a
214
+ # burst is exactly when these numbers matter and exactly when the row
215
+ # count explodes.
216
+ #
217
+ # On PostgreSQL/MySQL the local hour is derived in SQL, so at most 24
218
+ # rows come back. SQLite has no tz database, so it truncates to a
219
+ # FIXED-WIDTH UTC bin in SQL (bounding the result by the window) and Ruby
220
+ # converts that bin's instant to the local hour.
221
+ def occurrence_events_by_hour
222
+ return {} unless occurrences_available?
223
+
224
+ table = ErrorOccurrence.table_name
225
+ window(ErrorOccurrence.where(error_log_id: group_ids), table)
226
+ .group(hour_expression(table, "occurred_at")).count
227
+ end
228
+
229
+ def bucketed_events_by_hour
230
+ return {} unless buckets_available?
231
+
232
+ table = EventCount.table_name
233
+ window(EventCount.where(error_log_id: group_ids), table, column: "bucket_at")
234
+ .group(hour_expression(table, "bucket_at")).sum(:count)
235
+ end
236
+
237
+ # The width of the SQLite grouping bin, in seconds.
238
+ #
239
+ # A local hour boundary must always fall on a bin EDGE, or two events on
240
+ # opposite sides of it collapse into one bin and can no longer be told
241
+ # apart: in Asia/Kolkata (+05:30) 00:15 and 00:45 UTC are local hours 5
242
+ # and 6, but share a UTC hour. So the bin has to divide every zone offset
243
+ # in use. Offsets are whole multiples of 15 minutes (+05:30, +05:45,
244
+ # -09:30 and the rest), which makes 15 minutes the widest safe bin -- the
245
+ # same 900s quantum EventCount::BUCKET_SECONDS uses, for the same
246
+ # divides-the-clock-cleanly reason.
247
+ #
248
+ # Width matters only for the row count, which stays bounded by the
249
+ # WINDOW (4 rows per hour) rather than by the number of distinct event
250
+ # timestamps in it.
251
+ HOUR_BIN_SECONDS = 900
252
+
253
+ # The grouping key for hour-of-day aggregation.
254
+ #
255
+ # Returns the local hour directly where the adapter can convert zones,
256
+ # and a UTC bin key where it cannot. local_hour handles both: a Numeric
257
+ # passes straight through, a key is parsed AS UTC and converted.
258
+ def hour_expression(table, column)
259
+ zone = self.class.reporting_zone
260
+ quoted = ErrorLog.connection.quote(zone.tzinfo.name)
261
+
262
+ Arel.sql(
263
+ case ErrorLog.connection.adapter_name.downcase
264
+ when /postgres/
265
+ "EXTRACT(HOUR FROM #{table}.#{column} AT TIME ZONE 'UTC' AT TIME ZONE #{quoted})"
266
+ when /mysql|trilogy/
267
+ # Named zone, not a numeric offset -- same DST reasoning as
268
+ # day_expression.
269
+ "HOUR(CONVERT_TZ(#{table}.#{column}, '+00:00', #{quoted}))"
270
+ else
271
+ # SQLite: bound the row count by truncating the UTC epoch second to
272
+ # a whole bin. Integer division floors, which is what keeps every
273
+ # bin edge on a multiple of HOUR_BIN_SECONDS from the epoch -- and
274
+ # therefore on every local hour boundary. Ruby then shifts the bin
275
+ # into the reporting zone.
276
+ "(CAST(strftime('%s', #{table}.#{column}) AS INTEGER) / #{HOUR_BIN_SECONDS}) * #{HOUR_BIN_SECONDS}"
277
+ end
278
+ )
279
+ end
280
+
281
+ # The adapter may hand back either an hour already binned in SQL
282
+ # (PostgreSQL/MySQL, as a Numeric or a numeric string) or a UTC bin key
283
+ # that still needs converting (SQLite: epoch seconds).
284
+ #
285
+ # SQLite's key is an epoch second, so it carries its own UTC meaning and
286
+ # there is nothing to misread. The earlier key was a bare
287
+ # 'YYYY-MM-DD HH:00:00' string, which Time.zone.parse read as LOCAL time
288
+ # -- reporting the UTC hour verbatim.
289
+ def local_hour(value, zone)
290
+ return Time.at(value.to_i).utc.in_time_zone(zone).hour if sqlite_hour_bins?
291
+
292
+ return value.to_i % 24 if value.is_a?(Numeric)
293
+ return value.to_i % 24 if value.is_a?(String) && value.match?(/\A\d+(\.\d+)?\z/)
294
+
295
+ time = to_time(value)
296
+ time ? time.in_time_zone(zone).hour : 0
297
+ end
298
+
299
+ # True when hour_expression fell through to the SQLite branch and the
300
+ # keys are UTC bin epochs rather than hours binned in SQL.
301
+ def sqlite_hour_bins?
302
+ !ErrorLog.connection.adapter_name.downcase.match?(/postgres|mysql|trilogy/)
303
+ end
304
+
305
+ # The three by-attribute terms. Each joins back to ErrorLog because the
306
+ # attribute being grouped by lives on the GROUP, not on the event row.
307
+ def occurrence_events_by_attribute(column)
308
+ return {} unless occurrences_available?
309
+
310
+ logs = ErrorLog.table_name
311
+ window(
312
+ ErrorOccurrence.where(error_log_id: group_ids)
313
+ .joins("INNER JOIN #{logs} ON #{logs}.id = #{ErrorOccurrence.table_name}.error_log_id"),
314
+ ErrorOccurrence.table_name
315
+ ).group("#{logs}.#{column}").count
316
+ end
317
+
318
+ def bucketed_events_by_attribute(column)
319
+ return {} unless buckets_available?
320
+
321
+ logs = ErrorLog.table_name
322
+ window(
323
+ EventCount.where(error_log_id: group_ids)
324
+ .joins("INNER JOIN #{logs} ON #{logs}.id = #{EventCount.table_name}.error_log_id"),
325
+ EventCount.table_name,
326
+ column: "bucket_at"
327
+ ).group("#{logs}.#{column}").sum(:count)
328
+ end
329
+
330
+ # The untracked remainder is already resolved to (id, remainder), so the
331
+ # attribute is fetched for just those ids -- a bounded set, since only
332
+ # groups whose own occurred_at falls in the window can contribute.
333
+ def untracked_events_by_attribute(column)
334
+ rows = untracked_groups
335
+ return {} if rows.empty?
336
+
337
+ attributes = ErrorLog.where(id: rows.map(&:first)).pluck(:id, column).to_h
338
+ totals = Hash.new(0)
339
+ rows.each { |id, remainder, _occurred_at| totals[attributes[id]] += remainder }
340
+ totals
341
+ end
342
+
343
+ def untracked_events
344
+ untracked_groups.sum { |_id, remainder, _occurred_at| remainder }
345
+ end
346
+
347
+ # Already in Ruby, so the zone conversion is direct -- and uses the
348
+ # offset in force at each row's own instant.
349
+ def untracked_events_by_day
350
+ zone = self.class.reporting_zone
351
+ totals = Hash.new(0)
352
+ untracked_groups.each do |_id, remainder, occurred_at|
353
+ next unless occurred_at
354
+
355
+ totals[occurred_at.in_time_zone(zone).to_date] += remainder
356
+ end
357
+ totals
358
+ end
359
+
360
+ def window(relation, table, column: "occurred_at")
361
+ relation = relation.where("#{table}.#{column} >= ?", @from)
362
+ @to ? relation.where("#{table}.#{column} < ?", @to) : relation
363
+ end
364
+
365
+ # Grouping by day has to happen in SQL -- loading rows to bucket them in
366
+ # Ruby is exactly the unbounded-memory shape this codebase avoids.
367
+ # The reporting zone: one definition, used for BOTH the SQL bucket key
368
+ # and the Ruby-side window boundaries, so the two cannot disagree.
369
+ def self.reporting_zone
370
+ Time.zone || ActiveSupport::TimeZone["UTC"]
371
+ end
372
+
373
+ # Group by calendar day IN THE APPLICATION TIME ZONE.
374
+ #
375
+ # Timestamps are stored in UTC. A bare DATE(column) therefore buckets by
376
+ # UTC day while the caller looks up Date.current in Time.zone -- at 00:15
377
+ # in Asia/Kolkata a fresh capture is stored as 18:45 the previous day UTC,
378
+ # so "today" reported zero. The offset must also be the one in force AT
379
+ # EACH ROW'S OWN TIMESTAMP, not one current offset applied to the whole
380
+ # window, or a window spanning a DST change misplaces every row on one
381
+ # side of it.
382
+ #
383
+ # PostgreSQL and MySQL have a tz database and do this per row natively.
384
+ # SQLite has neither AT TIME ZONE nor CONVERT_TZ, and its 'localtime'
385
+ # modifier uses the SERVER's zone rather than the application's -- so
386
+ # there the conversion is done in Ruby, where the zone object knows each
387
+ # instant's true offset. That path is bounded: it groups an already
388
+ # windowed relation, and only the grouped result reaches Ruby.
389
+ def day_expression(table, column)
390
+ zone = self.class.reporting_zone
391
+
392
+ Arel.sql(
393
+ case ErrorLog.connection.adapter_name.downcase
394
+ when /postgres/
395
+ "DATE(#{table}.#{column} AT TIME ZONE 'UTC' AT TIME ZONE #{ErrorLog.connection.quote(zone.tzinfo.name)})"
396
+ when /mysql|trilogy/
397
+ # A NAMED zone, not a numeric offset. CONVERT_TZ with '+05:30'
398
+ # applies one fixed offset to every row, which silently misplaces
399
+ # rows on the far side of a DST transition; the named form uses the
400
+ # offset in force at each row's own timestamp.
401
+ #
402
+ # This needs the server's time-zone tables (mysql_tzinfo_to_sql) --
403
+ # the same requirement groupdate already imposes for every chart on
404
+ # the dashboard, and which `rails error_dashboard:verify` checks.
405
+ # See docs/guides/DATABASE_OPTIONS.md.
406
+ "DATE(CONVERT_TZ(#{table}.#{column}, '+00:00', #{ErrorLog.connection.quote(zone.tzinfo.name)}))"
407
+ else
408
+ # SQLite: no tz database, so the fold into local days happens in
409
+ # Ruby (see group_by_local_day). The SQL key must still be BOUNDED
410
+ # -- grouping by the raw timestamp returned one row per distinct
411
+ # instant, so a burst of 200 events in one day handed Ruby 200 rows
412
+ # to produce a single daily total. Memory has to be bounded by the
413
+ # reporting WINDOW, not by how many distinct timestamps are in it.
414
+ #
415
+ # The bound is a 15-minute truncated UTC bin, the same width the
416
+ # storm rollup uses and for the same reason: 15 minutes divides
417
+ # every UTC offset in use (including +05:30 Kolkata and +05:45
418
+ # Kathmandu), so a LOCAL day boundary always falls on a bin edge
419
+ # and no bin ever straddles two local days. An hour-wide bin would
420
+ # NOT be safe in those zones.
421
+ #
422
+ # The key is the bin's EPOCH SECOND, not a datetime string. An
423
+ # epoch second carries its own UTC meaning, so there is nothing to
424
+ # misread; a bare 'YYYY-MM-DD HH:MM:SS' string is parsed as LOCAL
425
+ # by Time.zone.parse, which would shift every bin by the reporting
426
+ # zone's offset. Integer division floors, keeping every bin edge on
427
+ # a multiple of the width from the epoch -- and therefore on every
428
+ # local midnight.
429
+ "(CAST(strftime('%s', #{table}.#{column}) AS INTEGER) / #{DAY_BIN_SECONDS}) * #{DAY_BIN_SECONDS}"
430
+ end
431
+ )
432
+ end
433
+
434
+ # The width of the SQLite day-grouping bin, in seconds -- the same 900s
435
+ # quantum, and the same reasoning, as HOUR_BIN_SECONDS above: it has to
436
+ # divide every zone offset in use so a local DAY boundary falls on a bin
437
+ # EDGE and no bin ever straddles two local days.
438
+ #
439
+ # This must equal EventCount::BUCKET_SECONDS, and there is a spec that
440
+ # asserts it. It is a literal rather than a reference because EventCount
441
+ # is an autoloaded model and this constant is evaluated at load time.
442
+ DAY_BIN_SECONDS = 900
443
+
444
+ # True when the adapter cannot convert zones itself and Ruby must.
445
+ def ruby_side_day_bucketing?
446
+ !ErrorLog.connection.adapter_name.downcase.match?(/postgres|mysql|trilogy/)
447
+ end
448
+
449
+ # Collapse a { utc instant => count } result into { Date => count } using
450
+ # the zone's offset AT EACH instant -- which is what makes a DST-spanning
451
+ # window correct. The keys are 15-minute bins (see day_expression), and
452
+ # because that width divides every offset in use, a bin never straddles
453
+ # two local days: folding by the bin's own instant is exact.
454
+ def group_by_local_day(rows)
455
+ zone = self.class.reporting_zone
456
+ totals = Hash.new(0)
457
+ rows.each do |key, value|
458
+ time = to_utc_bin_time(key)
459
+ next unless time
460
+
461
+ totals[time.in_time_zone(zone).to_date] += value
462
+ end
463
+ totals
464
+ end
465
+
466
+ # day_expression's SQLite key is a UTC epoch second (see there), which is
467
+ # unambiguous. Anything else reaching here is already a Time-like value
468
+ # from another adapter, so it is used as-is -- deliberately NOT routed
469
+ # through Time.zone.parse, which reads a bare datetime string as LOCAL.
470
+ def to_utc_bin_time(value)
471
+ return Time.at(value.to_i).utc if value.is_a?(Numeric)
472
+ return Time.at(value.to_i).utc if value.is_a?(String) && value.match?(/\A-?\d+\z/)
473
+ return value if value.respond_to?(:in_time_zone) && !value.is_a?(String)
474
+
475
+ nil
476
+ end
477
+
478
+ def to_date(value)
479
+ return value if value.is_a?(Date)
480
+
481
+ value.respond_to?(:to_date) ? value.to_date : Date.parse(value.to_s)
482
+ rescue StandardError
483
+ value
484
+ end
485
+
486
+ def occurrences_available?
487
+ return @occurrences_available if defined?(@occurrences_available)
488
+
489
+ @occurrences_available = defined?(ErrorOccurrence) && ErrorOccurrence.table_exists?
490
+ rescue StandardError
491
+ @occurrences_available = false
492
+ end
493
+
494
+ def buckets_available?
495
+ return @buckets_available if defined?(@buckets_available)
496
+
497
+ @buckets_available = defined?(EventCount) && EventCount.table_exists?
498
+ rescue StandardError
499
+ @buckets_available = false
500
+ end
501
+ end
502
+ end
503
+ end
@@ -77,6 +77,29 @@ module RailsErrorDashboard
77
77
  nil
78
78
  end
79
79
 
80
+ # Open a buffer ONLY if this thread has none, and say whether we opened
81
+ # it. The caller passes that answer back to clear_buffer_if_owned, so a
82
+ # job performed inline inside a request adds its crumbs to the request's
83
+ # trail and does not tear it down on the way out.
84
+ #
85
+ # Unconditional init/clear here would erase a surrounding request's
86
+ # buffer on every perform_now, the test adapter and the :inline queue.
87
+ # @return [Boolean] true when this caller opened the buffer
88
+ def self.init_buffer_unless_present
89
+ return false if Thread.current[THREAD_KEY]
90
+
91
+ init_buffer
92
+ true
93
+ rescue => e
94
+ RailsErrorDashboard::Logger.debug("[RailsErrorDashboard] BreadcrumbCollector.init_buffer_unless_present failed: #{e.message}")
95
+ false
96
+ end
97
+
98
+ # Tear down only what this caller opened (see init_buffer_unless_present).
99
+ def self.clear_buffer_if_owned(owned)
100
+ clear_buffer if owned
101
+ end
102
+
80
103
  # Clear the ring buffer (end of request — MUST be called in ensure block)
81
104
  def self.clear_buffer
82
105
  Thread.current[THREAD_KEY] = nil
@@ -26,12 +26,26 @@ module RailsErrorDashboard
26
26
  # old reference just before the swap would increment a map nobody would
27
27
  # ever read again, and the "exact" count lost an event.
28
28
  class CountBuffer
29
+ # How finely shed events are timestamped.
30
+ #
31
+ # 15 minutes, not an hour: every UTC offset in use divides into 15
32
+ # minutes -- including +05:30 (Kolkata) and +05:45 (Kathmandu) -- so a
33
+ # local midnight always falls on a bucket EDGE and a day's total is
34
+ # exact. An hourly bucket straddles those boundaries and could only
35
+ # ever report them approximately.
36
+ BUCKET_SECONDS = 900
37
+
29
38
  Entry = Struct.new(
30
39
  :error_class, :message, :first_app_frame,
31
40
  :controller_name, :action_name, :custom_hash, :environment,
32
- :opaque_identity, :count, :first_seen_at, :last_seen_at
41
+ :opaque_identity, :count, :first_seen_at, :last_seen_at, :buckets
33
42
  )
34
43
 
44
+ # The bucket an instant belongs to, as an epoch second.
45
+ def self.bucket_for(time)
46
+ (time.to_i / BUCKET_SECONDS) * BUCKET_SECONDS
47
+ end
48
+
35
49
  def initialize
36
50
  reset!
37
51
  end
@@ -46,7 +60,10 @@ module RailsErrorDashboard
46
60
  # @param gate_key [String] cheap in-process bucketing key
47
61
  # @param parts [Hash] identity parts captured at the gate
48
62
  def record(gate_key, parts)
49
- @lock.with_read_lock { add(gate_key, parts, 1, Time.current, Time.current) }
63
+ now = Time.current
64
+ @lock.with_read_lock do
65
+ add(gate_key, parts, 1, now, now, { self.class.bucket_for(now) => 1 })
66
+ end
50
67
  end
51
68
 
52
69
  # Put a snapshot BACK when its handoff failed (the flush job could not
@@ -66,7 +83,8 @@ module RailsErrorDashboard
66
83
  parts_from(entry),
67
84
  entry["count"].to_i,
68
85
  parse_time(entry["first_seen_at"]),
69
- parse_time(entry["last_seen_at"])
86
+ parse_time(entry["last_seen_at"]),
87
+ buckets_from(entry)
70
88
  )
71
89
  end
72
90
  @overflow.increment(overflow.to_i) if overflow.to_i.positive?
@@ -104,7 +122,12 @@ module RailsErrorDashboard
104
122
  "opaque_identity" => entry.opaque_identity,
105
123
  "count" => entry.count.value,
106
124
  "first_seen_at" => entry.first_seen_at.iso8601,
107
- "last_seen_at" => entry.last_seen_at.iso8601
125
+ "last_seen_at" => entry.last_seen_at.iso8601,
126
+ # { epoch_second => count }. This is the timing evidence: the
127
+ # flush job reconciles each bucket separately, because a total
128
+ # plus last_seen_at cannot say how many events fell on either
129
+ # side of a day boundary.
130
+ "buckets" => entry.buckets.each_pair.to_h { |at, n| [ at.to_s, n.value ] }
108
131
  }
109
132
  end
110
133
 
@@ -127,7 +150,7 @@ module RailsErrorDashboard
127
150
  private
128
151
 
129
152
  # Callers hold the read lock.
130
- def add(gate_key, parts, count, first_seen_at, last_seen_at)
153
+ def add(gate_key, parts, count, first_seen_at, last_seen_at, buckets = nil)
131
154
  return if count <= 0
132
155
 
133
156
  map = @map_ref.get
@@ -143,7 +166,8 @@ module RailsErrorDashboard
143
166
  parts[:error_class], parts[:message], parts[:first_app_frame],
144
167
  parts[:controller_name], parts[:action_name], parts[:custom_hash], parts[:environment],
145
168
  parts[:opaque_identity],
146
- Concurrent::AtomicFixnum.new(0), first_seen_at || Time.current, last_seen_at || Time.current
169
+ Concurrent::AtomicFixnum.new(0), first_seen_at || Time.current, last_seen_at || Time.current,
170
+ Concurrent::Map.new
147
171
  )
148
172
  end
149
173
  end
@@ -151,6 +175,40 @@ module RailsErrorDashboard
151
175
  entry.count.increment(count)
152
176
  entry.last_seen_at = [ entry.last_seen_at, last_seen_at ].compact.max
153
177
  entry.first_seen_at = [ entry.first_seen_at, first_seen_at ].compact.min
178
+
179
+ # Per-bucket tallies use the same AtomicFixnum-inside-a-Concurrent::Map
180
+ # shape as the total above, so they are safe under the READ lock and
181
+ # the write lock's scope is not widened (it still covers only the
182
+ # snapshot swap).
183
+ add_buckets(entry, count, last_seen_at, buckets)
184
+ end
185
+
186
+ # Distribute `count` across buckets. A live record supplies exactly one
187
+ # bucket; a restore supplies the map it was snapshotted with. The
188
+ # fallback keeps a caller that supplies none from losing the timing
189
+ # entirely -- it lands on last_seen_at's bucket, which is what the old
190
+ # behaviour did for every event.
191
+ def add_buckets(entry, count, last_seen_at, buckets)
192
+ pairs =
193
+ if buckets.is_a?(Hash) && buckets.any?
194
+ buckets
195
+ else
196
+ { self.class.bucket_for(last_seen_at || Time.current) => count }
197
+ end
198
+
199
+ pairs.each do |at, n|
200
+ n = n.to_i
201
+ next unless n.positive?
202
+
203
+ entry.buckets.compute_if_absent(at.to_i) { Concurrent::AtomicFixnum.new(0) }.increment(n)
204
+ end
205
+ end
206
+
207
+ def buckets_from(entry)
208
+ raw = entry["buckets"]
209
+ return nil unless raw.is_a?(Hash)
210
+
211
+ raw.to_h { |at, n| [ at.to_i, n.to_i ] }
154
212
  end
155
213
 
156
214
  def parts_from(entry)