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.
- 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/event_volume.rb +503 -0
- data/lib/rails_error_dashboard/services/breadcrumb_collector.rb +23 -0
- 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
|
@@ -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
|
-
|
|
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)
|