rails_error_dashboard 0.8.0 → 0.8.2

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +34 -1
  3. data/app/controllers/rails_error_dashboard/application_controller.rb +5 -0
  4. data/app/controllers/rails_error_dashboard/errors_controller.rb +12 -0
  5. data/app/controllers/rails_error_dashboard/webhooks_controller.rb +35 -1
  6. data/app/jobs/rails_error_dashboard/storm_flush_job.rb +19 -0
  7. data/app/jobs/rails_error_dashboard/storm_notification_job.rb +74 -0
  8. data/app/models/rails_error_dashboard/storm_event.rb +34 -0
  9. data/app/views/layouts/rails_error_dashboard.html.erb +21 -0
  10. data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +1 -0
  11. data/app/views/rails_error_dashboard/errors/storms.html.erb +91 -0
  12. data/config/routes.rb +1 -0
  13. data/db/migrate/20260306000002_add_instance_variables_to_error_logs.rb +7 -1
  14. data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +4 -0
  15. data/db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb +4 -0
  16. data/db/migrate/20260613000001_create_storm_events.rb +28 -0
  17. data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +49 -2
  18. data/lib/rails_error_dashboard/commands/create_issue.rb +1 -1
  19. data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +188 -0
  20. data/lib/rails_error_dashboard/commands/link_existing_issue.rb +3 -1
  21. data/lib/rails_error_dashboard/commands/log_error.rb +70 -12
  22. data/lib/rails_error_dashboard/configuration.rb +73 -7
  23. data/lib/rails_error_dashboard/queries/storm_history.rb +39 -0
  24. data/lib/rails_error_dashboard/services/issue_tracker_client.rb +8 -5
  25. data/lib/rails_error_dashboard/services/linear_issue_client.rb +248 -0
  26. data/lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb +195 -0
  27. data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +100 -0
  28. data/lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb +123 -0
  29. data/lib/rails_error_dashboard/services/storm_protection/gate.rb +258 -0
  30. data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +12 -0
  31. data/lib/rails_error_dashboard/version.rb +1 -1
  32. data/lib/rails_error_dashboard.rb +7 -0
  33. metadata +15 -3
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ # Linear GraphQL API client for issue management.
6
+ #
7
+ # API Docs: https://developers.linear.app/docs/graphql/working-with-the-graphql-api
8
+ # Auth: Personal API key (Settings > Security & access > Personal API keys)
9
+ # Rate limit: 1,500 requests/hour per API key
10
+ #
11
+ # Unlike the git forges, Linear has no "owner/repo" — issues belong to a
12
+ # team. The `repo` argument holds the team key (e.g. "ENG"), and issues
13
+ # are addressed by their human identifier ("ENG-123"), reconstructed from
14
+ # the team key plus the team-scoped issue number we store.
15
+ #
16
+ # Linear also has no open/closed binary — issues move between typed
17
+ # workflow states. Closing maps to the team's first `completed`-type
18
+ # state, reopening to the first `unstarted` (or `backlog`) state.
19
+ class LinearIssueClient < IssueTrackerClient
20
+ REOPEN_STATE_TYPES = [ "unstarted", "backlog", "triage" ].freeze
21
+
22
+ def initialize(token:, repo:, api_url: nil)
23
+ super
24
+ @api_url = api_url || "https://api.linear.app/graphql"
25
+ end
26
+
27
+ def create_issue(title:, body:, labels: [])
28
+ return error_response(@last_error || "Linear team '#{@repo}' not found") unless team_id
29
+
30
+ input = { teamId: team_id, title: title, description: truncate_body(body) }
31
+ label_ids = resolve_label_ids(labels)
32
+ input[:labelIds] = label_ids if label_ids.any?
33
+
34
+ data = graphql(<<~GRAPHQL, input: input)
35
+ mutation($input: IssueCreateInput!) {
36
+ issueCreate(input: $input) { success issue { identifier number url } }
37
+ }
38
+ GRAPHQL
39
+
40
+ issue = data&.dig("issueCreate", "issue")
41
+ if issue
42
+ success_response(url: issue["url"], number: issue["number"])
43
+ else
44
+ error_response(@last_error || "Linear API error: issue creation failed")
45
+ end
46
+ end
47
+
48
+ def close_issue(number:)
49
+ update_issue_state(number, "completed")
50
+ end
51
+
52
+ def reopen_issue(number:)
53
+ update_issue_state(number, REOPEN_STATE_TYPES)
54
+ end
55
+
56
+ def add_comment(number:, body:)
57
+ issue_id = find_issue_id(number)
58
+ return error_response(@last_error || "Linear issue #{identifier_for(number)} not found") unless issue_id
59
+
60
+ data = graphql(<<~GRAPHQL, input: { issueId: issue_id, body: truncate_body(body) })
61
+ mutation($input: CommentCreateInput!) {
62
+ commentCreate(input: $input) { success comment { url } }
63
+ }
64
+ GRAPHQL
65
+
66
+ comment = data&.dig("commentCreate", "comment")
67
+ comment ? success_response(url: comment["url"]) : error_response(@last_error || "Linear API error: comment failed")
68
+ end
69
+
70
+ def fetch_comments(number:, per_page: 10)
71
+ data = graphql(<<~GRAPHQL, id: identifier_for(number), first: per_page)
72
+ query($id: String!, $first: Int!) {
73
+ issue(id: $id) {
74
+ comments(first: $first) {
75
+ nodes { body createdAt url user { name avatarUrl } }
76
+ }
77
+ }
78
+ }
79
+ GRAPHQL
80
+
81
+ nodes = data&.dig("issue", "comments", "nodes")
82
+ return error_response(@last_error || "Linear API error: could not fetch comments") unless nodes
83
+
84
+ comments = nodes.map { |c|
85
+ {
86
+ author: c.dig("user", "name"),
87
+ avatar_url: c.dig("user", "avatarUrl"),
88
+ body: c["body"],
89
+ created_at: c["createdAt"],
90
+ url: c["url"]
91
+ }
92
+ }
93
+ success_response(comments: comments)
94
+ end
95
+
96
+ def fetch_issue(number:)
97
+ data = graphql(<<~GRAPHQL, id: identifier_for(number))
98
+ query($id: String!) {
99
+ issue(id: $id) {
100
+ title
101
+ state { name type }
102
+ assignee { name avatarUrl }
103
+ labels { nodes { name color } }
104
+ }
105
+ }
106
+ GRAPHQL
107
+
108
+ issue = data&.dig("issue")
109
+ return error_response(@last_error || "Linear API error: could not fetch issue") unless issue
110
+
111
+ assignee = issue["assignee"]
112
+ success_response(
113
+ state: closed_state_type?(issue.dig("state", "type")) ? "closed" : "open",
114
+ title: issue["title"],
115
+ assignees: assignee ? [ { login: assignee["name"], avatar_url: assignee["avatarUrl"] } ] : [],
116
+ labels: (issue.dig("labels", "nodes") || []).map { |l|
117
+ { name: l["name"], color: l["color"]&.delete("#") }
118
+ }
119
+ )
120
+ end
121
+
122
+ private
123
+
124
+ # Linear issues are addressed by "TEAM-123" — team key + stored number
125
+ def identifier_for(number)
126
+ "#{@repo}-#{number}"
127
+ end
128
+
129
+ def closed_state_type?(state_type)
130
+ [ "completed", "canceled" ].include?(state_type)
131
+ end
132
+
133
+ def update_issue_state(number, state_types)
134
+ issue_id = find_issue_id(number)
135
+ return error_response(@last_error || "Linear issue #{identifier_for(number)} not found") unless issue_id
136
+
137
+ state_id = workflow_state_id(Array(state_types))
138
+ return error_response(@last_error || "No matching workflow state for #{Array(state_types).join('/')}") unless state_id
139
+
140
+ data = graphql(<<~GRAPHQL, id: issue_id, input: { stateId: state_id })
141
+ mutation($id: String!, $input: IssueUpdateInput!) {
142
+ issueUpdate(id: $id, input: $input) { success }
143
+ }
144
+ GRAPHQL
145
+
146
+ data&.dig("issueUpdate", "success") ? success_response({}) : error_response(@last_error || "Linear API error: state update failed")
147
+ end
148
+
149
+ def find_issue_id(number)
150
+ data = graphql("query($id: String!) { issue(id: $id) { id } }", id: identifier_for(number))
151
+ data&.dig("issue", "id")
152
+ end
153
+
154
+ def team_id
155
+ @team_id ||= begin
156
+ data = graphql(<<~GRAPHQL, key: @repo)
157
+ query($key: String!) {
158
+ teams(filter: { key: { eq: $key } }) { nodes { id } }
159
+ }
160
+ GRAPHQL
161
+ data&.dig("teams", "nodes", 0, "id")
162
+ end
163
+ end
164
+
165
+ # Pick the first workflow state whose type matches, in preference order
166
+ def workflow_state_id(preferred_types)
167
+ states = workflow_states
168
+ return nil unless states
169
+
170
+ preferred_types.each do |type|
171
+ match = states.find { |s| s["type"] == type }
172
+ return match["id"] if match
173
+ end
174
+ nil
175
+ end
176
+
177
+ def workflow_states
178
+ @workflow_states ||= begin
179
+ data = graphql(<<~GRAPHQL, key: @repo)
180
+ query($key: String!) {
181
+ workflowStates(filter: { team: { key: { eq: $key } } }) {
182
+ nodes { id name type position }
183
+ }
184
+ }
185
+ GRAPHQL
186
+ data&.dig("workflowStates", "nodes")&.sort_by { |s| s["position"].to_f }
187
+ end
188
+ end
189
+
190
+ # Best-effort: resolve label names to UUIDs, creating missing ones.
191
+ # Label failures must never block issue creation.
192
+ def resolve_label_ids(names)
193
+ names = Array(names).map(&:to_s).reject(&:empty?)
194
+ return [] if names.empty?
195
+
196
+ data = graphql(<<~GRAPHQL, names: names)
197
+ query($names: [String!]) {
198
+ issueLabels(filter: { name: { in: $names } }) { nodes { id name } }
199
+ }
200
+ GRAPHQL
201
+ existing = data&.dig("issueLabels", "nodes") || []
202
+ ids = existing.map { |l| l["id"] }
203
+
204
+ missing = names - existing.map { |l| l["name"] }
205
+ missing.each do |name|
206
+ created = graphql(<<~GRAPHQL, input: { name: name, teamId: team_id })
207
+ mutation($input: IssueLabelCreateInput!) {
208
+ issueLabelCreate(input: $input) { issueLabel { id } }
209
+ }
210
+ GRAPHQL
211
+ id = created&.dig("issueLabelCreate", "issueLabel", "id")
212
+ ids << id if id
213
+ end
214
+
215
+ ids
216
+ rescue
217
+ []
218
+ end
219
+
220
+ # Execute a GraphQL request. Returns the "data" hash, or nil on any
221
+ # error (with the message stashed in @last_error for the caller).
222
+ def graphql(query, variables = {})
223
+ @last_error = nil
224
+ response = http_post(@api_url, { query: query, variables: variables }, auth_headers)
225
+
226
+ if response[:status] != 200
227
+ message = response.dig(:body, "errors", 0, "message") || response[:error]
228
+ @last_error = "Linear API error (#{response[:status]}): #{message}"
229
+ return nil
230
+ end
231
+
232
+ errors = response.dig(:body, "errors")
233
+ if errors.present?
234
+ @last_error = "Linear API error: #{errors.first["message"]}"
235
+ return nil
236
+ end
237
+
238
+ response.dig(:body, "data")
239
+ end
240
+
241
+ def auth_headers
242
+ # Personal API keys are passed bare; OAuth tokens need a Bearer prefix
243
+ value = @token.to_s.start_with?("lin_api_") ? @token : "Bearer #{@token}"
244
+ { "Authorization" => value }
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,195 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ module StormProtection
6
+ # Per-process circuit breaker for the error capture path.
7
+ #
8
+ # Counts capture attempts in fixed 10-second buckets and transitions
9
+ # between states based on the completed bucket's rate:
10
+ #
11
+ # :closed — normal operation, per-fingerprint buckets decide fidelity
12
+ # :shedding — elevated rate: context shed, notifications suppressed
13
+ # :open — storm: count-only mode, zero per-event I/O
14
+ # :half_open — post-cooldown probe: small sample admitted, watching rate
15
+ #
16
+ # Hysteresis: opens FAST (a single hot bucket, or mid-bucket fast-trip),
17
+ # closes SLOW (two consecutive calm buckets) to prevent flapping.
18
+ #
19
+ # Concurrency: the hot path is one AtomicFixnum increment plus a float
20
+ # comparison. The mutex is taken only on bucket roll (once per 10s) and
21
+ # for state transitions — never per event.
22
+ class CircuitBreaker
23
+ BUCKET_SECONDS = 10
24
+ CALM_BUCKETS_TO_CLOSE = 2
25
+
26
+ attr_reader :state
27
+
28
+ # @param clock [#call] returns monotonic seconds; injectable for tests
29
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
30
+ @clock = clock
31
+ @mutex = Mutex.new
32
+ reset!
33
+ end
34
+
35
+ def reset!
36
+ @mutex.synchronize do
37
+ @state = :closed
38
+ @bucket_start = @clock.call
39
+ @bucket_count = Concurrent::AtomicFixnum.new(0)
40
+ @calm_buckets = 0
41
+ @opened_at = nil
42
+ @episode = nil
43
+ end
44
+ end
45
+
46
+ # Count one capture attempt and return the state that should govern it.
47
+ # Called on EVERY capture — must stay allocation-free on the fast path.
48
+ def record!
49
+ now = @clock.call
50
+ roll!(now) if now - @bucket_start >= BUCKET_SECONDS
51
+
52
+ count = @bucket_count.increment
53
+
54
+ # Fast-trip: don't wait for the bucket to complete if it's already
55
+ # over the open threshold — at 50k errors/min a full 10s bucket
56
+ # would let ~8k events through before reacting.
57
+ if count >= open_threshold * BUCKET_SECONDS && @state != :open
58
+ trip_open!(now, count)
59
+ end
60
+
61
+ @state
62
+ end
63
+
64
+ # Episode metadata for the honesty layer (storm_events row).
65
+ # @return [Hash, nil] nil when no episode is active or recently closed
66
+ def episode_snapshot
67
+ @mutex.synchronize { @episode&.dup }
68
+ end
69
+
70
+ # Forget a closed episode once it has been persisted by the flush job.
71
+ def clear_closed_episode!
72
+ @mutex.synchronize do
73
+ @episode = nil if @episode && @episode[:ended_at]
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def roll!(now)
80
+ @mutex.synchronize do
81
+ elapsed = now - @bucket_start
82
+ return if elapsed < BUCKET_SECONDS # another thread already rolled
83
+
84
+ rate = @bucket_count.value / elapsed.to_f
85
+ @bucket_start = now
86
+ @bucket_count = Concurrent::AtomicFixnum.new(0)
87
+ transition!(rate, now)
88
+ end
89
+ end
90
+
91
+ # Transition table — runs inside @mutex, once per bucket roll.
92
+ # track_peak runs AFTER the case: a transition out of :closed creates
93
+ # the episode, and the triggering bucket's rate must be its first peak.
94
+ def transition!(rate, now)
95
+ case @state
96
+ when :closed
97
+ if rate >= open_threshold
98
+ open!(now)
99
+ elsif rate >= shedding_threshold
100
+ enter!(:shedding, now)
101
+ end
102
+ when :shedding
103
+ if rate >= open_threshold
104
+ open!(now)
105
+ elsif rate < shedding_threshold / 2.0
106
+ calm_step!(now)
107
+ else
108
+ @calm_buckets = 0
109
+ end
110
+ when :open
111
+ if now - @opened_at >= cooldown_seconds && rate < shedding_threshold
112
+ @state = :half_open
113
+ @calm_buckets = 0
114
+ end
115
+ when :half_open
116
+ if rate >= shedding_threshold
117
+ open!(now)
118
+ else
119
+ calm_step!(now)
120
+ end
121
+ end
122
+
123
+ track_peak(rate)
124
+ end
125
+
126
+ def calm_step!(now)
127
+ @calm_buckets += 1
128
+ close!(now) if @calm_buckets >= CALM_BUCKETS_TO_CLOSE
129
+ end
130
+
131
+ def enter!(new_state, _now)
132
+ begin_episode! if @state == :closed
133
+ @state = new_state
134
+ @calm_buckets = 0
135
+ end
136
+
137
+ def open!(now)
138
+ begin_episode! if @state == :closed
139
+ @state = :open
140
+ @opened_at = now
141
+ @calm_buckets = 0
142
+ @episode[:reached_open] = true if @episode
143
+ end
144
+
145
+ # Mid-bucket fast trip — takes the mutex (rare: at most once per storm onset).
146
+ def trip_open!(now, count)
147
+ @mutex.synchronize do
148
+ return if @state == :open
149
+
150
+ begin_episode! if @state == :closed
151
+ track_peak(count / [ now - @bucket_start, 1.0 ].max)
152
+ @state = :open
153
+ @opened_at = now
154
+ @calm_buckets = 0
155
+ @episode[:reached_open] = true if @episode
156
+ end
157
+ end
158
+
159
+ def close!(_now)
160
+ @state = :closed
161
+ @calm_buckets = 0
162
+ @episode[:ended_at] = Time.current if @episode
163
+ end
164
+
165
+ def begin_episode!
166
+ @episode = {
167
+ started_at: Time.current,
168
+ ended_at: nil,
169
+ peak_rate_per_minute: 0,
170
+ reached_open: false
171
+ }
172
+ end
173
+
174
+ def track_peak(rate_per_second)
175
+ return unless @episode
176
+
177
+ per_minute = (rate_per_second * 60).round
178
+ @episode[:peak_rate_per_minute] = per_minute if per_minute > @episode[:peak_rate_per_minute]
179
+ end
180
+
181
+ def shedding_threshold
182
+ RailsErrorDashboard.configuration.storm_shedding_threshold_per_second.to_f
183
+ end
184
+
185
+ def open_threshold
186
+ RailsErrorDashboard.configuration.storm_open_threshold_per_second.to_f
187
+ end
188
+
189
+ def cooldown_seconds
190
+ RailsErrorDashboard.configuration.storm_cooldown_seconds.to_i
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ module StormProtection
6
+ # In-memory accumulator for events that are counted but not stored
7
+ # per-event (Layer 1 overflow and the breaker's count-only mode).
8
+ #
9
+ # Stores exact counts plus just enough identity to reconcile onto the
10
+ # right ErrorLog at flush time: the flush command recomputes the
11
+ # canonical error_hash from these parts (with application resolved in
12
+ # the background job, where DB access is allowed) and issues a single
13
+ # `occurrence_count = occurrence_count + N` UPDATE per fingerprint.
14
+ # Fingerprints first seen during count-only mode get a minimal ErrorLog
15
+ # created from the stored exemplar. Counting is exact — no extrapolation.
16
+ #
17
+ # Memory: bounded map; beyond the cap events land in a single overflow
18
+ # counter (still exact in total, anonymous in identity).
19
+ #
20
+ # Concurrency: snapshot! atomically swaps the whole map out via
21
+ # AtomicReference, so flushing never races with recording.
22
+ class CountBuffer
23
+ Entry = Struct.new(
24
+ :error_class, :message, :first_app_frame,
25
+ :controller_name, :action_name, :custom_hash,
26
+ :count, :first_seen_at, :last_seen_at
27
+ )
28
+
29
+ def initialize
30
+ reset!
31
+ end
32
+
33
+ def reset!
34
+ @map_ref = Concurrent::AtomicReference.new(Concurrent::Map.new)
35
+ @overflow = Concurrent::AtomicFixnum.new(0)
36
+ end
37
+
38
+ # Record one counted-not-stored event.
39
+ # @param gate_key [String] cheap in-process bucketing key
40
+ # @param parts [Hash] identity parts captured at the gate
41
+ def record(gate_key, parts)
42
+ map = @map_ref.get
43
+ entry = map[gate_key]
44
+
45
+ unless entry
46
+ if map.size >= max_tracked
47
+ @overflow.increment
48
+ return
49
+ end
50
+ entry = map.compute_if_absent(gate_key) do
51
+ Entry.new(
52
+ parts[:error_class], parts[:message], parts[:first_app_frame],
53
+ parts[:controller_name], parts[:action_name], parts[:custom_hash],
54
+ Concurrent::AtomicFixnum.new(0), Time.current, Time.current
55
+ )
56
+ end
57
+ end
58
+
59
+ entry.count.increment
60
+ entry.last_seen_at = Time.current
61
+ end
62
+
63
+ def any?
64
+ @overflow.value.positive? || !@map_ref.get.empty?
65
+ end
66
+
67
+ # Atomically swap the buffer out and return serializable entry hashes.
68
+ # @return [Hash] { entries: Array<Hash>, overflow: Integer }
69
+ def snapshot!
70
+ old_map = @map_ref.get_and_set(Concurrent::Map.new)
71
+ overflow = @overflow.value
72
+ @overflow.update { |v| v - overflow }
73
+
74
+ entries = []
75
+ old_map.each_pair do |_key, entry|
76
+ entries << {
77
+ "error_class" => entry.error_class,
78
+ "message" => entry.message,
79
+ "first_app_frame" => entry.first_app_frame,
80
+ "controller_name" => entry.controller_name,
81
+ "action_name" => entry.action_name,
82
+ "custom_hash" => entry.custom_hash,
83
+ "count" => entry.count.value,
84
+ "first_seen_at" => entry.first_seen_at.iso8601,
85
+ "last_seen_at" => entry.last_seen_at.iso8601
86
+ }
87
+ end
88
+
89
+ { entries: entries, overflow: overflow }
90
+ end
91
+
92
+ private
93
+
94
+ def max_tracked
95
+ RailsErrorDashboard.configuration.storm_max_tracked_fingerprints.to_i
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsErrorDashboard
4
+ module Services
5
+ module StormProtection
6
+ # Layer 1: per-fingerprint rate limiting with graceful degradation.
7
+ #
8
+ # Each fingerprint gets a 60-second window. Within the window:
9
+ # - first N events → :full (everything captured)
10
+ # - past N, every Mth event → :lite (row captured, context shed)
11
+ # - everything else → :count_only (in-memory count, flushed later)
12
+ #
13
+ # The first event of every window is ALWAYS at least :lite, so a
14
+ # melting-down fingerprint still has a fresh exemplar each minute —
15
+ # deterministic, unlike rand-based sampling.
16
+ #
17
+ # Calm-mode adaptive context sampling rides the same entries: after K
18
+ # full-context captures per fingerprint per day, context is captured
19
+ # only every Mth time (an error firing 1000×/day doesn't need 1000
20
+ # breadcrumb trails). Occurrence rows are unaffected in calm mode.
21
+ #
22
+ # Concurrency: entries are mutable structs in a Concurrent::Map with
23
+ # plain (unlocked) field increments — races can miscount by a handful
24
+ # of events, which is acceptable for rate limiting. No mutex anywhere.
25
+ #
26
+ # Memory: the map is bounded. Once full, unseen fingerprints are NOT
27
+ # tracked and decide as :full — in calm weather that's harmless; in a
28
+ # storm of unique fingerprints the global breaker (Layer 2) takes over.
29
+ class FingerprintBuckets
30
+ WINDOW_SECONDS = 60
31
+ DAY_SECONDS = 86_400
32
+
33
+ Entry = Struct.new(:window_start, :window_count, :day_start, :day_full_context_count)
34
+
35
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
36
+ @clock = clock
37
+ reset!
38
+ end
39
+
40
+ def reset!
41
+ @entries = Concurrent::Map.new
42
+ end
43
+
44
+ # Decide capture fidelity for one event of this fingerprint.
45
+ # @return [Symbol] :full, :lite, or :count_only
46
+ def decide(gate_key)
47
+ now = @clock.call
48
+ entry = fetch_entry(gate_key, now)
49
+ return :full unless entry # map full — Layer 2 owns the storm case
50
+
51
+ roll_windows(entry, now)
52
+ entry.window_count += 1
53
+ n = entry.window_count
54
+
55
+ if n <= full_per_minute
56
+ decide_calm_context(entry)
57
+ elsif n == full_per_minute + 1 || ((n - full_per_minute) % keep_every).zero?
58
+ :lite
59
+ else
60
+ :count_only
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def fetch_entry(gate_key, now)
67
+ existing = @entries[gate_key]
68
+ return existing if existing
69
+
70
+ # Bounded: never insert past the cap (size check is approximate
71
+ # under concurrency — a few entries over the cap is fine)
72
+ return nil if @entries.size >= max_tracked
73
+
74
+ @entries.compute_if_absent(gate_key) { Entry.new(now, 0, now, 0) }
75
+ end
76
+
77
+ def roll_windows(entry, now)
78
+ if now - entry.window_start >= WINDOW_SECONDS
79
+ entry.window_start = now
80
+ entry.window_count = 0
81
+ end
82
+ if now - entry.day_start >= DAY_SECONDS
83
+ entry.day_start = now
84
+ entry.day_full_context_count = 0
85
+ end
86
+ end
87
+
88
+ # Under the per-minute cap: full context unless this fingerprint has
89
+ # already produced plenty of full-context captures today.
90
+ def decide_calm_context(entry)
91
+ entry.day_full_context_count += 1
92
+ k = entry.day_full_context_count
93
+
94
+ if k <= context_threshold_per_day || (k % context_keep_every).zero?
95
+ :full
96
+ else
97
+ :lite
98
+ end
99
+ end
100
+
101
+ def full_per_minute
102
+ RailsErrorDashboard.configuration.storm_fingerprint_full_per_minute.to_i
103
+ end
104
+
105
+ def keep_every
106
+ [ RailsErrorDashboard.configuration.storm_occurrence_sample_keep_every.to_i, 1 ].max
107
+ end
108
+
109
+ def context_threshold_per_day
110
+ RailsErrorDashboard.configuration.context_sampling_threshold_per_day.to_i
111
+ end
112
+
113
+ def context_keep_every
114
+ [ RailsErrorDashboard.configuration.context_sampling_keep_every.to_i, 1 ].max
115
+ end
116
+
117
+ def max_tracked
118
+ RailsErrorDashboard.configuration.storm_max_tracked_fingerprints.to_i
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end