Class: RailsErrorDashboard::Services::StormProtection::FingerprintBuckets

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

Overview

Layer 1: per-fingerprint rate limiting with graceful degradation.

Each fingerprint gets a 60-second window. Within the window:

- first N events            → :full  (everything captured)
- past N, every Mth event   → :lite  (row captured, context shed)
- everything else           → :count_only (in-memory count, flushed later)

The first event of every window is ALWAYS at least :lite, so a melting-down fingerprint still has a fresh exemplar each minute —deterministic, unlike rand-based sampling.

Calm-mode adaptive context sampling rides the same entries: after K full-context captures per fingerprint per day, context is captured only every Mth time (an error firing 1000×/day doesn’t need 1000 breadcrumb trails). Occurrence rows are unaffected in calm mode.

Concurrency: entries are mutable structs in a Concurrent::Map with plain (unlocked) field increments — races can miscount by a handful of events, which is acceptable for rate limiting. No mutex anywhere.

Memory: the map is bounded. Once full, unseen fingerprints are NOT tracked and decide as :full — in calm weather that’s harmless; in a storm of unique fingerprints the global breaker (Layer 2) takes over.

Defined Under Namespace

Classes: Entry

Constant Summary collapse

WINDOW_SECONDS =
60
DAY_SECONDS =
86_400

Instance Method Summary collapse

Constructor Details

#initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }) ⇒ FingerprintBuckets

Returns a new instance of FingerprintBuckets.



35
36
37
38
# File 'lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb', line 35

def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
  @clock = clock
  reset!
end

Instance Method Details

#decide(gate_key) ⇒ Symbol

Decide capture fidelity for one event of this fingerprint.

Returns:

  • (Symbol)

    :full, :lite, or :count_only



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
# File 'lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb', line 46

def decide(gate_key)
  now = @clock.call
  entry = fetch_entry(gate_key, now)
  return :full unless entry # map full — Layer 2 owns the storm case

  roll_windows(entry, now)
  entry.window_count += 1
  n = entry.window_count

  if n <= full_per_minute
    decide_calm_context(entry)
  elsif n == full_per_minute + 1 || ((n - full_per_minute) % keep_every).zero?
    :lite
  else
    :count_only
  end
end

#reset!Object



40
41
42
# File 'lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb', line 40

def reset!
  @entries = Concurrent::Map.new
end