Class: RailsErrorDashboard::Services::StormProtection::Gate

Inherits:
Object
  • Object
show all
Defined in:
lib/rails_error_dashboard/services/storm_protection/gate.rb

Overview

Facade for the storm-protection hot path. One call per capture attempt:

Gate.admit!(exception, context) # => :full | :lite | :count_only

:full       — capture everything (the normal path)
:lite       — capture the error + occurrence row, shed context
              (breadcrumbs / system health / locals / ivars)
:count_only — nothing stored now; counted in memory, reconciled
              onto ErrorLog.occurrence_count by the flush job

Safety contract (mirrors SwallowedExceptionTracker):

  • FAILS OPEN: any internal error → :full. Protection must never be the thing that loses an error.

  • Zero I/O on the hot path. The only DB-adjacent work is enqueueing the flush job at most once per flush interval.

  • Budget: digest + atomic increment + comparisons (~µs). Benchmarked.

  • Per-process state; Puma workers each run their own breaker. No thread-locals — shared atomics, so no Thread.current cleanup needed.

IMPORTANT ordering: callers must run ExceptionFilter (ignore list + static sampling) BEFORE this gate — ignored exceptions must never count toward storm state or be reconciled into ErrorLogs.

Class Method Summary collapse

Class Method Details

.admit!(exception, context = {}) ⇒ Object



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# File 'lib/rails_error_dashboard/services/storm_protection/gate.rb', line 30

def admit!(exception, context = {})
  return :full unless enabled?

  state = breaker.record!
  maybe_storm_notification(state)

  decision = decide(state, exception, context)
  maybe_flush!
  decision
rescue => e
  RailsErrorDashboard::Logger.error(
    "[RailsErrorDashboard] StormProtection failed open: #{e.class} - #{e.message}"
  )
  :full
end

.breakerObject

Exposed for the flush job (episode metadata for storm_events).



85
86
87
# File 'lib/rails_error_dashboard/services/storm_protection/gate.rb', line 85

def breaker
  @breaker ||= CircuitBreaker.new
end

.count_bufferObject



89
90
91
# File 'lib/rails_error_dashboard/services/storm_protection/gate.rb', line 89

def count_buffer
  @count_buffer ||= CountBuffer.new
end

.fingerprint_bucketsObject



93
94
95
# File 'lib/rails_error_dashboard/services/storm_protection/gate.rb', line 93

def fingerprint_buckets
  @fingerprint_buckets ||= FingerprintBuckets.new
end

.issue_creation_allowed?Boolean

Always-on cap for auto-created issues (a storm of NEW critical fingerprints must not open 500 GitHub/Linear issues). Token bucket: N per rolling window, per process. Each call consumes a token — call only when actually about to create an issue.

Returns:

  • (Boolean)


58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/rails_error_dashboard/services/storm_protection/gate.rb', line 58

def issue_creation_allowed?
  return true unless enabled?

  now = monotonic_now
  window = RailsErrorDashboard.configuration.auto_issue_rate_limit_window_minutes.to_i * 60
  limit = RailsErrorDashboard.configuration.auto_issue_rate_limit_count.to_i

  @issue_window_start ||= now
  @issue_window_count ||= Concurrent::AtomicFixnum.new(0)

  if now - @issue_window_start >= window
    @issue_window_start = now
    @issue_window_count = Concurrent::AtomicFixnum.new(0)
  end

  @issue_window_count.increment <= limit
rescue
  true
end

.notifications_suppressed?Boolean

While the breaker is not closed, per-error notifications are suppressed (a single storm notification replaces them).

Returns:

  • (Boolean)


48
49
50
51
52
# File 'lib/rails_error_dashboard/services/storm_protection/gate.rb', line 48

def notifications_suppressed?
  enabled? && breaker.state != :closed
rescue
  false
end

.reset!Object

Test hook + fork hygiene: fresh state, no leftover episodes.



98
99
100
101
102
103
104
105
106
107
# File 'lib/rails_error_dashboard/services/storm_protection/gate.rb', line 98

def reset!
  @breaker = nil
  @count_buffer = nil
  @fingerprint_buckets = nil
  @probe_counter = nil
  @issue_window_start = nil
  @issue_window_count = nil
  @last_flush = nil
  @storm_notified_episode = nil
end

.stateObject



78
79
80
81
82
# File 'lib/rails_error_dashboard/services/storm_protection/gate.rb', line 78

def state
  enabled? ? breaker.state : :closed
rescue
  :closed
end