Class: RailsErrorDashboard::Services::SwallowedExceptionTracker

Inherits:
Object
  • Object
show all
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

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_raisesObject

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_rescuesObject



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.message}"
    )
  end

  @rescue_tracepoint = TracePoint.new(:rescue) do |tp|
    on_rescue(tp)
  rescue => e
    RailsErrorDashboard::Logger.debug(
      "[RailsErrorDashboard] SwallowedExceptionTracker :rescue callback error: #{e.class} - #{e.message}"
    )
  end

  @raise_tracepoint.enable
  @rescue_tracepoint.enable

  at_exit { flush_all_threads! }

  true
end

.enabled?Boolean

Check if currently enabled

Returns:

  • (Boolean)


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.message}"
  )
end