realuptime-errors 0.1.0
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +94 -0
- data/lib/realuptime/errors/rack.rb +73 -0
- data/lib/realuptime/errors/rails.rb +144 -0
- data/lib/realuptime/errors/scrub.rb +150 -0
- data/lib/realuptime/errors/sidekiq.rb +75 -0
- data/lib/realuptime/errors/transport.rb +257 -0
- data/lib/realuptime/errors/version.rb +10 -0
- data/lib/realuptime/errors.rb +489 -0
- data/scrub-vectors.json +506 -0
- metadata +60 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Realuptime
|
|
9
|
+
module Errors
|
|
10
|
+
# Batched, buffered, backed-off delivery. Mirrors packages/errors-js/
|
|
11
|
+
# client.ts and the Python SDK's _Transport: over_quota pauses until the
|
|
12
|
+
# reset instant, key_revoked disables for the process lifetime,
|
|
13
|
+
# transient failures back off exponentially (5s doubling to 300s), and
|
|
14
|
+
# client-side drops ride the next successful batch as droppedClient so
|
|
15
|
+
# they reach the product's visible drop counters. Nothing here ever
|
|
16
|
+
# raises out to the host app.
|
|
17
|
+
#
|
|
18
|
+
# Delivery runs on ONE background thread per process, woken by enqueue
|
|
19
|
+
# and by the backoff timer. Forking servers (Puma clustered, Unicorn,
|
|
20
|
+
# Sidekiq under a preforking supervisor) are handled by re-spawning the
|
|
21
|
+
# worker when the pid changes: a thread does not survive fork, and a
|
|
22
|
+
# buffer that was filling in the parent must keep flushing in the child.
|
|
23
|
+
# `flush` is synchronous and is what at_exit, tests and job-finished
|
|
24
|
+
# hooks call.
|
|
25
|
+
class Transport
|
|
26
|
+
attr_reader :endpoint
|
|
27
|
+
|
|
28
|
+
def initialize(endpoint, opener: nil, now: nil, log: nil, background: true)
|
|
29
|
+
@endpoint = endpoint
|
|
30
|
+
@opener = opener || method(:http_post)
|
|
31
|
+
@now = now || -> { Time.now.to_f }
|
|
32
|
+
@log_fn = log || ->(message) { warn(message) }
|
|
33
|
+
@background = background
|
|
34
|
+
@lock = Mutex.new
|
|
35
|
+
@wake = ConditionVariable.new
|
|
36
|
+
@events = []
|
|
37
|
+
@dropped_since_report = 0
|
|
38
|
+
@backoff_s = 0.0
|
|
39
|
+
@next_attempt_at = 0.0
|
|
40
|
+
@paused_until = 0.0
|
|
41
|
+
@disabled = false
|
|
42
|
+
@logged_kinds = {}
|
|
43
|
+
@worker = nil
|
|
44
|
+
@worker_pid = nil
|
|
45
|
+
@closing = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Test and diagnostics seams.
|
|
49
|
+
def buffered_count
|
|
50
|
+
@lock.synchronize { @events.length }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def dropped_pending
|
|
54
|
+
@lock.synchronize { @dropped_since_report }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def disabled?
|
|
58
|
+
@lock.synchronize { @disabled }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def enqueue(event)
|
|
62
|
+
@lock.synchronize do
|
|
63
|
+
return if @disabled
|
|
64
|
+
|
|
65
|
+
if @events.length >= BUFFER_MAX
|
|
66
|
+
@events.shift
|
|
67
|
+
@dropped_since_report += 1
|
|
68
|
+
end
|
|
69
|
+
@events << event
|
|
70
|
+
end
|
|
71
|
+
if @background
|
|
72
|
+
ensure_worker
|
|
73
|
+
@lock.synchronize { @wake.signal }
|
|
74
|
+
else
|
|
75
|
+
flush
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Sends what is due, synchronously on the calling thread. Never raises.
|
|
80
|
+
def flush
|
|
81
|
+
deliver
|
|
82
|
+
rescue StandardError => e
|
|
83
|
+
log_once("internal", "delivery failed internally: #{e.class}: #{e.message}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Stops the worker thread (test seam / at_exit).
|
|
87
|
+
def close
|
|
88
|
+
@lock.synchronize do
|
|
89
|
+
@closing = true
|
|
90
|
+
@wake.broadcast
|
|
91
|
+
end
|
|
92
|
+
worker = @worker
|
|
93
|
+
worker&.join(1) if worker && worker != Thread.current
|
|
94
|
+
@worker = nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def http_post(url, body)
|
|
100
|
+
uri = URI.parse(url)
|
|
101
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
102
|
+
http.use_ssl = uri.scheme == "https"
|
|
103
|
+
http.open_timeout = SEND_TIMEOUT_S
|
|
104
|
+
http.read_timeout = SEND_TIMEOUT_S
|
|
105
|
+
req = Net::HTTP::Post.new(uri.request_uri, "content-type" => "application/json")
|
|
106
|
+
req.body = body
|
|
107
|
+
res = http.request(req)
|
|
108
|
+
[res.code.to_i, res.body]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def ensure_worker
|
|
112
|
+
pid = Process.pid
|
|
113
|
+
@lock.synchronize do
|
|
114
|
+
return if @worker&.alive? && @worker_pid == pid
|
|
115
|
+
|
|
116
|
+
@worker_pid = pid
|
|
117
|
+
@closing = false
|
|
118
|
+
@worker = Thread.new { worker_loop }
|
|
119
|
+
@worker.name = "realuptime-errors" if @worker.respond_to?(:name=)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def worker_loop
|
|
124
|
+
loop do
|
|
125
|
+
wait_s = nil
|
|
126
|
+
@lock.synchronize do
|
|
127
|
+
return if @closing
|
|
128
|
+
|
|
129
|
+
if @events.empty?
|
|
130
|
+
@wake.wait(@lock)
|
|
131
|
+
else
|
|
132
|
+
now = @now.call
|
|
133
|
+
due = [@next_attempt_at, @paused_until].max
|
|
134
|
+
wait_s = due - now if due > now
|
|
135
|
+
@wake.wait(@lock, wait_s) if wait_s
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
flush
|
|
139
|
+
end
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def log_once(kind, message)
|
|
145
|
+
return if @logged_kinds[kind]
|
|
146
|
+
|
|
147
|
+
@logged_kinds[kind] = true
|
|
148
|
+
begin
|
|
149
|
+
@log_fn.call("[realuptime-errors] #{message}")
|
|
150
|
+
rescue StandardError
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def deliver
|
|
156
|
+
loop do
|
|
157
|
+
events = nil
|
|
158
|
+
dropped_client = 0
|
|
159
|
+
@lock.synchronize do
|
|
160
|
+
now = @now.call
|
|
161
|
+
return if @disabled || @events.empty? || now < @next_attempt_at || now < @paused_until
|
|
162
|
+
|
|
163
|
+
events = @events[0, MAX_EVENTS_PER_BATCH]
|
|
164
|
+
dropped_client = @dropped_since_report
|
|
165
|
+
@dropped_since_report = 0
|
|
166
|
+
end
|
|
167
|
+
batch = { "sdk" => "#{SDK_NAME}/#{SDK_VERSION}", "droppedClient" => dropped_client, "events" => events }
|
|
168
|
+
begin
|
|
169
|
+
status, raw = @opener.call(@endpoint, JSON.generate(batch))
|
|
170
|
+
rescue StandardError
|
|
171
|
+
@lock.synchronize { @dropped_since_report += dropped_client }
|
|
172
|
+
back_off("network", "cannot reach the ingest endpoint; buffering and retrying")
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
body = begin
|
|
176
|
+
raw && !raw.empty? ? JSON.parse(raw) : {}
|
|
177
|
+
rescue StandardError
|
|
178
|
+
{}
|
|
179
|
+
end
|
|
180
|
+
body = {} unless body.is_a?(Hash)
|
|
181
|
+
|
|
182
|
+
if status >= 200 && status < 300
|
|
183
|
+
@lock.synchronize do
|
|
184
|
+
@events.shift(events.length)
|
|
185
|
+
@backoff_s = 0.0
|
|
186
|
+
@next_attempt_at = 0.0
|
|
187
|
+
end
|
|
188
|
+
if body["overQuota"]
|
|
189
|
+
pause_until((body["quota"] || {})["resetsAt"])
|
|
190
|
+
log_once("over-quota", OVER_QUOTA_MESSAGE)
|
|
191
|
+
return
|
|
192
|
+
end
|
|
193
|
+
next
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
reason = body["reason"]
|
|
197
|
+
if status == 403 && reason == "key_revoked"
|
|
198
|
+
@lock.synchronize do
|
|
199
|
+
@disabled = true
|
|
200
|
+
@events.clear
|
|
201
|
+
end
|
|
202
|
+
log_once(
|
|
203
|
+
"revoked",
|
|
204
|
+
"this project's ingest key was revoked; error reporting is disabled for this process. " \
|
|
205
|
+
"Rotate the key in realuptime Errors settings and redeploy with the new DSN."
|
|
206
|
+
)
|
|
207
|
+
return
|
|
208
|
+
end
|
|
209
|
+
if status == 429 && reason == "over_quota"
|
|
210
|
+
@lock.synchronize { @events.shift(events.length) }
|
|
211
|
+
pause_until(body["resetsAt"])
|
|
212
|
+
log_once("over-quota", OVER_QUOTA_MESSAGE)
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
if status == 400
|
|
216
|
+
@lock.synchronize do
|
|
217
|
+
@events.shift(events.length)
|
|
218
|
+
@dropped_since_report += dropped_client
|
|
219
|
+
end
|
|
220
|
+
log_once(
|
|
221
|
+
"malformed",
|
|
222
|
+
"the server refused a batch as malformed: #{body["error"] || "no detail"}. This is an SDK bug worth reporting."
|
|
223
|
+
)
|
|
224
|
+
next
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
@lock.synchronize { @dropped_since_report += dropped_client }
|
|
228
|
+
back_off("transient", "ingest endpoint answered #{status}; buffering and retrying")
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
OVER_QUOTA_MESSAGE = "monthly event quota reached; pausing until the window resets. " \
|
|
234
|
+
"Dropped events are counted and shown on your realuptime Errors dashboard."
|
|
235
|
+
|
|
236
|
+
def pause_until(resets_at)
|
|
237
|
+
parsed = nil
|
|
238
|
+
if resets_at.is_a?(String)
|
|
239
|
+
begin
|
|
240
|
+
parsed = Time.iso8601(resets_at).to_f
|
|
241
|
+
rescue StandardError
|
|
242
|
+
parsed = nil
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
@lock.synchronize { @paused_until = parsed || (@now.call + 3600.0) }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def back_off(kind, message)
|
|
249
|
+
@lock.synchronize do
|
|
250
|
+
@backoff_s = @backoff_s <= 0 ? BACKOFF_START_S : [@backoff_s * 2, BACKOFF_MAX_S].min
|
|
251
|
+
@next_attempt_at = @now.call + @backoff_s
|
|
252
|
+
end
|
|
253
|
+
log_once(kind, "#{message} (backing off).")
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Realuptime
|
|
4
|
+
module Errors
|
|
5
|
+
SDK_NAME = "realuptime-errors-ruby"
|
|
6
|
+
# The string every batch carries on the wire ("sdk" field); the gemspec
|
|
7
|
+
# and the public mirror's version both read this constant.
|
|
8
|
+
SDK_VERSION = "0.1.0"
|
|
9
|
+
end
|
|
10
|
+
end
|