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.
- checksums.yaml +4 -4
- data/README.md +34 -1
- data/app/controllers/rails_error_dashboard/application_controller.rb +5 -0
- data/app/controllers/rails_error_dashboard/errors_controller.rb +12 -0
- data/app/controllers/rails_error_dashboard/webhooks_controller.rb +35 -1
- data/app/jobs/rails_error_dashboard/storm_flush_job.rb +19 -0
- data/app/jobs/rails_error_dashboard/storm_notification_job.rb +74 -0
- data/app/models/rails_error_dashboard/storm_event.rb +34 -0
- data/app/views/layouts/rails_error_dashboard.html.erb +21 -0
- data/app/views/rails_error_dashboard/errors/_issue_section.html.erb +1 -0
- data/app/views/rails_error_dashboard/errors/storms.html.erb +91 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260306000002_add_instance_variables_to_error_logs.rb +7 -1
- data/db/migrate/20260306000003_create_rails_error_dashboard_swallowed_exceptions.rb +4 -0
- data/db/migrate/20260307000001_create_rails_error_dashboard_diagnostic_dumps.rb +4 -0
- data/db/migrate/20260613000001_create_storm_events.rb +28 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +49 -2
- data/lib/rails_error_dashboard/commands/create_issue.rb +1 -1
- data/lib/rails_error_dashboard/commands/flush_storm_counts.rb +188 -0
- data/lib/rails_error_dashboard/commands/link_existing_issue.rb +3 -1
- data/lib/rails_error_dashboard/commands/log_error.rb +70 -12
- data/lib/rails_error_dashboard/configuration.rb +73 -7
- data/lib/rails_error_dashboard/queries/storm_history.rb +39 -0
- data/lib/rails_error_dashboard/services/issue_tracker_client.rb +8 -5
- data/lib/rails_error_dashboard/services/linear_issue_client.rb +248 -0
- data/lib/rails_error_dashboard/services/storm_protection/circuit_breaker.rb +195 -0
- data/lib/rails_error_dashboard/services/storm_protection/count_buffer.rb +100 -0
- data/lib/rails_error_dashboard/services/storm_protection/fingerprint_buckets.rb +123 -0
- data/lib/rails_error_dashboard/services/storm_protection/gate.rb +258 -0
- data/lib/rails_error_dashboard/subscribers/issue_tracker_subscriber.rb +12 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +7 -0
- 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
|