naijacloud-argus 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/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +93 -0
- data/LICENSE +21 -0
- data/README.md +255 -0
- data/SECURITY.md +60 -0
- data/lib/naijacloud/argus/client.rb +647 -0
- data/lib/naijacloud/argus/configuration.rb +95 -0
- data/lib/naijacloud/argus/dsn.rb +60 -0
- data/lib/naijacloud/argus/integrations/net_http.rb +77 -0
- data/lib/naijacloud/argus/integrations/rack.rb +134 -0
- data/lib/naijacloud/argus/integrations/rails.rb +131 -0
- data/lib/naijacloud/argus/logger.rb +60 -0
- data/lib/naijacloud/argus/otlp.rb +116 -0
- data/lib/naijacloud/argus/scope.rb +66 -0
- data/lib/naijacloud/argus/scrub.rb +108 -0
- data/lib/naijacloud/argus/span.rb +109 -0
- data/lib/naijacloud/argus/stack.rb +101 -0
- data/lib/naijacloud/argus/transport.rb +352 -0
- data/lib/naijacloud/argus/version.rb +14 -0
- data/lib/naijacloud/argus.rb +320 -0
- metadata +100 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module NaijaCloud
|
|
8
|
+
module Argus
|
|
9
|
+
# Sending (spec §6). One bounded queue and one background thread per
|
|
10
|
+
# endpoint ("lane"), so a slow traces endpoint never holds up errors, and
|
|
11
|
+
# nothing here ever runs on, blocks, or raises into the app's own threads.
|
|
12
|
+
#
|
|
13
|
+
# - network errors and 5xx: back off 1 s, 2 s, 4 s … (cap 60 s), 5 tries;
|
|
14
|
+
# - 429: wait Retry-After (or X-Sentry-Rate-Limits), retry without
|
|
15
|
+
# counting a try, and send nothing else on that lane meanwhile;
|
|
16
|
+
# - 401/403: the key was refused; the lane stops until the process restarts;
|
|
17
|
+
# - any other status: dropped, never retried. That includes 3xx: a
|
|
18
|
+
# followed redirect would resend the key to a host the response chose.
|
|
19
|
+
class Transport
|
|
20
|
+
MAX_ITEMS = 100
|
|
21
|
+
MAX_BYTES = 1 << 20
|
|
22
|
+
MAX_TRIES = 5
|
|
23
|
+
MAX_BACKOFF = 60
|
|
24
|
+
MAX_RATE_LIMIT = 3600
|
|
25
|
+
|
|
26
|
+
Request = Struct.new(:url, :body, :headers) do
|
|
27
|
+
def bytesize
|
|
28
|
+
body.bytesize
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# A response as the transport saw it; handed to `observer` (contract
|
|
33
|
+
# tests) and nothing else.
|
|
34
|
+
Response = Struct.new(:lane, :url, :status, :headers)
|
|
35
|
+
|
|
36
|
+
class Lane
|
|
37
|
+
attr_accessor :queue, :bytes, :dead, :blocked_until, :busy, :thread, :cv
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@queue = []
|
|
41
|
+
@bytes = 0
|
|
42
|
+
@dead = false
|
|
43
|
+
@blocked_until = 0.0
|
|
44
|
+
@busy = false
|
|
45
|
+
@cv = ConditionVariable.new
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Test hooks. `sleeper` replaces waiting (it is handed the seconds that
|
|
50
|
+
# would have been slept); `observer` sees every response.
|
|
51
|
+
attr_writer :sleeper
|
|
52
|
+
attr_accessor :observer
|
|
53
|
+
# The per-lane byte bound (spec §6: 1 MB). Settable for tests only.
|
|
54
|
+
attr_accessor :max_bytes
|
|
55
|
+
|
|
56
|
+
def initialize(debug: nil, open_timeout: 5, read_timeout: 10)
|
|
57
|
+
@debug = debug
|
|
58
|
+
@open_timeout = open_timeout
|
|
59
|
+
@read_timeout = read_timeout
|
|
60
|
+
@sleeper = nil
|
|
61
|
+
@observer = nil
|
|
62
|
+
@max_bytes = MAX_BYTES
|
|
63
|
+
@dropped_series_logged = false
|
|
64
|
+
reset!
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Queues a request on a lane. Returns false when it was not accepted.
|
|
68
|
+
def enqueue(lane_name, request)
|
|
69
|
+
after_fork_reset if @pid != Process.pid
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
return false if @closed
|
|
72
|
+
|
|
73
|
+
lane = (@lanes[lane_name] ||= Lane.new)
|
|
74
|
+
return false if lane.dead
|
|
75
|
+
|
|
76
|
+
lane.queue << request
|
|
77
|
+
lane.bytes += request.bytesize
|
|
78
|
+
# Drop the oldest, not the newest: the newest describes the state
|
|
79
|
+
# the app is in now, which is what someone looking at it wants.
|
|
80
|
+
while lane.queue.length > MAX_ITEMS || (lane.bytes > @max_bytes && lane.queue.length > 1)
|
|
81
|
+
lane.bytes -= lane.queue.shift.bytesize
|
|
82
|
+
end
|
|
83
|
+
if lane.bytes > @max_bytes
|
|
84
|
+
lane.queue.clear
|
|
85
|
+
lane.bytes = 0
|
|
86
|
+
log("#{lane_name}: a single request over #{@max_bytes} bytes, dropped")
|
|
87
|
+
return false
|
|
88
|
+
end
|
|
89
|
+
start_worker(lane_name, lane)
|
|
90
|
+
lane.cv.signal
|
|
91
|
+
end
|
|
92
|
+
true
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
log("#{lane_name}: could not queue (#{e.class}: #{e.message})")
|
|
95
|
+
false
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Waits until every lane is empty and idle, or timeout seconds pass.
|
|
99
|
+
# True when everything went out.
|
|
100
|
+
def flush(timeout = 2.0)
|
|
101
|
+
deadline = monotonic + timeout.to_f
|
|
102
|
+
@mutex.synchronize do
|
|
103
|
+
until idle?
|
|
104
|
+
remaining = deadline - monotonic
|
|
105
|
+
return false if remaining <= 0
|
|
106
|
+
|
|
107
|
+
@idle.wait(@mutex, remaining)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
true
|
|
111
|
+
rescue StandardError
|
|
112
|
+
false
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Stops the workers. Anything still queued is dropped.
|
|
116
|
+
def close
|
|
117
|
+
threads = @mutex.synchronize do
|
|
118
|
+
@closed = true
|
|
119
|
+
@lanes.each_value { |l| l.cv.broadcast }
|
|
120
|
+
@wake.broadcast
|
|
121
|
+
@lanes.values.map(&:thread).compact
|
|
122
|
+
end
|
|
123
|
+
threads.each { |t| t.join(0.5) }
|
|
124
|
+
nil
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def dead?(lane_name)
|
|
130
|
+
@mutex.synchronize { (l = @lanes[lane_name]) ? l.dead : false }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# For the flags endpoint, which answers synchronously but must obey the
|
|
134
|
+
# same 401/429 rules as the queued lanes.
|
|
135
|
+
def mark_dead(lane_name)
|
|
136
|
+
@mutex.synchronize { (@lanes[lane_name] ||= Lane.new).dead = true }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def block(lane_name, seconds)
|
|
140
|
+
@mutex.synchronize { (@lanes[lane_name] ||= Lane.new).blocked_until = monotonic + seconds }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def blocked?(lane_name)
|
|
144
|
+
@mutex.synchronize { (l = @lanes[lane_name]) ? monotonic < l.blocked_until : false }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# One synchronous POST, outside the queues. Returns a Net::HTTPResponse
|
|
148
|
+
# or raises; callers rescue.
|
|
149
|
+
def post(url, body, headers, read_timeout: @read_timeout, open_timeout: @open_timeout)
|
|
150
|
+
uri = URI.parse(url)
|
|
151
|
+
req = Net::HTTP::Post.new(uri.request_uri)
|
|
152
|
+
headers.each { |k, v| req[k] = v }
|
|
153
|
+
req["User-Agent"] = "#{SDK_NAME}/#{VERSION}"
|
|
154
|
+
req.body = body
|
|
155
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
156
|
+
http.use_ssl = uri.scheme == "https"
|
|
157
|
+
# Explicit, not inherited from whatever else in the process has
|
|
158
|
+
# touched OpenSSL's defaults.
|
|
159
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER if http.use_ssl?
|
|
160
|
+
http.open_timeout = open_timeout
|
|
161
|
+
http.read_timeout = read_timeout
|
|
162
|
+
http.write_timeout = read_timeout if http.respond_to?(:write_timeout=)
|
|
163
|
+
Transport.internal { http.start { |conn| conn.request(req) } }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Marks the current thread as SDK-internal so the Net::HTTP integration
|
|
167
|
+
# does not trace, or add breadcrumbs for, Argus's own requests.
|
|
168
|
+
def self.internal
|
|
169
|
+
prev = Thread.current[:naijacloud_argus_internal]
|
|
170
|
+
Thread.current[:naijacloud_argus_internal] = true
|
|
171
|
+
yield
|
|
172
|
+
ensure
|
|
173
|
+
Thread.current[:naijacloud_argus_internal] = prev
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.internal?
|
|
177
|
+
Thread.current[:naijacloud_argus_internal] == true
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Reads Retry-After, then X-Sentry-Rate-Limits; at least 1 s, at most 1 h.
|
|
181
|
+
def self.retry_after(headers)
|
|
182
|
+
ra = Float(headers["retry-after"].to_s, exception: false)
|
|
183
|
+
seconds = ra && ra.positive? ? ra : nil
|
|
184
|
+
unless seconds
|
|
185
|
+
first = Float(headers["x-sentry-rate-limits"].to_s.split(":").first.to_s, exception: false)
|
|
186
|
+
seconds = first && first.positive? ? first : 60
|
|
187
|
+
end
|
|
188
|
+
seconds.clamp(1, MAX_RATE_LIMIT)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def inspect
|
|
192
|
+
"#<NaijaCloud::Argus::Transport>"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
def reset!
|
|
198
|
+
@pid = Process.pid
|
|
199
|
+
@mutex = Mutex.new
|
|
200
|
+
@idle = ConditionVariable.new
|
|
201
|
+
@wake = ConditionVariable.new
|
|
202
|
+
@lanes = {}
|
|
203
|
+
@closed = false
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Puma, Unicorn and Resque fork after the app has loaded. The parent's
|
|
207
|
+
# worker threads do not exist in the child, and its queue belongs to the
|
|
208
|
+
# parent, so the child starts empty.
|
|
209
|
+
def after_fork_reset
|
|
210
|
+
reset!
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def idle?
|
|
214
|
+
@lanes.each_value.all? { |l| (l.queue.empty? || l.dead) && !l.busy }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def start_worker(name, lane)
|
|
218
|
+
return if lane.thread&.alive?
|
|
219
|
+
|
|
220
|
+
lane.thread = Thread.new { work(name, lane) }
|
|
221
|
+
lane.thread.name = "argus-#{name}" if lane.thread.respond_to?(:name=)
|
|
222
|
+
lane.thread.report_on_exception = false
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def work(name, lane)
|
|
226
|
+
Thread.current[:naijacloud_argus_internal] = true
|
|
227
|
+
loop do
|
|
228
|
+
req = @mutex.synchronize do
|
|
229
|
+
lane.cv.wait(@mutex) while lane.queue.empty? && !@closed && !lane.dead
|
|
230
|
+
if @closed || lane.dead
|
|
231
|
+
@idle.broadcast
|
|
232
|
+
return
|
|
233
|
+
end
|
|
234
|
+
lane.busy = true
|
|
235
|
+
r = lane.queue.shift
|
|
236
|
+
lane.bytes -= r.bytesize
|
|
237
|
+
r
|
|
238
|
+
end
|
|
239
|
+
begin
|
|
240
|
+
deliver(name, lane, req)
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
log("#{name}: #{e.class}: #{e.message}")
|
|
243
|
+
ensure
|
|
244
|
+
@mutex.synchronize do
|
|
245
|
+
lane.busy = false
|
|
246
|
+
@idle.broadcast
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def deliver(name, lane, req)
|
|
253
|
+
tries = 0
|
|
254
|
+
wait = @mutex.synchronize { lane.blocked_until - monotonic }
|
|
255
|
+
pause(wait) if wait.positive?
|
|
256
|
+
loop do
|
|
257
|
+
return if closed?
|
|
258
|
+
|
|
259
|
+
res = begin
|
|
260
|
+
post(req.url, req.body, req.headers)
|
|
261
|
+
rescue StandardError => e
|
|
262
|
+
log("#{name}: network error (#{e.class}: #{e.message}), try #{tries + 1}")
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
observe(name, req, res)
|
|
266
|
+
code = res ? res.code.to_i : 0
|
|
267
|
+
|
|
268
|
+
if res && code >= 200 && code < 300
|
|
269
|
+
note_dropped_series(res)
|
|
270
|
+
return
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
if code == 429
|
|
274
|
+
seconds = Transport.retry_after(res)
|
|
275
|
+
@mutex.synchronize { lane.blocked_until = monotonic + seconds }
|
|
276
|
+
log("#{name}: 429, waiting #{seconds} s")
|
|
277
|
+
pause(seconds)
|
|
278
|
+
next
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
if [401, 403].include?(code)
|
|
282
|
+
log("#{name}: #{code}, the key was refused; stopping this endpoint until restart")
|
|
283
|
+
@mutex.synchronize do
|
|
284
|
+
lane.dead = true
|
|
285
|
+
lane.queue.clear
|
|
286
|
+
lane.bytes = 0
|
|
287
|
+
end
|
|
288
|
+
return
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
if res && code < 500
|
|
292
|
+
log("#{name}: #{code}, dropped")
|
|
293
|
+
return
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
tries += 1
|
|
297
|
+
if tries >= MAX_TRIES
|
|
298
|
+
log("#{name}: giving up after #{tries} tries")
|
|
299
|
+
return
|
|
300
|
+
end
|
|
301
|
+
log("#{name}: #{code}, try #{tries}") if res
|
|
302
|
+
pause([MAX_BACKOFF, 2**(tries - 1)].min)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def observe(name, req, res)
|
|
307
|
+
return unless @observer
|
|
308
|
+
|
|
309
|
+
headers = {}
|
|
310
|
+
res&.each_header { |k, v| headers[k] = v }
|
|
311
|
+
@observer.call(Response.new(name, req.url, res ? res.code.to_i : 0, headers))
|
|
312
|
+
rescue StandardError
|
|
313
|
+
nil
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def note_dropped_series(res)
|
|
317
|
+
return if @dropped_series_logged || res["x-argus-dropped-series"].nil?
|
|
318
|
+
|
|
319
|
+
@dropped_series_logged = true
|
|
320
|
+
log("the workspace hit its metric series limit; new series are being dropped (X-Argus-Dropped-Series: #{res['x-argus-dropped-series']})")
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def closed?
|
|
324
|
+
@mutex.synchronize { @closed }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Waits on a condition variable rather than Kernel#sleep so close can
|
|
328
|
+
# wake a worker that is backing off.
|
|
329
|
+
def pause(seconds)
|
|
330
|
+
return @sleeper.call(seconds) if @sleeper
|
|
331
|
+
|
|
332
|
+
deadline = monotonic + seconds
|
|
333
|
+
@mutex.synchronize do
|
|
334
|
+
until @closed
|
|
335
|
+
remaining = deadline - monotonic
|
|
336
|
+
break if remaining <= 0
|
|
337
|
+
|
|
338
|
+
@wake.wait(@mutex, remaining)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def monotonic
|
|
344
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def log(msg)
|
|
348
|
+
@debug&.call(msg)
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NaijaCloud
|
|
4
|
+
module Argus
|
|
5
|
+
VERSION = "0.1.0"
|
|
6
|
+
|
|
7
|
+
# The wire contract this gem implements (nc-argus spec/README.md). Sent as
|
|
8
|
+
# the `argus.contract` resource attribute so ingest can tell which rules a
|
|
9
|
+
# payload was built under.
|
|
10
|
+
CONTRACT = "argus.sdk/1"
|
|
11
|
+
|
|
12
|
+
SDK_NAME = "argus.ruby"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "argus/version"
|
|
4
|
+
require_relative "argus/dsn"
|
|
5
|
+
require_relative "argus/scrub"
|
|
6
|
+
require_relative "argus/otlp"
|
|
7
|
+
require_relative "argus/transport"
|
|
8
|
+
require_relative "argus/stack"
|
|
9
|
+
require_relative "argus/span"
|
|
10
|
+
require_relative "argus/scope"
|
|
11
|
+
require_relative "argus/configuration"
|
|
12
|
+
require_relative "argus/client"
|
|
13
|
+
require_relative "argus/integrations/rack"
|
|
14
|
+
|
|
15
|
+
module NaijaCloud
|
|
16
|
+
# The official Ruby SDK for Naijacloud Argus (contract `argus.sdk/1`).
|
|
17
|
+
#
|
|
18
|
+
# NaijaCloud::Argus.init # reads ARGUS_DSN
|
|
19
|
+
# NaijaCloud::Argus.capture_exception(err)
|
|
20
|
+
#
|
|
21
|
+
# Every method here is safe to call before `init`, without a DSN, or after
|
|
22
|
+
# `close`: it does nothing. None of them raises, whatever Argus or the
|
|
23
|
+
# network does.
|
|
24
|
+
module Argus
|
|
25
|
+
# ::Logger-compatible sink; loaded on first use so apps that never touch it
|
|
26
|
+
# do not load the logger library.
|
|
27
|
+
autoload :Logger, File.expand_path("argus/logger", __dir__)
|
|
28
|
+
|
|
29
|
+
@client = nil
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
@at_exit_installed = false
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Starts Argus. Options win over ARGUS_* and then SENTRY_* environment
|
|
35
|
+
# variables. A second call returns the client the first one made.
|
|
36
|
+
#
|
|
37
|
+
# NaijaCloud::Argus.init(dsn: "https://…@argus-in.naijacloud.com/41")
|
|
38
|
+
# NaijaCloud::Argus.init { |c| c.release = "1.4.2"; c.traces_sample_rate = 0.2 }
|
|
39
|
+
def init(**options)
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
return @client if @client
|
|
42
|
+
|
|
43
|
+
config = Configuration.new(**options)
|
|
44
|
+
yield config if block_given?
|
|
45
|
+
config.resolve!
|
|
46
|
+
@client = Client.new(config)
|
|
47
|
+
install(@client)
|
|
48
|
+
@client
|
|
49
|
+
end
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
warn("[argus] init failed: #{e.class}: #{e.message}") if options[:debug]
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The client init made, or nil.
|
|
56
|
+
def client
|
|
57
|
+
@client
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialized?
|
|
61
|
+
!@client.nil?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether init found a DSN and the SDK is sending.
|
|
65
|
+
def enabled?
|
|
66
|
+
!!@client&.enabled?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# ---- errors ----
|
|
70
|
+
|
|
71
|
+
# Sends an exception. Returns the event id, or nil when nothing was sent.
|
|
72
|
+
# Options: level:, tags:, extra:, user:, contexts:, fingerprint:,
|
|
73
|
+
# request:, transaction:.
|
|
74
|
+
def capture_exception(exception, **context)
|
|
75
|
+
safely(nil) { @client&.capture_exception(exception, **context) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def capture_message(message, level: :info, **context)
|
|
79
|
+
safely(nil) { @client&.capture_message(message, level: level, **context) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# `set_user(id: "u_1", email: "ada@example.com")`, or `set_user(nil)`.
|
|
83
|
+
def set_user(user = nil, **fields)
|
|
84
|
+
safely { @client&.set_user(user.nil? && fields.empty? ? nil : (user || {}).merge(fields)) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def set_tag(key, value)
|
|
88
|
+
safely { @client&.set_tag(key, value) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def set_tags(tags)
|
|
92
|
+
safely { @client&.set_tags(tags) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def set_context(name, context)
|
|
96
|
+
safely { @client&.set_context(name, context) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# `add_breadcrumb(category: "auth", message: "signed in", data: {...})`
|
|
100
|
+
def add_breadcrumb(crumb = nil, **fields)
|
|
101
|
+
safely { @client&.add_breadcrumb((crumb || {}).merge(fields)) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Runs the block with its own user, tags and breadcrumbs, so what it sets
|
|
105
|
+
# is not seen by other threads. The Rack middleware does this per request.
|
|
106
|
+
def with_scope(&block)
|
|
107
|
+
return yield(nil) unless @client
|
|
108
|
+
|
|
109
|
+
@client.with_scope(&block)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# ---- logs, metrics, traces ----
|
|
113
|
+
|
|
114
|
+
# `NaijaCloud::Argus.log.info("order placed", order_id: "ord_81")`
|
|
115
|
+
def log
|
|
116
|
+
LOG
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# `NaijaCloud::Argus.metrics.count("orders.placed", 1, method: "card")`
|
|
120
|
+
def metrics
|
|
121
|
+
METRICS
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Runs the block inside a span and yields it. The block's return value is
|
|
125
|
+
# returned; its exception marks the span failed and is re-raised.
|
|
126
|
+
# Without a DSN the block still runs, with a span that records nothing.
|
|
127
|
+
def trace(name, attributes = nil, &block)
|
|
128
|
+
client = @client
|
|
129
|
+
return yield(Span.new(nil, name)) if client.nil?
|
|
130
|
+
|
|
131
|
+
client.trace(name, attributes, &block)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Starts a span you finish yourself (`span.finish`).
|
|
135
|
+
def start_span(name, attributes = nil, kind = Span::KIND_INTERNAL)
|
|
136
|
+
safely(nil) { @client&.start_span(name, attributes: attributes, kind: kind) } || Span.new(nil, name)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The current span, if any.
|
|
140
|
+
def current_span
|
|
141
|
+
Span.current
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The W3C traceparent for the current span, for propagating a trace
|
|
145
|
+
# through something the SDK does not instrument (a queue, a gRPC call).
|
|
146
|
+
def traceparent
|
|
147
|
+
Span.current&.traceparent
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# ---- crons and flags ----
|
|
151
|
+
|
|
152
|
+
# `NaijaCloud::Argus.cron.monitor("nightly-invoices") { send_invoices }`
|
|
153
|
+
def cron
|
|
154
|
+
CRON
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# `NaijaCloud::Argus.flags.on?("new-checkout", user.id)`
|
|
158
|
+
def flags
|
|
159
|
+
FLAGS
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Shorthand for `cron.monitor(slug) { ... }`.
|
|
163
|
+
def monitor(slug, &block)
|
|
164
|
+
CRON.monitor(slug, &block)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# ---- lifecycle ----
|
|
168
|
+
|
|
169
|
+
# Sends what's queued. True when it all went out within timeout seconds.
|
|
170
|
+
def flush(timeout = 2.0)
|
|
171
|
+
safely(true) { @client ? @client.flush(timeout) : true }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Flushes and stops the SDK. `init` can be called again afterwards.
|
|
175
|
+
def close(timeout = 2.0)
|
|
176
|
+
c = @mutex.synchronize do
|
|
177
|
+
old = @client
|
|
178
|
+
@client = nil
|
|
179
|
+
old
|
|
180
|
+
end
|
|
181
|
+
safely(true) { c ? c.close(timeout) : true }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Runs SDK code so that nothing it does can raise into the app.
|
|
185
|
+
def safely(fallback = nil)
|
|
186
|
+
yield
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
if @client&.config&.debug
|
|
189
|
+
begin
|
|
190
|
+
@client.config.debug_output&.puts("[argus] #{e.class}: #{e.message}")
|
|
191
|
+
rescue StandardError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
fallback
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def install(client)
|
|
201
|
+
return unless client.enabled?
|
|
202
|
+
|
|
203
|
+
install_at_exit
|
|
204
|
+
if client.config.net_http
|
|
205
|
+
require_relative "argus/integrations/net_http"
|
|
206
|
+
Integrations::NetHTTP.install
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# A script that dies of an uncaught exception reports it, and every
|
|
211
|
+
# process sends what is still queued (spec §6: flush on exit).
|
|
212
|
+
def install_at_exit
|
|
213
|
+
return if @at_exit_installed
|
|
214
|
+
|
|
215
|
+
@at_exit_installed = true
|
|
216
|
+
at_exit do
|
|
217
|
+
c = @client
|
|
218
|
+
if c
|
|
219
|
+
err = $!
|
|
220
|
+
if c.config.capture_at_exit && err && !err.is_a?(SystemExit) && !err.is_a?(SignalException)
|
|
221
|
+
safely { c.capture_exception(err, level: :fatal, tags: { mechanism: "at_exit" }) }
|
|
222
|
+
end
|
|
223
|
+
safely { c.flush(2.0) }
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# `NaijaCloud::Argus.log`: logs to Argus Logs. Attributes are a Hash.
|
|
230
|
+
module LogAPI
|
|
231
|
+
extend self
|
|
232
|
+
|
|
233
|
+
%i[trace debug info warn error fatal].each do |level|
|
|
234
|
+
define_method(level) do |message, attributes = nil|
|
|
235
|
+
Argus.safely { Argus.client&.log(level, message, attributes) }
|
|
236
|
+
nil
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
LOG = LogAPI
|
|
241
|
+
|
|
242
|
+
# `NaijaCloud::Argus.metrics`: aggregated in process, sent every 10 s.
|
|
243
|
+
module MetricsAPI
|
|
244
|
+
extend self
|
|
245
|
+
|
|
246
|
+
# Adds to a counter (default 1).
|
|
247
|
+
def count(name, value = 1, attributes = nil)
|
|
248
|
+
Argus.safely { Argus.client&.count(name, value, attributes) }
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Sets a gauge to its latest value.
|
|
253
|
+
def gauge(name, value, attributes = nil, unit = nil)
|
|
254
|
+
Argus.safely { Argus.client&.gauge(name, value, attributes, unit) }
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Records a value into a histogram (durations, sizes).
|
|
259
|
+
def distribution(name, value, attributes = nil, unit = nil)
|
|
260
|
+
Argus.safely { Argus.client&.distribution(name, value, attributes, unit) }
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
METRICS = MetricsAPI
|
|
265
|
+
|
|
266
|
+
# `NaijaCloud::Argus.cron`: check-ins by the monitor's slug. Create the
|
|
267
|
+
# monitor in Argus first; an unknown slug is answered 404 and dropped.
|
|
268
|
+
module CronAPI
|
|
269
|
+
extend self
|
|
270
|
+
|
|
271
|
+
# status: :start, :ok, :error, or an exit code.
|
|
272
|
+
def check_in(slug, status = :ok, output = nil)
|
|
273
|
+
Argus.safely { Argus.client&.check_in(slug, status, output) }
|
|
274
|
+
nil
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Reports the start, then success or failure, and re-raises the block's
|
|
278
|
+
# exception. Without a DSN the block just runs.
|
|
279
|
+
def monitor(slug)
|
|
280
|
+
client = Argus.client
|
|
281
|
+
return yield if client.nil? || !client.enabled?
|
|
282
|
+
|
|
283
|
+
Argus.safely { client.check_in(slug, :start) }
|
|
284
|
+
begin
|
|
285
|
+
result = yield
|
|
286
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised
|
|
287
|
+
Argus.safely { client.check_in(slug, :error, "#{e.class}: #{e.message}") }
|
|
288
|
+
raise
|
|
289
|
+
end
|
|
290
|
+
Argus.safely { client.check_in(slug, :ok) }
|
|
291
|
+
result
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
CRON = CronAPI
|
|
295
|
+
|
|
296
|
+
# `NaijaCloud::Argus.flags`: evaluated by Argus for a subject (usually a
|
|
297
|
+
# user id). Answers are cached 30 s; on any failure you get the fallback.
|
|
298
|
+
module FlagsAPI
|
|
299
|
+
extend self
|
|
300
|
+
|
|
301
|
+
def on?(key, subject, fallback = false, attributes = nil)
|
|
302
|
+
result = Argus.safely(nil) { Argus.client&.on?(key, subject, fallback, attributes) }
|
|
303
|
+
result.nil? ? fallback : result
|
|
304
|
+
end
|
|
305
|
+
alias enabled? on?
|
|
306
|
+
|
|
307
|
+
def variant(key, subject, fallback = nil, attributes = nil)
|
|
308
|
+
result = Argus.safely(nil) { Argus.client&.variant(key, subject, fallback, attributes) }
|
|
309
|
+
result.nil? ? fallback : result
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def all(subject, attributes = nil)
|
|
313
|
+
Argus.safely({}) { Argus.client ? Argus.client.evaluate_flags(subject, attributes) : {} }
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
FLAGS = FlagsAPI
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
require_relative "argus/integrations/rails" if defined?(::Rails::Railtie)
|