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