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
|
@@ -19,6 +19,14 @@ module RailsErrorDashboard
|
|
|
19
19
|
ActiveRecord::Deadlocked
|
|
20
20
|
].freeze
|
|
21
21
|
|
|
22
|
+
# Context keys whose Hash value ErrorContext#extract_params folds into the
|
|
23
|
+
# stored request_params. Each one must be redacted before the payload
|
|
24
|
+
# crosses the queue, or the queue and the database drift apart.
|
|
25
|
+
# :job/:job_class carry live objects rather than Hashes and are skipped by
|
|
26
|
+
# the is_a?(Hash) guard; their arguments reach the payload through
|
|
27
|
+
# :params, which is covered here.
|
|
28
|
+
CONTEXT_PARAM_KEYS = %i[params additional_context metadata].freeze
|
|
29
|
+
|
|
22
30
|
def self.call(exception, context = {})
|
|
23
31
|
# Filter FIRST (ignore list + static sampling) so ignored exceptions
|
|
24
32
|
# never count toward storm state. _pre_filtered prevents the sync path
|
|
@@ -79,7 +87,14 @@ module RailsErrorDashboard
|
|
|
79
87
|
# Kept as a module-level helper so both sync and async paths can call it.
|
|
80
88
|
# @return [Hash<String, Object>]
|
|
81
89
|
def self.build_capture_span_attributes(exception, was_async:)
|
|
82
|
-
|
|
90
|
+
# Redact BEFORE truncating. The span leaves the process for a collector
|
|
91
|
+
# the host app may not control, so it is an export boundary and gets the
|
|
92
|
+
# same policy as storage -- otherwise enabling tracing silently widened
|
|
93
|
+
# what counts as safe to emit. filter_attributes returns its input
|
|
94
|
+
# unchanged when filter_sensitive_data is off and rescues internally.
|
|
95
|
+
msg = Services::SensitiveDataFilter.filter_attributes(
|
|
96
|
+
message: exception.message.to_s
|
|
97
|
+
)[:message].to_s
|
|
83
98
|
{
|
|
84
99
|
"error.type" => exception.class.name,
|
|
85
100
|
"error.message" => msg.length > 200 ? "#{msg[0, 200]}…" : msg,
|
|
@@ -133,13 +148,33 @@ module RailsErrorDashboard
|
|
|
133
148
|
exception_data, context = redact_async_payload(exception_data, context)
|
|
134
149
|
context = context.merge(_identity: identity_parts) if identity_parts
|
|
135
150
|
|
|
151
|
+
# Stamp WHEN and WHAT RELEASE this event was captured under, before it
|
|
152
|
+
# crosses the queue. Both used to be resolved by the worker from
|
|
153
|
+
# Time.current and its own process configuration, so a queue backed up
|
|
154
|
+
# across a deploy gave the event the drain time and the new release --
|
|
155
|
+
# an error captured at 12:00 under v1 and drained at 14:00 under v2 was
|
|
156
|
+
# stored as 14:00/v2, and release comparison blamed the wrong build.
|
|
157
|
+
# The row's created_at still records when the worker wrote it, so queue
|
|
158
|
+
# lag stays observable.
|
|
159
|
+
context = context.merge(_captured_at: (normalized_occurred_at(context) || Time.current).iso8601(6))
|
|
160
|
+
context = context.merge(_app_version: capture_app_version) unless context.key?(:_app_version)
|
|
161
|
+
context = context.merge(_git_sha: capture_git_sha) unless context.key?(:_git_sha)
|
|
162
|
+
|
|
136
163
|
# Storm shedding: :lite captures skip ALL pre-enqueue context harvest —
|
|
137
164
|
# this is request-thread CPU, the most valuable thing to shed.
|
|
138
165
|
lite = storm_lite?(context)
|
|
139
166
|
|
|
140
167
|
# Harvest breadcrumbs NOW (before job dispatch — different thread won't have them)
|
|
141
168
|
if !lite && RailsErrorDashboard.configuration.enable_breadcrumbs
|
|
142
|
-
|
|
169
|
+
# A failing job's snapshot rides the envelope: the worker that runs
|
|
170
|
+
# the capture is a different thread (and often a different process),
|
|
171
|
+
# so a thread-local left here would never be seen again. Prefer it
|
|
172
|
+
# over the live buffer, which the job's ensure has already cleared.
|
|
173
|
+
job_trail = Thread.current[Subscribers::BreadcrumbSubscriber::JOB_TRAIL_KEY]
|
|
174
|
+
Thread.current[Subscribers::BreadcrumbSubscriber::JOB_TRAIL_KEY] = nil
|
|
175
|
+
harvested = Services::BreadcrumbCollector.harvest
|
|
176
|
+
trail = job_trail.is_a?(Array) && job_trail.any? ? job_trail : harvested
|
|
177
|
+
context = context.merge(_serialized_breadcrumbs: trail)
|
|
143
178
|
end
|
|
144
179
|
|
|
145
180
|
# Capture system health NOW (metrics are time-sensitive, different thread = different state)
|
|
@@ -270,6 +305,24 @@ module RailsErrorDashboard
|
|
|
270
305
|
|
|
271
306
|
context = context.merge(request_params: filtered[:request_params]) if context.key?(:request_params)
|
|
272
307
|
context = context.merge(request_url: filtered[:request_url]) if context.key?(:request_url)
|
|
308
|
+
|
|
309
|
+
# request_params is only one of the shapes that becomes the stored
|
|
310
|
+
# params: ErrorContext#extract_params also folds in :params,
|
|
311
|
+
# :additional_context, :metadata and the job/sidekiq keys. Those crossed
|
|
312
|
+
# the queue raw, so the row was redacted while the secret sat in the
|
|
313
|
+
# queue's backing store -- exactly the drift this method exists to
|
|
314
|
+
# prevent. ParameterFilter takes a Hash directly; filter_json_string is
|
|
315
|
+
# no use here because these are Hashes, not JSON strings.
|
|
316
|
+
param_filter = Services::SensitiveDataFilter.parameter_filter
|
|
317
|
+
if param_filter
|
|
318
|
+
CONTEXT_PARAM_KEYS.each do |key|
|
|
319
|
+
value = context[key]
|
|
320
|
+
next unless value.is_a?(Hash)
|
|
321
|
+
|
|
322
|
+
context = context.merge(key => param_filter.filter(value))
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
273
326
|
# The raw session ID must not sit in Redis / Solid Queue either.
|
|
274
327
|
if context[:session_id]
|
|
275
328
|
context = context.merge(session_id: Services::SensitiveDataFilter.digest_session_id(context[:session_id]))
|
|
@@ -373,7 +426,11 @@ module RailsErrorDashboard
|
|
|
373
426
|
truncated_backtrace = Services::BacktraceProcessor.truncate(@exception.backtrace)
|
|
374
427
|
attributes = {
|
|
375
428
|
application_id: application.id,
|
|
376
|
-
|
|
429
|
+
# The reported type wins over the reconstructed class: an async
|
|
430
|
+
# capture of a type with no Ruby class here (a frontend error) is
|
|
431
|
+
# rebuilt as StandardError, and that name must not become the group
|
|
432
|
+
# identity. Falls back to the real class for every ordinary capture.
|
|
433
|
+
error_type: reported_error_type || @exception.class.name,
|
|
377
434
|
message: @exception.message,
|
|
378
435
|
backtrace: truncated_backtrace,
|
|
379
436
|
user_id: error_context.user_id,
|
|
@@ -384,7 +441,12 @@ module RailsErrorDashboard
|
|
|
384
441
|
platform: error_context.platform,
|
|
385
442
|
controller_name: error_context.controller_name,
|
|
386
443
|
action_name: error_context.action_name,
|
|
387
|
-
|
|
444
|
+
# Three sources, most specific first. A caller-supplied event time
|
|
445
|
+
# (ManualErrorReporter documents it, already clamped to not-future by
|
|
446
|
+
# ErrorContext) beats the capture-time stamp carried across the queue
|
|
447
|
+
# (see call_async), which in turn beats this worker's clock. Ordinary
|
|
448
|
+
# synchronous captures supply neither and fall through to now.
|
|
449
|
+
occurred_at: error_context.occurred_at || captured_at_from_context || Time.current
|
|
388
450
|
}
|
|
389
451
|
|
|
390
452
|
# Enriched request context (if columns exist)
|
|
@@ -423,17 +485,21 @@ module RailsErrorDashboard
|
|
|
423
485
|
|
|
424
486
|
# Add git/release info if columns exist
|
|
425
487
|
if ErrorLog.column_names.include?("git_sha")
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
ENV["RENDER_GIT_COMMIT"] ||
|
|
430
|
-
RailsErrorDashboard.detected_git_sha
|
|
488
|
+
# The release the event was CAPTURED under, carried across the queue,
|
|
489
|
+
# falling back to this process's own for a synchronous capture.
|
|
490
|
+
attributes[:git_sha] = context_value(:_git_sha) || capture_git_sha
|
|
431
491
|
end
|
|
432
492
|
|
|
433
493
|
if ErrorLog.column_names.include?("app_version")
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
494
|
+
# Same precedence as occurred_at above. The reporter's own version
|
|
495
|
+
# wins over this server's -- for a mobile or frontend report they are
|
|
496
|
+
# different and the client's is the useful one (documented by
|
|
497
|
+
# ManualErrorReporter, previously discarded) -- then the release the
|
|
498
|
+
# event was CAPTURED under carried across the queue, then this
|
|
499
|
+
# process's own.
|
|
500
|
+
attributes[:app_version] = error_context.app_version ||
|
|
501
|
+
context_value(:_app_version) ||
|
|
502
|
+
capture_app_version
|
|
437
503
|
end
|
|
438
504
|
|
|
439
505
|
# Add environment snapshot (if column exists)
|
|
@@ -459,14 +525,38 @@ module RailsErrorDashboard
|
|
|
459
525
|
|
|
460
526
|
# Harvest breadcrumbs (if enabled and column exists)
|
|
461
527
|
if !storm_lite && ErrorLog.column_names.include?("breadcrumbs") && RailsErrorDashboard.configuration.enable_breadcrumbs
|
|
462
|
-
#
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
#
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
528
|
+
# The envelope wins when there is one.
|
|
529
|
+
#
|
|
530
|
+
# An async capture harvests the REQUEST's trail before enqueue and
|
|
531
|
+
# carries it here; the worker thread running this job now has a
|
|
532
|
+
# buffer of its own (jobs get one, so a failing job has a trail), and
|
|
533
|
+
# harvesting that first would show the worker's activity in place of
|
|
534
|
+
# the request's. Draining the current thread stays the sync path, and
|
|
535
|
+
# still runs below so a worker's own buffer is not left to leak.
|
|
536
|
+
serialized = @context[:_serialized_breadcrumbs] || @context["_serialized_breadcrumbs"]
|
|
537
|
+
|
|
538
|
+
# A failing job's trail, snapshotted by the around_perform on its way
|
|
539
|
+
# out. Active Job reports a job error two frames OUTSIDE the callback
|
|
540
|
+
# that owns the buffer, so by now the live buffer is already gone and
|
|
541
|
+
# a current-thread harvest returns nothing -- the snapshot is the only
|
|
542
|
+
# surviving copy. Consumed here, whoever wrote it.
|
|
543
|
+
job_trail = Thread.current[Subscribers::BreadcrumbSubscriber::JOB_TRAIL_KEY]
|
|
544
|
+
Thread.current[Subscribers::BreadcrumbSubscriber::JOB_TRAIL_KEY] = nil
|
|
545
|
+
|
|
546
|
+
# Still unconditional: drains a worker's own buffer so it cannot leak.
|
|
547
|
+
current = Services::BreadcrumbCollector.harvest
|
|
548
|
+
|
|
549
|
+
# The envelope stays FIRST. An async capture carries the request's
|
|
550
|
+
# trail across the queue, and neither the worker's own buffer nor a
|
|
551
|
+
# snapshot may displace it.
|
|
552
|
+
raw_breadcrumbs =
|
|
553
|
+
if serialized.is_a?(Array) && serialized.any?
|
|
554
|
+
serialized
|
|
555
|
+
elsif job_trail.is_a?(Array) && job_trail.any?
|
|
556
|
+
job_trail
|
|
557
|
+
else
|
|
558
|
+
current
|
|
559
|
+
end
|
|
470
560
|
|
|
471
561
|
if raw_breadcrumbs.is_a?(Array) && raw_breadcrumbs.any?
|
|
472
562
|
filtered = Services::BreadcrumbCollector.filter_sensitive(raw_breadcrumbs)
|
|
@@ -528,9 +618,15 @@ module RailsErrorDashboard
|
|
|
528
618
|
# context payloads by design, and must not be recorded as though it
|
|
529
619
|
# refreshed the snapshot -- nor allowed to overwrite a good backtrace.
|
|
530
620
|
# It is stripped before the INSERT (it is a signal, not a column).
|
|
621
|
+
#
|
|
622
|
+
# Only the SHED case is asserted here. "This capture was complete" and
|
|
623
|
+
# "the stored snapshot is complete" are different claims: when the
|
|
624
|
+
# grouping command keeps an earlier occurrence's user or locals beside
|
|
625
|
+
# this one's URL, the row displays a mixture, and only that command can
|
|
626
|
+
# see it. Passing nil lets it decide between "full" and "partial".
|
|
531
627
|
error_log = ErrorLog.find_or_increment_by_hash(
|
|
532
628
|
error_hash,
|
|
533
|
-
attributes.merge(error_hash: error_hash, _context_fidelity: storm_lite ? "lite" :
|
|
629
|
+
attributes.merge(error_hash: error_hash, _context_fidelity: storm_lite ? "lite" : nil)
|
|
534
630
|
)
|
|
535
631
|
|
|
536
632
|
# OTel: now that the error_log exists, attach its id + dedup flag + severity
|
|
@@ -828,8 +924,75 @@ module RailsErrorDashboard
|
|
|
828
924
|
nil
|
|
829
925
|
end
|
|
830
926
|
|
|
927
|
+
# The error type as REPORTED, carried across the queue by
|
|
928
|
+
# AsyncErrorLoggingJob. Symbol or string key: ActiveJob's serializer
|
|
929
|
+
# turns symbol keys into strings on the way through.
|
|
930
|
+
def reported_error_type
|
|
931
|
+
return nil unless @context.is_a?(Hash)
|
|
932
|
+
|
|
933
|
+
(@context[:_reported_error_type] || @context["_reported_error_type"]).presence
|
|
934
|
+
rescue StandardError
|
|
935
|
+
nil
|
|
936
|
+
end
|
|
937
|
+
|
|
831
938
|
# Detect app version from VERSION file (fallback)
|
|
832
939
|
def detect_version_from_file
|
|
940
|
+
self.class.detect_version_from_file
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# --- Capture-time envelope -------------------------------------------
|
|
944
|
+
#
|
|
945
|
+
# The release THIS process is running, resolved on the capture thread so
|
|
946
|
+
# it can be stamped onto the payload before it crosses the queue. The
|
|
947
|
+
# worker reads the stamp instead of asking its own configuration, which
|
|
948
|
+
# is what made a queued event inherit the release it was drained under.
|
|
949
|
+
|
|
950
|
+
# ONE normalization of a caller-supplied event time, shared by both
|
|
951
|
+
# transports.
|
|
952
|
+
#
|
|
953
|
+
# ErrorContext#extract_occurred_at already parses Strings, clamps a
|
|
954
|
+
# future time and rescues bad input -- but it runs in the WORKER on the
|
|
955
|
+
# async path, long after this method's caller has already had to
|
|
956
|
+
# serialize the value. Stamping the envelope by calling .iso8601 on the
|
|
957
|
+
# raw input therefore raised NoMethodError for a String (a documented
|
|
958
|
+
# ManualErrorReporter input form), the outer rescue in .call swallowed
|
|
959
|
+
# it, and the capture vanished: nothing enqueued, no row, nothing logged
|
|
960
|
+
# at error level. The sync path accepted the identical input.
|
|
961
|
+
#
|
|
962
|
+
# Normalizing here keeps ONE policy -- including the future clamp -- and
|
|
963
|
+
# returns nil rather than raising, so an unparseable value costs the
|
|
964
|
+
# timestamp and never the error itself (Safety Rule 1).
|
|
965
|
+
# @return [Time, nil]
|
|
966
|
+
def self.normalized_occurred_at(context)
|
|
967
|
+
raw = context[:occurred_at] || context["occurred_at"]
|
|
968
|
+
return nil if raw.nil? || (raw.respond_to?(:empty?) && raw.empty?)
|
|
969
|
+
|
|
970
|
+
time = raw.is_a?(String) ? Time.zone.parse(raw) : raw
|
|
971
|
+
return nil unless time.respond_to?(:to_time)
|
|
972
|
+
|
|
973
|
+
[ time.to_time, Time.current ].min
|
|
974
|
+
rescue StandardError => e
|
|
975
|
+
RailsErrorDashboard::Logger.debug(
|
|
976
|
+
"[RailsErrorDashboard] Unparseable occurred_at (#{e.class}); using capture time"
|
|
977
|
+
)
|
|
978
|
+
nil
|
|
979
|
+
end
|
|
980
|
+
|
|
981
|
+
def self.capture_app_version
|
|
982
|
+
RailsErrorDashboard.configuration.app_version ||
|
|
983
|
+
ENV["APP_VERSION"] ||
|
|
984
|
+
detect_version_from_file
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
def self.capture_git_sha
|
|
988
|
+
RailsErrorDashboard.configuration.git_sha ||
|
|
989
|
+
ENV["GIT_SHA"] ||
|
|
990
|
+
ENV["HEROKU_SLUG_COMMIT"] ||
|
|
991
|
+
ENV["RENDER_GIT_COMMIT"] ||
|
|
992
|
+
RailsErrorDashboard.detected_git_sha
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
def self.detect_version_from_file
|
|
833
996
|
version_file = Rails.root.join("VERSION")
|
|
834
997
|
return File.read(version_file).strip if File.exist?(version_file)
|
|
835
998
|
nil
|
|
@@ -837,6 +1000,35 @@ module RailsErrorDashboard
|
|
|
837
1000
|
RailsErrorDashboard::Logger.debug("Could not detect version: #{e.message}")
|
|
838
1001
|
nil
|
|
839
1002
|
end
|
|
1003
|
+
|
|
1004
|
+
# Symbol or string key: the async job round-trips the context through the
|
|
1005
|
+
# queue serializer, which turns symbol keys into strings.
|
|
1006
|
+
def context_value(key)
|
|
1007
|
+
return nil unless @context.is_a?(Hash)
|
|
1008
|
+
|
|
1009
|
+
(@context[key] || @context[key.to_s]).presence
|
|
1010
|
+
rescue StandardError
|
|
1011
|
+
nil
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
def capture_app_version
|
|
1015
|
+
self.class.capture_app_version
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
def capture_git_sha
|
|
1019
|
+
self.class.capture_git_sha
|
|
1020
|
+
end
|
|
1021
|
+
|
|
1022
|
+
# The capture-time stamp an async payload carries, or nil for a
|
|
1023
|
+
# synchronous capture (which has no queue hop and is already "now").
|
|
1024
|
+
def captured_at_from_context
|
|
1025
|
+
raw = context_value(:_captured_at)
|
|
1026
|
+
return nil if raw.blank?
|
|
1027
|
+
|
|
1028
|
+
Time.zone ? Time.zone.parse(raw.to_s) : Time.parse(raw.to_s)
|
|
1029
|
+
rescue StandardError
|
|
1030
|
+
nil
|
|
1031
|
+
end
|
|
840
1032
|
end
|
|
841
1033
|
end
|
|
842
1034
|
end
|
|
@@ -182,6 +182,12 @@ module RailsErrorDashboard
|
|
|
182
182
|
attr_accessor :local_variable_max_array_items # Max array items to serialize (default: 10)
|
|
183
183
|
attr_accessor :local_variable_max_hash_items # Max hash entries to serialize (default: 20)
|
|
184
184
|
attr_accessor :local_variable_filter_patterns # Additional sensitive name patterns (default: [])
|
|
185
|
+
# Calling #inspect on an unknown object runs arbitrary APPLICATION code on
|
|
186
|
+
# the failure path; truncating the result bounds storage, not cost. The
|
|
187
|
+
# default is a safe structural summary, with full inspect per type by
|
|
188
|
+
# opt-in and a wall-clock budget even then.
|
|
189
|
+
attr_accessor :local_variable_inspect_allowlist # Class names whose #inspect may run (default: safe built-ins)
|
|
190
|
+
attr_accessor :local_variable_inspect_budget_ms # Wall-clock budget for one #inspect (default: 5)
|
|
185
191
|
|
|
186
192
|
# Instance variable capture from tp.self (receiver object at raise time)
|
|
187
193
|
attr_accessor :enable_instance_variables # Master switch (default: false)
|
|
@@ -415,6 +421,20 @@ module RailsErrorDashboard
|
|
|
415
421
|
@local_variable_max_array_items = 10 # Max array items to serialize
|
|
416
422
|
@local_variable_max_hash_items = 20 # Max hash entries to serialize
|
|
417
423
|
@local_variable_filter_patterns = [] # Additional sensitive variable name patterns
|
|
424
|
+
# Struct is serialized MEMBER-WISE (never via its own #inspect), so it
|
|
425
|
+
# needs no allowlist entry -- see VariableSerializer.serialize_struct.
|
|
426
|
+
#
|
|
427
|
+
# ActiveModel is deliberately NOT allowlisted. Reading
|
|
428
|
+
# ActiveModel::Attributes#attributes runs each attribute's type cast,
|
|
429
|
+
# which is application code, so neither #inspect nor member-wise reading
|
|
430
|
+
# can be bounded for it; it gets a safe summary instead.
|
|
431
|
+
#
|
|
432
|
+
# Anything added here opts that type IN to unbounded execution: the only
|
|
433
|
+
# way to interrupt arbitrary Ruby mid-call is Timeout, which is not safe
|
|
434
|
+
# on the capture path. The budget below selects the stored OUTPUT after
|
|
435
|
+
# the fact; it does not bound the work.
|
|
436
|
+
@local_variable_inspect_allowlist = []
|
|
437
|
+
@local_variable_inspect_budget_ms = 5
|
|
418
438
|
|
|
419
439
|
# Instance variable capture defaults - OFF by default (opt-in)
|
|
420
440
|
@enable_instance_variables = false # Capture ivars from tp.self at raise time
|
|
@@ -85,6 +85,19 @@ module RailsErrorDashboard
|
|
|
85
85
|
RailsErrorDashboard::Subscribers::BreadcrumbSubscriber.subscribe!
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
# Give background jobs a breadcrumb buffer of their own. init_buffer had
|
|
89
|
+
# exactly one caller -- the Rack middleware -- so a job never entered the
|
|
90
|
+
# HTTP stack and had no buffer at all; every subscriber early-returns on
|
|
91
|
+
# `unless current_buffer`, so a failing job's SQL and custom crumbs were
|
|
92
|
+
# dropped in the one place an error is hardest to reproduce.
|
|
93
|
+
#
|
|
94
|
+
# Registered unconditionally and gated at PERFORM time, not here:
|
|
95
|
+
# enable_breadcrumbs defaults to false, and the ActiveJob callback list
|
|
96
|
+
# is fixed once the class loads, so a boot-time gate would leave this
|
|
97
|
+
# permanently unregistered for any host that turns the feature on in an
|
|
98
|
+
# initializer that runs later. Off, it costs one config read per job.
|
|
99
|
+
RailsErrorDashboard::Subscribers::BreadcrumbSubscriber.install_job_buffer!
|
|
100
|
+
|
|
88
101
|
# Subscribe to Rack Attack AS::Notifications events (requires Rack::Attack).
|
|
89
102
|
# Breadcrumbs are NOT required — events persist to their own table (issue #143).
|
|
90
103
|
if RailsErrorDashboard.configuration.enable_rack_attack_tracking &&
|
|
@@ -42,7 +42,11 @@ module RailsErrorDashboard
|
|
|
42
42
|
# @param app_version [String, nil] Version of the app where error occurred
|
|
43
43
|
# @param metadata [Hash, nil] Additional custom metadata about the error
|
|
44
44
|
# @param occurred_at [Time, nil] When the error occurred (defaults to Time.current)
|
|
45
|
-
# @param severity [Symbol, nil]
|
|
45
|
+
# @param severity [Symbol, nil] IGNORED, and kept only so existing callers
|
|
46
|
+
# do not break. Severity is not stored: ErrorLog#severity is CLASSIFIED
|
|
47
|
+
# from error_type by Services::SeverityClassifier, so the way to control
|
|
48
|
+
# it is the error_type you report. Accepting this silently made callers
|
|
49
|
+
# believe they had set a value that was never read.
|
|
46
50
|
# @param source [String, nil] Source identifier (e.g., "frontend", "mobile_app")
|
|
47
51
|
#
|
|
48
52
|
# @return [ErrorLog, nil] The created error log record, or nil if filtered/ignored
|
|
@@ -65,8 +69,7 @@ module RailsErrorDashboard
|
|
|
65
69
|
# user_agent: request.user_agent,
|
|
66
70
|
# ip_address: request.remote_ip,
|
|
67
71
|
# app_version: "1.2.3",
|
|
68
|
-
# metadata: { card_type: "visa", amount: 99.99 }
|
|
69
|
-
# severity: :high
|
|
72
|
+
# metadata: { card_type: "visa", amount: 99.99 }
|
|
70
73
|
# )
|
|
71
74
|
def self.report(
|
|
72
75
|
error_type:,
|
|
@@ -100,10 +103,18 @@ module RailsErrorDashboard
|
|
|
100
103
|
platform: platform,
|
|
101
104
|
app_version: app_version,
|
|
102
105
|
metadata: metadata,
|
|
103
|
-
occurred_at: occurred_at || Time.current
|
|
104
|
-
severity: severity
|
|
106
|
+
occurred_at: occurred_at || Time.current
|
|
105
107
|
}.compact # Remove nil values
|
|
106
108
|
|
|
109
|
+
# severity is deliberately NOT forwarded: nothing downstream reads it,
|
|
110
|
+
# and passing it on would keep the illusion that it does.
|
|
111
|
+
if severity.present?
|
|
112
|
+
RailsErrorDashboard::Logger.debug(
|
|
113
|
+
"[RailsErrorDashboard] ManualErrorReporter: severity: is ignored — " \
|
|
114
|
+
"severity is classified from error_type (#{error_type})."
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
107
118
|
# Use the existing LogError command
|
|
108
119
|
Commands::LogError.call(synthetic_exception, context)
|
|
109
120
|
end
|
|
@@ -67,43 +67,62 @@ module RailsErrorDashboard
|
|
|
67
67
|
|
|
68
68
|
# Two counting units, kept distinct on purpose.
|
|
69
69
|
#
|
|
70
|
-
# An ErrorLog row is a GROUP; its
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
70
|
+
# An ErrorLog row is a GROUP; its occurred_at is FIRST-SEEN and is never
|
|
71
|
+
# rewritten when the error recurs.
|
|
72
|
+
#
|
|
73
|
+
# EVENT figures -> Queries::EventVolume, which counts occurrence rows
|
|
74
|
+
# + storm buckets + the untracked remainder, each
|
|
75
|
+
# against its OWN timestamp.
|
|
76
|
+
# GROUP figures -> base_query, which selects groups by first-seen.
|
|
77
|
+
# Correct here: a group is the thing that gets
|
|
78
|
+
# resolved, and an event cannot be.
|
|
79
|
+
#
|
|
80
|
+
# Mixing them is what made this page disagree with the Overview: filtering
|
|
81
|
+
# GROUPS by first-seen and then summing their LIFETIME occurrence_count
|
|
82
|
+
# answers "how much total volume do the groups born in this window carry",
|
|
83
|
+
# not "how many events happened in this window". A group first seen in
|
|
84
|
+
# August that recurred today was excluded entirely, so the page reported
|
|
85
|
+
# zero events while its own affected-users table listed the very event.
|
|
75
86
|
def error_statistics
|
|
76
87
|
{
|
|
77
88
|
total: event_count,
|
|
78
89
|
total_groups: base_query.count,
|
|
79
90
|
unresolved: base_query.unresolved.count,
|
|
80
91
|
resolved: base_query.resolved.count,
|
|
81
|
-
by_type:
|
|
82
|
-
by_day:
|
|
92
|
+
by_type: volume.by_group_attribute(:error_type).sort_by { |_, count| -count }.to_h,
|
|
93
|
+
by_day: volume.by_day.transform_keys(&:to_s),
|
|
83
94
|
affected_users_incomplete: affected_users_incomplete?
|
|
84
95
|
}
|
|
85
96
|
end
|
|
86
97
|
|
|
87
|
-
#
|
|
88
|
-
#
|
|
98
|
+
# One EventVolume for the window, reused by every EVENT figure below so
|
|
99
|
+
# they cannot drift apart from each other or from the headline total.
|
|
100
|
+
def volume
|
|
101
|
+
@volume ||= Queries::EventVolume.new(base_scope, @start_date)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Total EVENTS in the window. dashboard_stats.rb counts the same way,
|
|
105
|
+
# through the same primitive -- that agreement is asserted by
|
|
106
|
+
# spec/queries/overview_analytics_agreement_spec.rb.
|
|
89
107
|
def event_count
|
|
90
|
-
|
|
108
|
+
volume.count
|
|
91
109
|
end
|
|
92
110
|
|
|
93
111
|
def errors_over_time
|
|
94
|
-
|
|
112
|
+
volume.by_day
|
|
95
113
|
end
|
|
96
114
|
|
|
115
|
+
# Top 10 by EVENTS. Deliberately a partial breakdown -- it does not sum
|
|
116
|
+
# to the headline total, and the agreement spec treats it as such.
|
|
97
117
|
def errors_by_type
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
.to_h
|
|
118
|
+
volume.by_group_attribute(:error_type)
|
|
119
|
+
.sort_by { |_, count| -count }
|
|
120
|
+
.first(10)
|
|
121
|
+
.to_h
|
|
103
122
|
end
|
|
104
123
|
|
|
105
124
|
def errors_by_platform
|
|
106
|
-
|
|
125
|
+
volume.by_group_attribute(:platform)
|
|
107
126
|
end
|
|
108
127
|
|
|
109
128
|
# NULL (captured before the column existed) is reported under :unknown
|
|
@@ -111,14 +130,16 @@ module RailsErrorDashboard
|
|
|
111
130
|
def errors_by_environment
|
|
112
131
|
return {} unless ErrorLog.column_names.include?("environment")
|
|
113
132
|
|
|
114
|
-
|
|
133
|
+
volume.by_group_attribute(:environment).transform_keys { |env| env.nil? ? :unknown : env }
|
|
115
134
|
end
|
|
116
135
|
|
|
136
|
+
# Diurnal pattern: which hour of the day errors peak in, 0..23.
|
|
137
|
+
#
|
|
138
|
+
# Counted per EVENT against the event's own timestamp. Grouping ErrorLog
|
|
139
|
+
# by its first-seen hour put a group's whole lifetime volume on the hour
|
|
140
|
+
# it was first seen, so a recurrence never moved the curve.
|
|
117
141
|
def errors_by_hour
|
|
118
|
-
|
|
119
|
-
# (when in the day errors peak). The chart title says "Errors by Hour
|
|
120
|
-
# of Day" — group_by_hour produced a chronological time series instead.
|
|
121
|
-
base_query.group_by_hour_of_day(:occurred_at).sum(:occurrence_count)
|
|
142
|
+
volume.by_hour_of_day
|
|
122
143
|
end
|
|
123
144
|
|
|
124
145
|
# Events per user, counted from OCCURRENCE rows.
|
|
@@ -150,17 +171,52 @@ module RailsErrorDashboard
|
|
|
150
171
|
|
|
151
172
|
# A user whose events predate occurrence tracking -- or whose events
|
|
152
173
|
# were shed by storm protection, which writes no occurrence row -- still
|
|
153
|
-
# belongs in the table.
|
|
154
|
-
#
|
|
155
|
-
|
|
174
|
+
# belongs in the table. The fallback covers ONLY groups with no
|
|
175
|
+
# occurrence coverage at all in this window.
|
|
176
|
+
#
|
|
177
|
+
# It used to merge by taking the larger of the two counts per user, and
|
|
178
|
+
# that overstated real users: the group's user_id is overwritten by
|
|
179
|
+
# every new occurrence, so group_user_counts attributes a group's whole
|
|
180
|
+
# lifetime count to whoever hit it LAST. For occurrences {A: 2, B: 1}
|
|
181
|
+
# the group figure for B was 3, max kept 3, and the page reported five
|
|
182
|
+
# events for a three-event group. A number that overstates a user's
|
|
183
|
+
# events cannot be presented as an "at least" bound, so the uncovered
|
|
184
|
+
# case is counted and the covered case is left to the occurrence rows.
|
|
185
|
+
uncovered = uncovered_group_user_counts
|
|
186
|
+
counts.merge(uncovered) { |_user, from_occurrences, from_groups| from_occurrences + from_groups }
|
|
156
187
|
rescue StandardError
|
|
157
188
|
group_user_counts
|
|
158
189
|
end
|
|
159
190
|
|
|
191
|
+
# GROUP-based on purpose: this is the fallback for groups with no
|
|
192
|
+
# per-event record, where the group's own user_id and lifetime count are
|
|
193
|
+
# the only evidence that exists. Routing it through EventVolume would be
|
|
194
|
+
# wrong -- EventVolume counts events, and this needs the per-user split
|
|
195
|
+
# that only the group row carries. See design.md D5.
|
|
160
196
|
def group_user_counts
|
|
161
197
|
base_query.where.not(user_id: nil).group(:user_id).sum(:occurrence_count)
|
|
162
198
|
end
|
|
163
199
|
|
|
200
|
+
# Groups in this window that have NO occurrence row at all: rows from
|
|
201
|
+
# before occurrence tracking, and groups whose every event was shed by
|
|
202
|
+
# storm protection. Their occurrence_count is the only evidence those
|
|
203
|
+
# events happened, and the group's own user_id the only attribution
|
|
204
|
+
# available -- a floor, which affected_users_incomplete? reports.
|
|
205
|
+
#
|
|
206
|
+
# A group with even one occurrence row is excluded: its per-event rows
|
|
207
|
+
# are authoritative, and adding the group total on top is what produced
|
|
208
|
+
# the overcount.
|
|
209
|
+
def uncovered_group_user_counts
|
|
210
|
+
covered = ErrorOccurrence.where(error_log_id: base_query.select(:id)).select(:error_log_id)
|
|
211
|
+
|
|
212
|
+
base_query.where.not(user_id: nil)
|
|
213
|
+
.where.not(id: covered)
|
|
214
|
+
.group(:user_id)
|
|
215
|
+
.sum(:occurrence_count)
|
|
216
|
+
rescue StandardError
|
|
217
|
+
{}
|
|
218
|
+
end
|
|
219
|
+
|
|
164
220
|
# Occurrence rows joined back to error_logs, so the application filter
|
|
165
221
|
# (which the occurrence table has no column for) still applies.
|
|
166
222
|
def occurrence_scope
|
|
@@ -215,11 +271,13 @@ module RailsErrorDashboard
|
|
|
215
271
|
end
|
|
216
272
|
|
|
217
273
|
def mobile_errors_count
|
|
218
|
-
|
|
274
|
+
Queries::EventVolume.in_window(base_scope.where(platform: [ "iOS", "Android" ]), @start_date)
|
|
219
275
|
end
|
|
220
276
|
|
|
221
277
|
def api_errors_count
|
|
222
|
-
|
|
278
|
+
Queries::EventVolume.in_window(
|
|
279
|
+
base_scope.where("platform IS NULL OR platform = ?", "API"), @start_date
|
|
280
|
+
)
|
|
223
281
|
end
|
|
224
282
|
|
|
225
283
|
# Pattern insights for top error types
|