Class: RailsErrorDashboard::Services::SwallowedExceptionTracker
- Inherits:
-
Object
- Object
- RailsErrorDashboard::Services::SwallowedExceptionTracker
- Defined in:
- lib/rails_error_dashboard/services/swallowed_exception_tracker.rb
Overview
TracePoint lifecycle manager for detecting swallowed (raised-then-rescued) exceptions.
Uses separate TracePoint(:raise) and TracePoint(:rescue) hooks (Ruby 3.3+). Counts raises vs rescues per exception class + location pair. A high rescue ratio indicates exceptions being silently swallowed (e.g., ‘rescue => e; nil`).
This is intentionally SEPARATE from LocalVariableCapturer — that TracePoint aggressively filters to only app-code paths, while this one needs broader visibility to detect swallowed exceptions in gem code too (e.g., Stripe::CardError rescued in a service).
Safety contract:
-
Default OFF (opt-in via config.detect_swallowed_exceptions)
-
Ruby 3.3+ version gate (TracePoint(:rescue) not available before 3.3)
-
Thread-local counters (no shared state, no mutex in hot path)
-
~500ns per raise/rescue (hash lookup + integer increment)
-
Zero I/O in callbacks — async flush via Command
-
Every callback wrapped in rescue => e (never raises)
-
LRU eviction when thread-local cache exceeds max size
-
Periodic flush via cheap timestamp check
Constant Summary collapse
- RAISE_THREAD_KEY =
:red_swallowed_raises- RESCUE_THREAD_KEY =
:red_swallowed_rescues- FLUSH_THREAD_KEY =
:red_swallowed_last_flush- RAISE_LOC_IVAR =
:@_red_raise_loc- FLOW_CONTROL_EXCEPTIONS =
Flow-control exceptions that are commonly raised/rescued in normal Rails operation. These are NOT bugs — they’re control flow. Skipping them reduces noise.
%w[ SystemExit SignalException Interrupt Errno::EPIPE Errno::ECONNRESET Errno::ETIMEDOUT IOError ActionController::RoutingError ActionController::UnknownFormat ActionController::InvalidAuthenticityToken ActiveRecord::RecordNotFound ActionView::MissingTemplate AbstractController::ActionNotFound ].freeze
Class Method Summary collapse
-
.clear! ⇒ Object
Clear current thread’s counters without flushing (for testing).
-
.current_raises ⇒ Object
Read current thread’s counters (for testing/inspection).
- .current_rescues ⇒ Object
-
.disable! ⇒ Object
Disable both TracePoints and flush remaining data.
-
.enable! ⇒ Object
Enable both TracePoints.
-
.enabled? ⇒ Boolean
Check if currently enabled.
-
.flush! ⇒ Object
Force flush the current thread’s counters (used by job and tests).
Class Method Details
.clear! ⇒ Object
Clear current thread’s counters without flushing (for testing)
128 129 130 131 132 |
# File 'lib/rails_error_dashboard/services/swallowed_exception_tracker.rb', line 128 def clear! Thread.current[RAISE_THREAD_KEY] = nil Thread.current[RESCUE_THREAD_KEY] = nil Thread.current[FLUSH_THREAD_KEY] = nil end |
.current_raises ⇒ Object
Read current thread’s counters (for testing/inspection)
119 120 121 |
# File 'lib/rails_error_dashboard/services/swallowed_exception_tracker.rb', line 119 def current_raises Thread.current[RAISE_THREAD_KEY] || {} end |
.current_rescues ⇒ Object
123 124 125 |
# File 'lib/rails_error_dashboard/services/swallowed_exception_tracker.rb', line 123 def current_rescues Thread.current[RESCUE_THREAD_KEY] || {} end |
.disable! ⇒ Object
Disable both TracePoints and flush remaining data
85 86 87 88 89 90 |
# File 'lib/rails_error_dashboard/services/swallowed_exception_tracker.rb', line 85 def disable! @raise_tracepoint&.disable @rescue_tracepoint&.disable @raise_tracepoint = nil @rescue_tracepoint = nil end |
.enable! ⇒ Object
Enable both TracePoints. No-op on Ruby < 3.3 or if already enabled.
50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 |
# File 'lib/rails_error_dashboard/services/swallowed_exception_tracker.rb', line 50 def enable! unless RUBY_VERSION >= "3.3" RailsErrorDashboard::Logger.debug( "[RailsErrorDashboard] SwallowedExceptionTracker requires Ruby 3.3+ (current: #{RUBY_VERSION}). Skipping." ) return false end return true if enabled? @raise_tracepoint = TracePoint.new(:raise) do |tp| on_raise(tp) rescue => e RailsErrorDashboard::Logger.debug( "[RailsErrorDashboard] SwallowedExceptionTracker :raise callback error: #{e.class} - #{e.}" ) end @rescue_tracepoint = TracePoint.new(:rescue) do |tp| on_rescue(tp) rescue => e RailsErrorDashboard::Logger.debug( "[RailsErrorDashboard] SwallowedExceptionTracker :rescue callback error: #{e.class} - #{e.}" ) end @raise_tracepoint.enable @rescue_tracepoint.enable at_exit { flush_all_threads! } true end |
.enabled? ⇒ Boolean
Check if currently enabled
93 94 95 |
# File 'lib/rails_error_dashboard/services/swallowed_exception_tracker.rb', line 93 def enabled? @raise_tracepoint&.enabled? == true && @rescue_tracepoint&.enabled? == true end |
.flush! ⇒ Object
Force flush the current thread’s counters (used by job and tests)
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/rails_error_dashboard/services/swallowed_exception_tracker.rb', line 98 def flush! raises = Thread.current[RAISE_THREAD_KEY] rescues = Thread.current[RESCUE_THREAD_KEY] return if raises.nil? && rescues.nil? return if raises&.empty? && rescues&.empty? # Copy and clear atomically (per-thread, no lock needed) raise_snapshot = raises&.dup || {} rescue_snapshot = rescues&.dup || {} raises&.clear rescues&.clear Thread.current[FLUSH_THREAD_KEY] = Time.now.to_f dispatch_flush(raise_snapshot, rescue_snapshot) rescue => e RailsErrorDashboard::Logger.debug( "[RailsErrorDashboard] SwallowedExceptionTracker.flush! failed: #{e.class} - #{e.}" ) end |