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.
@@ -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