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,647 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "socket"
6
+ require "stringio"
7
+ require "time"
8
+ require "zlib"
9
+
10
+ module NaijaCloud
11
+ module Argus
12
+ # Does the work behind the module-level API. One per process, made by
13
+ # `NaijaCloud::Argus.init`. Without a DSN it is disabled and every method
14
+ # returns immediately.
15
+ class Client
16
+ LEVELS = %w[fatal error warning info debug].freeze
17
+ LOG_SEVERITIES = %i[trace debug info warn error fatal].freeze
18
+
19
+ FLUSH_LOGS = 5
20
+ FLUSH_SPANS = 5
21
+ FLUSH_METRICS = 10
22
+ MAX_LOGS = 1000
23
+ MAX_SPANS = 512
24
+ MAX_SERIES = 2000
25
+ MAX_TAGS = 50
26
+ MAX_LOG_BODY = 16_384
27
+ MAX_CRON_OUTPUT = 2048
28
+ FLAG_CACHE_TTL = 30
29
+ # Request bodies stay under the transport's 1 MiB queue bound (spec §6),
30
+ # which is also under every endpoint's body limit (spec §3).
31
+ MAX_BODY = 1_000_000
32
+ HIST_BOUNDS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10_000].freeze
33
+
34
+ attr_reader :config, :dsn, :transport, :global_scope
35
+
36
+ def initialize(config)
37
+ @config = config
38
+ @dsn = config.enabled == false ? nil : Dsn.parse(config.dsn)
39
+ debug("the DSN is not valid; nothing will be sent") if config.dsn && @dsn.nil?
40
+ @transport = Transport.new(debug: method(:debug))
41
+ @stack = Stack.new(project_root: config.project_root, context_lines: config.context_lines)
42
+ @global_scope = Scope.new(config.max_breadcrumbs.to_i)
43
+ @server_name = config.server_name || hostname
44
+ @mutex = Mutex.new
45
+ @log_buf = []
46
+ @span_buf = []
47
+ @metrics = {}
48
+ @metric_start = Time.now.to_f
49
+ @flag_cache = {}
50
+ @closed = false
51
+ @pid = Process.pid
52
+ @next = { logs: now + FLUSH_LOGS, spans: now + FLUSH_SPANS, metrics: now + FLUSH_METRICS }
53
+ @ticker = nil
54
+ @tick_cv = ConditionVariable.new
55
+ start_ticker if enabled?
56
+ end
57
+
58
+ # Whether a DSN was given and the SDK will send anything.
59
+ def enabled?
60
+ !@dsn.nil? && !@closed
61
+ end
62
+
63
+ # ---- scope ----
64
+
65
+ def current_scope
66
+ Scope.current || @global_scope
67
+ end
68
+
69
+ def set_user(user)
70
+ current_scope.user = user.nil? ? nil : symbolize(user)
71
+ end
72
+
73
+ def set_tag(key, value)
74
+ current_scope.set_tag(key, value)
75
+ end
76
+
77
+ def set_tags(tags)
78
+ tags.each { |k, v| set_tag(k, v) }
79
+ end
80
+
81
+ def set_context(name, ctx)
82
+ current_scope.set_context(name, ctx)
83
+ end
84
+
85
+ def add_breadcrumb(crumb)
86
+ c = { timestamp: Time.now.to_f }.merge(symbolize(crumb))
87
+ c[:level] = normalize_level(c[:level]) if c[:level]
88
+ if @config.before_breadcrumb
89
+ begin
90
+ c = @config.before_breadcrumb.call(c)
91
+ rescue StandardError
92
+ nil # keep the crumb
93
+ end
94
+ end
95
+ current_scope.add_breadcrumb(c) if c
96
+ end
97
+
98
+ # Runs the block with a fresh scope (the Rack middleware does this per
99
+ # request), so what it sets does not leak to other requests.
100
+ def with_scope(&block)
101
+ Scope.with(Scope.new(@config.max_breadcrumbs.to_i), &block)
102
+ end
103
+
104
+ # ---- errors ----
105
+
106
+ # Sends an exception. Returns its event id, or nil when nothing was sent.
107
+ def capture_exception(exception, **ctx)
108
+ return nil unless enabled?
109
+ return nil if excluded?(exception)
110
+ return nil if already_captured?(exception)
111
+
112
+ send_event({ exception: { values: exception_chain(exception) } }, ctx)
113
+ end
114
+
115
+ def capture_message(message, level: :info, **ctx)
116
+ return nil unless enabled?
117
+
118
+ send_event({ message: message.to_s }, ctx.merge(level: level))
119
+ end
120
+
121
+ # ---- logs ----
122
+
123
+ def log(severity, body, attributes = nil)
124
+ return unless enabled?
125
+
126
+ severity = severity.to_sym
127
+ severity = :warn if severity == :warning
128
+ return unless LOG_SEVERITIES.include?(severity)
129
+
130
+ span = Span.current
131
+ rec = {
132
+ time: Time.now.to_f, severity: severity,
133
+ body: Scrub.scrub_string(body.to_s)[0, MAX_LOG_BODY],
134
+ attributes: attributes && Scrub.scrub(attributes),
135
+ trace_id: span&.trace_id, span_id: span&.span_id,
136
+ }
137
+ full = @mutex.synchronize do
138
+ check_fork
139
+ @log_buf << rec
140
+ @log_buf.length >= MAX_LOGS
141
+ end
142
+ flush_logs if full
143
+ end
144
+
145
+ # ---- metrics ----
146
+
147
+ def count(name, value = 1, attributes = nil)
148
+ metric(:sum, name, value, attributes, nil)
149
+ end
150
+
151
+ def gauge(name, value, attributes = nil, unit = nil)
152
+ metric(:gauge, name, value, attributes, unit)
153
+ end
154
+
155
+ def distribution(name, value, attributes = nil, unit = nil)
156
+ metric(:histogram, name, value, attributes, unit)
157
+ end
158
+
159
+ # ---- traces ----
160
+
161
+ def sample_trace?
162
+ enabled? && rand < @config.traces_sample_rate.to_f
163
+ end
164
+
165
+ # Starts a span under the current one (or a new trace). Finish it yourself.
166
+ def start_span(name, kind: Span::KIND_INTERNAL, attributes: nil, parent: nil)
167
+ parent ||= (cur = Span.current) && { trace_id: cur.trace_id, span_id: cur.span_id, sampled: cur.sampled? }
168
+ Span.new(enabled? ? self : nil, name, kind: kind, parent: parent, attributes: attributes)
169
+ end
170
+
171
+ # Runs the block inside a span that ends when the block does. An
172
+ # exception from the block marks the span failed and is re-raised.
173
+ # Positional, not keyword, options: on Ruby 3 a braceless attributes
174
+ # hash (`trace("x", "job.kind" => "nightly")`) would otherwise be taken
175
+ # for keywords and raise.
176
+ def trace(name, attributes = nil, kind = Span::KIND_INTERNAL, parent = nil)
177
+ span = start_span(name, kind: kind, attributes: attributes, parent: parent)
178
+ begin
179
+ result = Span.with(span) { yield span }
180
+ rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised
181
+ span.finish(e)
182
+ raise
183
+ end
184
+ span.finish
185
+ result
186
+ end
187
+
188
+ def record_span(data)
189
+ return unless enabled?
190
+
191
+ data[:attributes] = Scrub.scrub(data[:attributes]) if data[:attributes]
192
+ full = @mutex.synchronize do
193
+ check_fork
194
+ @span_buf << data
195
+ @span_buf.length >= MAX_SPANS
196
+ end
197
+ flush_spans if full
198
+ end
199
+
200
+ # ---- crons ----
201
+
202
+ # Reports a run to the cron monitor with this slug: :start, :ok,
203
+ # :error, or an exit code.
204
+ def check_in(slug, status = :ok, output = nil)
205
+ return unless enabled?
206
+
207
+ suffix = case status
208
+ when :start, "start" then "/start"
209
+ when :ok, "ok", nil then ""
210
+ when :error, :fail, "error", "fail" then "/fail"
211
+ when Integer then "/#{status}"
212
+ else raise ArgumentError, "unknown check-in status #{status.inspect}"
213
+ end
214
+ body = output ? Scrub.scrub_string(output.to_s)[0, MAX_CRON_OUTPUT] : ""
215
+ enqueue("crons", "/v1/cron/#{escape(slug)}#{suffix}", body, "text/plain; charset=utf-8")
216
+ end
217
+
218
+ # Runs the block and reports it: start, then ok or fail. Re-raises.
219
+ def monitor(slug)
220
+ check_in(slug, :start)
221
+ begin
222
+ result = yield
223
+ rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised
224
+ check_in(slug, :error, "#{e.class}: #{e.message}")
225
+ raise
226
+ end
227
+ check_in(slug, :ok)
228
+ result
229
+ end
230
+
231
+ # ---- flags ----
232
+
233
+ # Every flag's variant for a subject (usually a user id), cached for 30 s.
234
+ # Waits at most 2 s for Argus; on any failure answers from the cache, or {}.
235
+ def evaluate_flags(subject, attributes = nil)
236
+ return {} unless enabled? && !subject.to_s.empty?
237
+
238
+ attributes = (attributes || {}).each_with_object({}) { |(k, v), h| h[k.to_s] = v.to_s }
239
+ key = "#{subject}|#{JSON.generate(attributes.sort)}"
240
+ hit = @mutex.synchronize { @flag_cache[key] }
241
+ return hit[:flags] if hit && now - hit[:at] < FLAG_CACHE_TTL
242
+ return hit ? hit[:flags] : {} if @transport.dead?("flags") || @transport.blocked?("flags")
243
+
244
+ flags = fetch_flags(subject.to_s, attributes)
245
+ return hit ? hit[:flags] : {} if flags.nil?
246
+
247
+ @mutex.synchronize do
248
+ @flag_cache.clear if @flag_cache.size > 1000
249
+ @flag_cache[key] = { at: now, flags: flags }
250
+ end
251
+ flags
252
+ end
253
+
254
+ def variant(key, subject, fallback, attributes = nil)
255
+ evaluate_flags(subject, attributes).fetch(key.to_s, fallback)
256
+ end
257
+
258
+ def on?(key, subject, fallback = false, attributes = nil)
259
+ v = variant(key, subject, fallback ? "on" : "off", attributes)
260
+ %w[on true].include?(v.to_s)
261
+ end
262
+
263
+ # ---- sending ----
264
+
265
+ def flush_logs
266
+ batch = @mutex.synchronize { @log_buf.slice!(0, MAX_LOGS) }
267
+ send_otlp("logs", "/v1/logs", batch) { |chunk| OTLP.logs(resource, chunk) }
268
+ end
269
+
270
+ def flush_spans
271
+ batch = @mutex.synchronize { @span_buf.slice!(0, MAX_SPANS) }
272
+ send_otlp("traces", "/v1/traces", batch) { |chunk| OTLP.traces(resource, chunk) }
273
+ end
274
+
275
+ def flush_metrics
276
+ points = @mutex.synchronize do
277
+ stop = Time.now.to_f
278
+ start = @metric_start
279
+ @metric_start = stop
280
+ aggs = @metrics.values
281
+ @metrics = {}
282
+ aggs.map { |m| metric_point(m, start, stop) }
283
+ end
284
+ send_otlp("metrics", "/v1/metrics", points) { |chunk| OTLP.metrics(resource, chunk) }
285
+ end
286
+
287
+ # Sends everything queued. True when it all went out within timeout seconds.
288
+ def flush(timeout = 2.0)
289
+ return true unless @dsn
290
+
291
+ flush_logs
292
+ flush_spans
293
+ flush_metrics
294
+ @transport.flush(timeout)
295
+ end
296
+
297
+ # Flushes, then stops sending.
298
+ def close(timeout = 2.0)
299
+ ok = flush(timeout)
300
+ @closed = true
301
+ @mutex.synchronize { @tick_cv.broadcast }
302
+ @transport.close
303
+ ok
304
+ end
305
+
306
+ def inspect
307
+ "#<NaijaCloud::Argus::Client #{@dsn ? @dsn.inspect : 'disabled'}>"
308
+ end
309
+
310
+ private
311
+
312
+ def send_event(partial, ctx)
313
+ return nil if rand >= @config.sample_rate.to_f
314
+
315
+ event = build_event(partial, ctx)
316
+ if @config.before_send
317
+ begin
318
+ event = @config.before_send.call(event)
319
+ rescue StandardError => e
320
+ debug("before_send raised #{e.class}; sending the event unchanged")
321
+ end
322
+ return nil unless event
323
+ end
324
+ event = symbolize(event)
325
+ sdk = event.delete(:sdk) || { name: SDK_NAME, version: VERSION }
326
+ clean = Scrub.scrub(event).merge("sdk" => sdk)
327
+ clean["event_id"] = SecureRandom.hex(16) unless clean["event_id"].to_s =~ /\A\h{32}\z/
328
+ envelope = [
329
+ JSON.generate(event_id: clean["event_id"], sent_at: Time.now.utc.iso8601(3), dsn: @dsn.raw, sdk: sdk),
330
+ JSON.generate(type: "event", content_type: "application/json"),
331
+ JSON.generate(clean),
332
+ ].join("\n") << "\n"
333
+ @transport.enqueue("errors", Transport::Request.new(
334
+ @dsn.envelope_url, gzip(envelope),
335
+ {
336
+ "Content-Type" => "application/x-sentry-envelope",
337
+ "Content-Encoding" => "gzip",
338
+ "X-Sentry-Auth" => "Sentry sentry_version=7, sentry_client=#{SDK_NAME}/#{VERSION}, sentry_key=#{@dsn.public_key}",
339
+ },
340
+ ))
341
+ clean["event_id"]
342
+ end
343
+
344
+ def build_event(partial, ctx)
345
+ scope = current_scope
346
+ global = @global_scope.snapshot
347
+ local = scope.equal?(@global_scope) ? nil : scope.snapshot
348
+ span = Span.current
349
+ tags = global[:tags].merge(local ? local[:tags] : {}).merge(stringify(ctx[:tags] || {}))
350
+ crumbs = global[:breadcrumbs] + (local ? local[:breadcrumbs] : [])
351
+ user = ctx[:user] ? symbolize(ctx[:user]) : (local && local[:user]) || global[:user]
352
+ user = user.reject { |k, _| k == :ip_address } if user && !@config.send_default_pii
353
+ contexts = default_contexts.merge(global[:contexts]).merge(local ? local[:contexts] : {}).merge(stringify(ctx[:contexts] || {}))
354
+ contexts["trace"] = { trace_id: span.trace_id, span_id: span.span_id } if span
355
+ transaction = ctx[:transaction] || (local && local[:transaction]) || span&.name
356
+
357
+ event = {
358
+ event_id: SecureRandom.hex(16),
359
+ timestamp: Time.now.to_f,
360
+ platform: "ruby",
361
+ level: normalize_level(ctx[:level] || :error),
362
+ release: @config.release && @config.release[0, 200],
363
+ environment: @config.environment.to_s[0, 64],
364
+ server_name: @server_name && @server_name[0, 200],
365
+ transaction: transaction && transaction.to_s[0, 300],
366
+ tags: tags.first(MAX_TAGS).to_h,
367
+ user: user,
368
+ breadcrumbs: { values: crumbs.last(@config.max_breadcrumbs.to_i) },
369
+ contexts: contexts,
370
+ extra: ctx[:extra],
371
+ request: ctx[:request] || (local && local[:request]),
372
+ fingerprint: ctx[:fingerprint]&.map(&:to_s),
373
+ sdk: { name: SDK_NAME, version: VERSION },
374
+ }.merge(partial)
375
+ event.reject { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
376
+ end
377
+
378
+ def default_contexts
379
+ {
380
+ "runtime" => { name: "ruby", version: RUBY_VERSION, engine: RUBY_ENGINE },
381
+ "os" => { name: RbConfig::CONFIG["host_os"] },
382
+ }
383
+ end
384
+
385
+ # The exception and its causes, oldest (deepest cause) first, as
386
+ # Argus expects: the last entry is the error itself.
387
+ def exception_chain(exception)
388
+ unless exception.is_a?(Exception)
389
+ return [{ type: "Error", value: "Non-exception captured: #{safe_inspect(exception)}"[0, 1000] }]
390
+ end
391
+
392
+ out = []
393
+ seen = {}
394
+ cur = exception
395
+ 5.times do
396
+ break unless cur.is_a?(Exception) && !seen[cur.object_id]
397
+
398
+ seen[cur.object_id] = true
399
+ frames = @stack.frames(cur)
400
+ entry = { type: cur.class.name || cur.class.to_s, value: safe_message(cur)[0, 8192] }
401
+ entry[:stacktrace] = { frames: frames } unless frames.empty?
402
+ out.unshift(entry)
403
+ cur = cur.cause
404
+ end
405
+ out
406
+ end
407
+
408
+ def excluded?(exception)
409
+ return false unless exception.is_a?(Exception)
410
+
411
+ names = exception.class.ancestors.map(&:name)
412
+ Array(@config.excluded_exceptions).any? { |ex| names.include?(ex.is_a?(Module) ? ex.name : ex.to_s) }
413
+ end
414
+
415
+ # The same exception object is reported once, however many layers (Rack,
416
+ # Rails.error, ActiveJob, at_exit) see it.
417
+ def already_captured?(exception)
418
+ return false unless exception.is_a?(Exception)
419
+ return true if exception.instance_variable_defined?(:@__naijacloud_argus_captured)
420
+
421
+ exception.instance_variable_set(:@__naijacloud_argus_captured, true)
422
+ false
423
+ rescue StandardError # frozen exceptions
424
+ false
425
+ end
426
+
427
+ def metric(kind, name, value, attributes, unit)
428
+ return unless enabled?
429
+ return unless value.is_a?(Numeric) && value.to_f.finite?
430
+
431
+ attributes = attributes ? stringify(attributes) : {}
432
+ key = "#{kind}|#{name}|#{JSON.generate(attributes.sort)}"
433
+ @mutex.synchronize do
434
+ check_fork
435
+ m = @metrics[key]
436
+ unless m
437
+ return if @metrics.size >= MAX_SERIES
438
+
439
+ m = @metrics[key] = { kind: kind, name: name.to_s, unit: unit, attributes: attributes, value: 0.0,
440
+ count: 0, sum: 0.0, min: Float::INFINITY, max: -Float::INFINITY,
441
+ buckets: Array.new(HIST_BOUNDS.length + 1, 0) }
442
+ end
443
+ case kind
444
+ when :sum then m[:value] += value
445
+ when :gauge then m[:value] = value
446
+ else
447
+ m[:count] += 1
448
+ m[:sum] += value
449
+ m[:min] = value if value < m[:min]
450
+ m[:max] = value if value > m[:max]
451
+ m[:buckets][HIST_BOUNDS.index { |b| value <= b } || HIST_BOUNDS.length] += 1
452
+ end
453
+ end
454
+ end
455
+
456
+ def metric_point(m, start, stop)
457
+ base = { kind: m[:kind], name: m[:name], unit: m[:unit], attributes: m[:attributes], start: start, end: stop }
458
+ case m[:kind]
459
+ when :sum, :gauge then base.merge(value: m[:value])
460
+ else base.merge(count: m[:count], sum: m[:sum], min: m[:min], max: m[:max], bounds: HIST_BOUNDS, buckets: m[:buckets])
461
+ end
462
+ end
463
+
464
+ def resource
465
+ {
466
+ "service.name" => @config.service_name,
467
+ "service.version" => @config.release,
468
+ "deployment.environment" => @config.environment,
469
+ "host.name" => @server_name,
470
+ "telemetry.sdk.name" => SDK_NAME,
471
+ "telemetry.sdk.version" => VERSION,
472
+ "telemetry.sdk.language" => "ruby",
473
+ "argus.contract" => CONTRACT,
474
+ }
475
+ end
476
+
477
+ # Splits a batch in half until each request fits under MAX_BODY.
478
+ def send_otlp(lane, path, items, &build)
479
+ return if items.empty? || @dsn.nil?
480
+
481
+ body = JSON.generate(build.call(items))
482
+ if body.bytesize > MAX_BODY
483
+ if items.length == 1
484
+ debug("#{lane}: one record over #{MAX_BODY} bytes, dropped")
485
+ else
486
+ half = items.length / 2
487
+ send_otlp(lane, path, items[0, half], &build)
488
+ send_otlp(lane, path, items[half..-1], &build)
489
+ end
490
+ return
491
+ end
492
+ enqueue(lane, path, body, "application/json")
493
+ rescue StandardError => e
494
+ debug("#{lane}: could not encode (#{e.class}: #{e.message})")
495
+ end
496
+
497
+ def enqueue(lane, path, body, content_type)
498
+ @transport.enqueue(lane, Transport::Request.new(
499
+ @dsn.ingest_url(path), body,
500
+ { "Content-Type" => content_type, "Authorization" => "Bearer #{@dsn.public_key}" },
501
+ ))
502
+ end
503
+
504
+ def fetch_flags(subject, attributes)
505
+ url = @dsn.ingest_url("/v1/flags/evaluate")
506
+ body = JSON.generate(subject: subject, environment: @config.environment, attributes: attributes)
507
+ headers = { "Content-Type" => "application/json", "Authorization" => "Bearer #{@dsn.public_key}" }
508
+ res = @transport.post(url, body, headers, read_timeout: 2, open_timeout: 2)
509
+ observe("flags", url, res)
510
+ code = res.code.to_i
511
+ if [401, 403].include?(code)
512
+ debug("flags: #{code}, the key was refused; stopping flag evaluation until restart")
513
+ @transport.mark_dead("flags")
514
+ return nil
515
+ end
516
+ if code == 429
517
+ @transport.block("flags", Transport.retry_after(res))
518
+ return nil
519
+ end
520
+ return nil unless code >= 200 && code < 300
521
+
522
+ parsed = JSON.parse(res.body.to_s)
523
+ flags = parsed.is_a?(Hash) && parsed["flags"].is_a?(Hash) ? parsed["flags"] : {}
524
+ flags.transform_values(&:to_s)
525
+ rescue StandardError => e
526
+ debug("flags: #{e.class}: #{e.message}")
527
+ observe("flags", url, nil) if url
528
+ nil
529
+ end
530
+
531
+ def observe(lane, url, res)
532
+ obs = @transport.observer
533
+ return unless obs
534
+
535
+ obs.call(Transport::Response.new(lane, url, res ? res.code.to_i : 0, {}))
536
+ rescue StandardError
537
+ nil
538
+ end
539
+
540
+ # One background thread wakes once a second and sends whatever batch is
541
+ # due. It never runs app code and never raises.
542
+ def start_ticker
543
+ @ticker = Thread.new do
544
+ Thread.current[:naijacloud_argus_internal] = true
545
+ until @closed
546
+ @mutex.synchronize { @tick_cv.wait(@mutex, 1) unless @closed }
547
+ break if @closed
548
+
549
+ tick
550
+ end
551
+ end
552
+ @ticker.name = "argus-flush" if @ticker.respond_to?(:name=)
553
+ @ticker.report_on_exception = false
554
+ end
555
+
556
+ def tick
557
+ t = now
558
+ if t >= @next[:logs]
559
+ @next[:logs] = t + FLUSH_LOGS
560
+ flush_logs
561
+ end
562
+ if t >= @next[:spans]
563
+ @next[:spans] = t + FLUSH_SPANS
564
+ flush_spans
565
+ end
566
+ if t >= @next[:metrics]
567
+ @next[:metrics] = t + FLUSH_METRICS
568
+ flush_metrics
569
+ end
570
+ rescue StandardError => e
571
+ debug("flush: #{e.class}: #{e.message}")
572
+ end
573
+
574
+ # Called with @mutex held. After a fork the ticker thread is gone and
575
+ # the buffers hold the parent's records, which the parent will send.
576
+ def check_fork
577
+ return if @pid == Process.pid
578
+
579
+ @pid = Process.pid
580
+ @log_buf.clear
581
+ @span_buf.clear
582
+ @metrics.clear
583
+ @metric_start = Time.now.to_f
584
+ @tick_cv = ConditionVariable.new
585
+ start_ticker unless @closed
586
+ end
587
+
588
+ def normalize_level(level)
589
+ l = level.to_s.downcase
590
+ l = "warning" if l == "warn"
591
+ LEVELS.include?(l) ? l : "error"
592
+ end
593
+
594
+ def symbolize(hash)
595
+ return hash unless hash.is_a?(Hash)
596
+
597
+ hash.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
598
+ end
599
+
600
+ def stringify(hash)
601
+ hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v.is_a?(Symbol) ? v.to_s : v }
602
+ end
603
+
604
+ def escape(slug)
605
+ slug.to_s.b.gsub(/[^a-zA-Z0-9_.~-]/) { |c| format("%%%02X", c.ord) }
606
+ end
607
+
608
+ def gzip(str)
609
+ io = StringIO.new(+"")
610
+ gz = Zlib::GzipWriter.new(io)
611
+ gz.write(str)
612
+ gz.close
613
+ io.string
614
+ end
615
+
616
+ def hostname
617
+ Socket.gethostname
618
+ rescue StandardError
619
+ nil
620
+ end
621
+
622
+ def safe_message(exception)
623
+ exception.message.to_s
624
+ rescue StandardError
625
+ exception.class.to_s
626
+ end
627
+
628
+ def safe_inspect(value)
629
+ value.inspect
630
+ rescue StandardError
631
+ value.class.to_s
632
+ end
633
+
634
+ def now
635
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
636
+ end
637
+
638
+ def debug(msg)
639
+ return unless @config.debug
640
+
641
+ @config.debug_output&.puts("[argus] #{msg}")
642
+ rescue StandardError
643
+ nil
644
+ end
645
+ end
646
+ end
647
+ end