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
@@ -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
- msg = exception.message.to_s
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
- context = context.merge(_serialized_breadcrumbs: Services::BreadcrumbCollector.harvest)
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
- error_type: @exception.class.name,
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
- occurred_at: Time.current
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
- attributes[:git_sha] = RailsErrorDashboard.configuration.git_sha ||
427
- ENV["GIT_SHA"] ||
428
- ENV["HEROKU_SLUG_COMMIT"] ||
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
- attributes[:app_version] = RailsErrorDashboard.configuration.app_version ||
435
- ENV["APP_VERSION"] ||
436
- detect_version_from_file
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
- # Sync path: harvest from current thread
463
- raw_breadcrumbs = Services::BreadcrumbCollector.harvest
464
-
465
- # Async path fallback: use pre-serialized breadcrumbs from call_async context
466
- if raw_breadcrumbs.empty?
467
- serialized = @context[:_serialized_breadcrumbs]
468
- raw_breadcrumbs = serialized if serialized.is_a?(Array)
469
- end
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" : "full")
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] Severity level (:critical, :high, :medium, :low)
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 occurrence_count says how many times
71
- # that error actually happened. Volume figures are EVENTS (the sum), so
72
- # this page agrees with the Overview, which counts the same way. Resolved
73
- # and unresolved stay GROUP counts -- a group is the thing that gets
74
- # resolved, and an event cannot be.
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: base_query.group(:error_type).sum(:occurrence_count).sort_by { |_, count| -count }.to_h,
82
- by_day: base_query.group("DATE(occurred_at)").sum(:occurrence_count),
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
- # Total EVENTS in the window. dashboard_stats.rb and
88
- # platform_comparison.rb:165 count the same way.
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
- base_query.sum(:occurrence_count)
108
+ volume.count
91
109
  end
92
110
 
93
111
  def errors_over_time
94
- base_query.group_by_day(:occurred_at).sum(:occurrence_count)
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
- base_query.group(:error_type)
99
- .sum(:occurrence_count)
100
- .sort_by { |_, count| -count }
101
- .first(10)
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
- base_query.group(:platform).sum(:occurrence_count)
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
- base_query.group(:environment).sum(:occurrence_count).transform_keys { |env| env.nil? ? :unknown : env }
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
- # group_by_hour_of_day buckets into 0..23 to show diurnal patterns
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. Fall back to the group's own user for those,
154
- # taking whichever count is larger. UserImpactSummary merges the same way.
155
- group_user_counts.merge(counts) { |_user, group_count, occurrence_count| [ group_count, occurrence_count ].max }
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
- base_query.where(platform: [ "iOS", "Android" ]).sum(:occurrence_count)
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
- base_query.where("platform IS NULL OR platform = ?", "API").sum(:occurrence_count)
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