sillydev 1.0.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,674 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "stringio"
7
+ require "uri"
8
+ require "zlib"
9
+ require_relative "aggregator"
10
+ require_relative "runtime"
11
+ require_relative "serialize"
12
+
13
+ module SillyDev
14
+ module Monitoring
15
+ # One configured monitoring client: buffers, aggregates and sends to a Feathers ingest node.
16
+ # Use the functions on SillyDev::Monitoring instead.
17
+ #
18
+ # Nothing here runs on require: a client only starts its thread when Monitoring.init makes one
19
+ # with a key. Its public methods never raise into application code.
20
+ class Client
21
+ MAX_ITEMS_PER_REQUEST = 500
22
+ MAX_SLOW_PER_REQUEST = 10
23
+ MAX_SLOW_PER_WINDOW = 10
24
+ MAX_RAW_BYTES = 1000 * 1024 # under Feathers' 1 MiB decompressed limit
25
+ MAX_SENT_BYTES = 250 * 1024 # under Feathers' 256 KiB as-sent limit
26
+ GZIP_MIN_BYTES = 1024
27
+ REQUEST_TIMEOUT = 5.0
28
+ STALE_AFTER = 55 * 60 # Feathers drops items over an hour old
29
+ BACKOFF_BASE = 1.0
30
+ BACKOFF_CAP = 300.0
31
+
32
+ Pending = Struct.new(:array, :item, :size) # rubocop:disable Lint/StructNewOverride
33
+
34
+ attr_reader :key, :endpoint, :service, :environment, :release, :instance, :sample_rate,
35
+ :flush_interval, :max_buffer_bytes, :slow_threshold_ms, :dropped
36
+ attr_accessor :clock, :monotonic_clock, :random, :transport
37
+
38
+ def self.runtime_name
39
+ engine = defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby"
40
+ version = if engine == "ruby"
41
+ RUBY_VERSION
42
+ else
43
+ (defined?(RUBY_ENGINE_VERSION) ? RUBY_ENGINE_VERSION : RUBY_VERSION)
44
+ end
45
+ "#{engine} #{version}"
46
+ end
47
+
48
+ def initialize(key = nil, endpoint: nil, service: nil, environment: nil, release: nil, runtime_metrics: true,
49
+ capture_unhandled: true, sample_rate: 1.0, before_send: nil, flush_interval: 10.0,
50
+ max_buffer_bytes: 1_000_000, slow_threshold_ms: 1000, logger: nil, debug: false, active: true)
51
+ @key = key
52
+ @endpoint = endpoint.to_s.chomp("/")
53
+ @service = (service || "default").to_s.downcase[0, 64]
54
+ @environment = (environment || "production").to_s.downcase[0, 64]
55
+ @release = release ? release.to_s[0, 64] : nil
56
+ @runtime_metrics = runtime_metrics
57
+ @capture_unhandled = capture_unhandled
58
+ @sample_rate = sample_rate.to_f.clamp(0.0, 1.0)
59
+ @before_send = before_send
60
+ @flush_interval = [flush_interval.to_f, 0.05].max
61
+ @max_buffer_bytes = [max_buffer_bytes.to_i, 1024].max
62
+ @slow_threshold_ms = slow_threshold_ms.to_f
63
+ @logger = logger
64
+ @debug = debug
65
+ @instance = SecureRandom.hex(4)
66
+ @active = active && key && !@endpoint.empty? ? true : false
67
+
68
+ # Replaceable in tests.
69
+ @clock = -> { Time.now.to_f }
70
+ @monotonic_clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
71
+ @random = -> { rand }
72
+ @transport = nil # ->(url, body, headers, timeout) { [status, headers, body] }
73
+
74
+ reset_state
75
+ @logged = {}
76
+ @stopped = false # by a 401/403: never sends again
77
+ @closed = false
78
+ end
79
+
80
+ def inspect
81
+ "#<#{self.class.name} endpoint=#{@endpoint.inspect} service=#{@service.inspect} enabled=#{enabled?}>"
82
+ end
83
+
84
+ def runtime_metrics?
85
+ @runtime_metrics
86
+ end
87
+
88
+ def capture_unhandled?
89
+ @capture_unhandled
90
+ end
91
+
92
+ def active?
93
+ @active
94
+ end
95
+
96
+ # True while the client has a key, has not been stopped by the ingest and is not shut down.
97
+ def enabled?
98
+ @active && !@stopped && !@closed
99
+ end
100
+
101
+ # Starts the thread (called by Monitoring.init).
102
+ def start
103
+ return if !@active || @closed
104
+
105
+ @runtime = RuntimeCollector.new if @runtime_metrics
106
+ start_thread
107
+ debug_log("sending to #{@endpoint} as service #{@service.inspect}, environment " \
108
+ "#{@environment.inspect}, instance #{@instance}")
109
+ end
110
+
111
+ def shutdown(timeout = 2.0)
112
+ return if @closed
113
+
114
+ deadline = now_mono + [timeout.to_f, 0.0].max
115
+ @stop = true
116
+ wake
117
+ flush_until(deadline) if @active && !@stopped
118
+ @closed = true
119
+ thread = @thread
120
+ thread.join((deadline - now_mono).clamp(0, 0.5)) if thread && thread != Thread.current
121
+ nil
122
+ rescue StandardError
123
+ nil
124
+ end
125
+
126
+ # Resets everything that belongs to the parent process and starts again (after fork).
127
+ def after_fork
128
+ reset_state
129
+ @instance = SecureRandom.hex(4)
130
+ @runtime&.reset
131
+ start_thread if enabled?
132
+ end
133
+
134
+ # -- recording ------------------------------------------------------------------------------
135
+
136
+ def increment(name, value = 1, tags = nil)
137
+ metric("counter", name, value, tags)
138
+ end
139
+
140
+ def gauge(name, value, tags = nil)
141
+ metric("gauge", name, value, tags)
142
+ end
143
+
144
+ def timing(name, ms, tags = nil)
145
+ metric("histogram", name, ms, tags)
146
+ end
147
+
148
+ def event(name, attributes = nil)
149
+ safely do
150
+ next unless enabled?
151
+ next log_once("event:#{name}", :warn, "ignoring event with invalid name #{name.inspect}") unless Serialize.valid_name?(name)
152
+
153
+ item = { "ts" => @clock.call, "name" => name,
154
+ "attributes" => Serialize.bounded_object(attributes, Serialize::MAX_ATTRIBUTES_BYTES) }
155
+ submit("events", "event", item, sampled: true)
156
+ end
157
+ end
158
+
159
+ def capture_exception(exception, tags: nil, context: nil, fingerprint: nil, handled: true)
160
+ safely do
161
+ next unless enabled? && exception.is_a?(Exception)
162
+ next if exception.instance_variable_defined?(:@__sillydev_monitoring_captured)
163
+
164
+ begin
165
+ exception.instance_variable_set(:@__sillydev_monitoring_captured, true)
166
+ rescue FrozenError
167
+ nil
168
+ end
169
+ body = Serialize.serialize_exception(exception)
170
+ item = {
171
+ "ts" => @clock.call,
172
+ "type" => body["type"],
173
+ "message" => body["message"],
174
+ "handled" => handled ? true : false,
175
+ "stack" => body["stack"],
176
+ "fingerprint" => fingerprint ? fingerprint.to_s[0, Serialize::MAX_FINGERPRINT] : nil,
177
+ "tags" => Serialize.clean_tags(tags).to_h,
178
+ "context" => Serialize.bounded_object(context, Serialize::MAX_CONTEXT_BYTES),
179
+ }
180
+ item["cause"] = body["cause"] if body.key?("cause")
181
+ submit("errors", "error", item, sampled: true)
182
+ end
183
+ end
184
+
185
+ def capture_message(message, level: "error", tags: nil, context: nil)
186
+ safely do
187
+ next unless enabled?
188
+
189
+ level = (level || "error").to_s.downcase
190
+ all_tags = (tags || {}).transform_keys(&:to_s)
191
+ all_tags["level"] ||= level
192
+ item = {
193
+ "ts" => @clock.call,
194
+ "type" => "Message",
195
+ "message" => message.to_s[0, Serialize::MAX_MESSAGE],
196
+ "handled" => true,
197
+ "stack" => [],
198
+ "fingerprint" => nil,
199
+ "tags" => Serialize.clean_tags(all_tags).to_h,
200
+ "context" => Serialize.bounded_object(context, Serialize::MAX_CONTEXT_BYTES),
201
+ }
202
+ submit("errors", "error", item, sampled: true)
203
+ end
204
+ end
205
+
206
+ def record_slow(name, duration_ms, kind: "custom", tags: nil, attributes: nil)
207
+ safely do
208
+ next unless enabled?
209
+
210
+ duration = finite(duration_ms)
211
+ next if duration.nil?
212
+
213
+ item = {
214
+ "ts" => @clock.call,
215
+ "name" => name.to_s[0, 256],
216
+ "kind" => Serialize::SLOW_KINDS.include?(kind.to_s) ? kind.to_s : "custom",
217
+ "duration_ms" => [duration, 0.0].max.round(3),
218
+ "tags" => Serialize.clean_tags(tags).to_h,
219
+ "attributes" => Serialize.bounded_object(attributes, Serialize::MAX_ATTRIBUTES_BYTES),
220
+ }
221
+ item = filter("slow", item, sampled: false)
222
+ next if item.nil?
223
+
224
+ @lock.synchronize do
225
+ @slow << item
226
+ if @slow.size > 1000
227
+ @slow.sort_by! { |s| -s["duration_ms"] }
228
+ count_drop("slow_buffer", @slow.size - MAX_SLOW_PER_WINDOW)
229
+ @slow = @slow.first(MAX_SLOW_PER_WINDOW)
230
+ end
231
+ end
232
+ end
233
+ end
234
+
235
+ # Marks this instance alive now (sent straight away by the background thread).
236
+ def heartbeat
237
+ safely do
238
+ next unless enabled?
239
+
240
+ @lock.synchronize { @heartbeat_due = true }
241
+ wake
242
+ end
243
+ end
244
+
245
+ # Records a slow operation when duration_ms is over the threshold.
246
+ def maybe_slow(name, duration_ms, kind, tags = nil)
247
+ record_slow(name, duration_ms, kind: kind, tags: tags) if enabled? && duration_ms >= @slow_threshold_ms
248
+ end
249
+
250
+ # Sends everything buffered, including the current window. True when nothing is left.
251
+ def flush(timeout = 2.0)
252
+ safely(false) do
253
+ next false unless enabled?
254
+
255
+ flush_until(now_mono + [timeout.to_f, 0.0].max)
256
+ end
257
+ end
258
+
259
+ # -- for tests and the integrations ---------------------------------------------------------
260
+
261
+ def pending_items
262
+ @lock.synchronize { @pending.map { |p| [p.array, p.item] } }
263
+ end
264
+
265
+ private
266
+
267
+ def reset_state
268
+ @lock = Mutex.new
269
+ @send_lock = Mutex.new
270
+ @wake_lock = Mutex.new
271
+ @wake_cond = ConditionVariable.new
272
+ @woken = false
273
+ @stop = false
274
+ @aggregator = Aggregator.new
275
+ @pending = []
276
+ @pending_bytes = 0
277
+ @slow = []
278
+ @heartbeat_due = false
279
+ @dropped = Hash.new(0)
280
+ @failures = 0
281
+ @next_attempt = 0.0
282
+ @thread = nil
283
+ end
284
+
285
+ def now_mono
286
+ @monotonic_clock.call
287
+ end
288
+
289
+ def safely(fallback = nil)
290
+ yield
291
+ rescue StandardError => e
292
+ debug_log("#{e.class}: #{e.message}")
293
+ fallback
294
+ end
295
+
296
+ def finite(value)
297
+ number = Float(value)
298
+ number.finite? ? number : nil
299
+ rescue ArgumentError, TypeError
300
+ nil
301
+ end
302
+
303
+ def metric(kind, name, value, tags)
304
+ safely do
305
+ next unless enabled?
306
+ next log_once("name:#{name}", :warn, "ignoring metric with invalid name #{name.inspect}") unless Serialize.valid_name?(name)
307
+
308
+ number = finite(value)
309
+ if number.nil? || (kind != "gauge" && number.negative?)
310
+ next unless kind == "histogram" && number
311
+
312
+ number = 0.0
313
+ end
314
+ key = Serialize.clean_tags(tags)
315
+ now = @clock.call
316
+ @lock.synchronize do
317
+ case kind
318
+ when "counter" then @aggregator.counter(now, name, number, key)
319
+ when "gauge" then @aggregator.gauge(now, name, number, key)
320
+ else @aggregator.histogram(now, name, number, key)
321
+ end
322
+ end
323
+ end
324
+ end
325
+
326
+ def filter(item_type, item, sampled:)
327
+ return nil if sampled && @sample_rate < 1.0 && @random.call >= @sample_rate
328
+ return item if @before_send.nil?
329
+
330
+ begin
331
+ result = @before_send.call(item_type, item)
332
+ rescue StandardError
333
+ log_once("before_send", :warn, "before_send raised; the item was dropped")
334
+ return nil
335
+ end
336
+ result.is_a?(Hash) ? result : nil
337
+ end
338
+
339
+ def submit(array, item_type, item, sampled:)
340
+ filtered = filter(item_type, item, sampled: sampled)
341
+ return if filtered.nil?
342
+
343
+ full = @lock.synchronize do
344
+ push(array, filtered)
345
+ @pending_bytes >= MAX_SENT_BYTES * 0.75 || @pending.size >= MAX_ITEMS_PER_REQUEST
346
+ end
347
+ wake if full
348
+ end
349
+
350
+ # Appends to the pending buffer, dropping the oldest items over the size bound. Lock held.
351
+ def push(array, item)
352
+ size = JSON.generate(item).bytesize
353
+ @pending << Pending.new(array, item, size)
354
+ @pending_bytes += size
355
+ enforce_bound
356
+ end
357
+
358
+ def enforce_bound
359
+ while @pending_bytes > @max_buffer_bytes && !@pending.empty?
360
+ @pending_bytes -= @pending.shift.size
361
+ count_drop("buffer_full")
362
+ log_once("buffer_full", :warn, "buffer full, dropping the oldest data")
363
+ end
364
+ end
365
+
366
+ def count_drop(reason, count = 1)
367
+ @dropped[reason] += count
368
+ end
369
+
370
+ # Moves ended windows (all of them when final) and the slowest operations into pending.
371
+ def move_ready(final)
372
+ @lock.synchronize do
373
+ @aggregator.drain(final ? nil : @clock.call).each { |point| push("metrics", point) }
374
+ if @aggregator.dropped.positive?
375
+ count_drop("series_buffer", @aggregator.dropped)
376
+ log_once("series_buffer", :warn, "too many distinct series, dropping new ones")
377
+ @aggregator.dropped = 0
378
+ end
379
+ unless @slow.empty?
380
+ @slow.sort_by! { |s| -s["duration_ms"] }
381
+ count_drop("slow_window", @slow.size - MAX_SLOW_PER_WINDOW) if @slow.size > MAX_SLOW_PER_WINDOW
382
+ @slow.first(MAX_SLOW_PER_WINDOW).each { |item| push("slow", item) }
383
+ @slow = []
384
+ end
385
+ end
386
+ end
387
+
388
+ def collect
389
+ @runtime&.collect&.each { |name, kind, value| metric(kind, name, value, nil) }
390
+ Monitoring.collectors.dup.each do |collector|
391
+ collector.call(self)
392
+ rescue StandardError => e
393
+ log_once("collector:#{collector.object_id}", :warn, "a metrics collector failed: #{e.class}: #{e.message}")
394
+ end
395
+ end
396
+
397
+ def take_batch
398
+ @lock.synchronize do
399
+ heartbeat = @heartbeat_due || (@runtime_metrics && !@pending.empty?)
400
+ @heartbeat_due = false
401
+ batch = []
402
+ skipped = []
403
+ slow = 0
404
+ oldest = @clock.call - STALE_AFTER
405
+ while !@pending.empty? && batch.size < MAX_ITEMS_PER_REQUEST
406
+ entry = @pending.shift
407
+ @pending_bytes -= entry.size
408
+ if (entry.item["ts"] || oldest) < oldest
409
+ count_drop("stale")
410
+ next
411
+ end
412
+ if entry.array == "slow"
413
+ if slow >= MAX_SLOW_PER_REQUEST
414
+ skipped << entry
415
+ next
416
+ end
417
+ slow += 1
418
+ end
419
+ batch << entry
420
+ end
421
+ skipped.reverse_each do |entry|
422
+ @pending.unshift(entry)
423
+ @pending_bytes += entry.size
424
+ end
425
+ [batch, heartbeat]
426
+ end
427
+ end
428
+
429
+ def requeue(batch, heartbeat)
430
+ @lock.synchronize do
431
+ batch.reverse_each do |entry|
432
+ @pending.unshift(entry)
433
+ @pending_bytes += entry.size
434
+ end
435
+ @heartbeat_due = true if heartbeat
436
+ enforce_bound
437
+ end
438
+ end
439
+
440
+ # -- sending --------------------------------------------------------------------------------
441
+
442
+ def wake
443
+ @wake_lock.synchronize do
444
+ @woken = true
445
+ @wake_cond.signal
446
+ end
447
+ end
448
+
449
+ def wait_for_wake(seconds)
450
+ @wake_lock.synchronize do
451
+ @wake_cond.wait(@wake_lock, seconds) unless @woken
452
+ woken = @woken
453
+ @woken = false
454
+ woken
455
+ end
456
+ end
457
+
458
+ def start_thread
459
+ @thread = Thread.new { run }
460
+ @thread.name = "sillydev-monitoring" if @thread.respond_to?(:name=)
461
+ @thread.report_on_exception = false
462
+ end
463
+
464
+ def run
465
+ last_collect = now_mono
466
+ until @stop
467
+ woken = wait_for_wake(@flush_interval)
468
+ break if @stop
469
+
470
+ begin
471
+ now = now_mono
472
+ if !woken || now - last_collect >= @flush_interval
473
+ last_collect = now
474
+ collect
475
+ end
476
+ move_ready(false)
477
+ send_until(now_mono + [REQUEST_TIMEOUT * 2, @flush_interval].max)
478
+ rescue StandardError => e
479
+ log_once("thread", :warn, "background flush failed: #{e.class}: #{e.message}")
480
+ end
481
+ break if @stopped
482
+ end
483
+ end
484
+
485
+ def flush_until(deadline)
486
+ move_ready(true)
487
+ send_until(deadline)
488
+ end
489
+
490
+ def send_until(deadline)
491
+ locked = false
492
+ until (locked = @send_lock.try_lock)
493
+ return false if now_mono >= deadline
494
+
495
+ sleep 0.01
496
+ end
497
+ loop do
498
+ return false if @stopped
499
+
500
+ now = now_mono
501
+ return false if now < @next_attempt || now >= deadline
502
+
503
+ batch, heartbeat = take_batch
504
+ return true if batch.empty? && !heartbeat
505
+ return false unless deliver(batch, heartbeat, deadline)
506
+ end
507
+ ensure
508
+ @send_lock.unlock if locked
509
+ end
510
+
511
+ def payload(batch, heartbeat)
512
+ resource = { "service" => @service, "environment" => @environment, "instance" => @instance,
513
+ "runtime" => Client.runtime_name }
514
+ resource["release"] = @release if @release
515
+ body = { "sdk" => { "name" => "sillydev-ruby", "version" => VERSION }, "resource" => resource,
516
+ "heartbeat" => heartbeat ? true : false, "metrics" => [], "errors" => [], "events" => [], "slow" => [] }
517
+ batch.each { |entry| body[entry.array] << entry.item }
518
+ body
519
+ end
520
+
521
+ # Sends one batch. True to carry on with the next one; false to stop for now.
522
+ def deliver(batch, heartbeat, deadline)
523
+ raw = JSON.generate(payload(batch, heartbeat))
524
+ content = raw
525
+ gzipped = false
526
+ if raw.bytesize >= GZIP_MIN_BYTES
527
+ packed = gzip(raw)
528
+ if packed.bytesize < raw.bytesize
529
+ content = packed
530
+ gzipped = true
531
+ end
532
+ end
533
+ if raw.bytesize > MAX_RAW_BYTES || content.bytesize > MAX_SENT_BYTES
534
+ return split(batch, heartbeat, deadline, "batch over the size limit")
535
+ end
536
+
537
+ debug_log("sending #{content.bytesize} bytes: #{raw[0, 8000]}")
538
+ headers = { "Authorization" => "Bearer #{@key}", "Content-Type" => "application/json",
539
+ "User-Agent" => "sillydev-ruby/#{VERSION} monitoring" }
540
+ headers["Content-Encoding"] = "gzip" if gzipped
541
+ timeout = (deadline - now_mono).clamp(0.1, REQUEST_TIMEOUT)
542
+ begin
543
+ status, response_headers, response_body = post("#{@endpoint}/v1/batch", content, headers, timeout)
544
+ rescue StandardError => e
545
+ requeue(batch, heartbeat)
546
+ back_off("could not reach #{@endpoint}: #{e.class}")
547
+ return false
548
+ end
549
+
550
+ if status.between?(200, 299)
551
+ @failures = 0
552
+ debug_log("#{status} #{response_body.to_s[0, 2000]}")
553
+ return true
554
+ end
555
+ if [401, 403].include?(status)
556
+ @stopped = true
557
+ @lock.synchronize do
558
+ @pending.clear
559
+ @pending_bytes = 0
560
+ @aggregator.clear
561
+ @slow = []
562
+ end
563
+ log_once("auth", :warn, "the ingest answered #{status} (#{error_text(response_body)}); monitoring has " \
564
+ "stopped. Check SILLYDEV_MONITORING_KEY.")
565
+ wake
566
+ return false
567
+ end
568
+ return split(batch, heartbeat, deadline, "the ingest answered 413") if status == 413
569
+
570
+ if status == 429
571
+ requeue(batch, heartbeat)
572
+ retry_after = finite(response_headers["retry-after"])
573
+ @next_attempt = now_mono + (retry_after && retry_after >= 0 ? retry_after : backoff_delay)
574
+ log_once("429", :info, "rate limited by the ingest, waiting")
575
+ return false
576
+ end
577
+ if status >= 500
578
+ requeue(batch, heartbeat)
579
+ back_off("the ingest answered #{status}")
580
+ return false
581
+ end
582
+ count_drop("http_#{status}", batch.size)
583
+ log_once("http:#{status}", :warn, "the ingest refused a batch (#{status}: #{error_text(response_body)}); it was dropped")
584
+ true
585
+ end
586
+
587
+ def post(url, body, headers, timeout)
588
+ return @transport.call(url, body, headers, timeout) if @transport
589
+
590
+ uri = URI(url)
591
+ http = Net::HTTP.new(uri.host, uri.port)
592
+ http.use_ssl = uri.scheme == "https"
593
+ http.open_timeout = timeout
594
+ http.read_timeout = timeout
595
+ http.write_timeout = timeout
596
+ request = Net::HTTP::Post.new(uri)
597
+ headers.each { |k, v| request[k] = v }
598
+ request.body = body
599
+ response = http.start { |h| h.request(request) }
600
+ response_headers = {}
601
+ response.each_header { |k, v| response_headers[k.downcase] = v }
602
+ [response.code.to_i, response_headers, response.body]
603
+ end
604
+
605
+ def gzip(text)
606
+ io = StringIO.new(+"".b)
607
+ writer = Zlib::GzipWriter.new(io, 6)
608
+ writer.write(text)
609
+ writer.close
610
+ io.string
611
+ end
612
+
613
+ def split(batch, heartbeat, deadline, why)
614
+ log_once("split:#{why}", :info, "#{why}, splitting it")
615
+ if batch.size <= 1
616
+ count_drop("too_large", batch.size)
617
+ log_once("too_large", :warn, "dropped an item too large to send")
618
+ return true
619
+ end
620
+ half = batch.size / 2
621
+ first = batch[0, half]
622
+ second = batch[half..]
623
+ unless deliver(first, heartbeat, deadline)
624
+ requeue(second, false)
625
+ return false
626
+ end
627
+ deliver(second, false, deadline)
628
+ end
629
+
630
+ def backoff_delay
631
+ delay = [BACKOFF_CAP, BACKOFF_BASE * (2**[@failures, 20].min)].min
632
+ (delay / 2) + (@random.call * delay / 2)
633
+ end
634
+
635
+ def back_off(why)
636
+ @failures += 1
637
+ delay = backoff_delay
638
+ @next_attempt = now_mono + delay
639
+ log_once("backoff:#{why}", :info, format("%<why>s; retrying in %<delay>.1fs", why: why, delay: delay))
640
+ end
641
+
642
+ def error_text(body)
643
+ parsed = JSON.parse(body.to_s)
644
+ (parsed["code"] || parsed["error"]).to_s[0, 200]
645
+ rescue StandardError
646
+ ""
647
+ end
648
+
649
+ def log_once(key, level, message)
650
+ if @logged.key?(key)
651
+ debug_log(message)
652
+ return
653
+ end
654
+ @logged[key] = true if @logged.size < 1000
655
+ log(level, message)
656
+ end
657
+
658
+ def debug_log(message)
659
+ log(:debug, message) if @debug
660
+ end
661
+
662
+ def log(level, message)
663
+ text = "sillydev.monitoring: #{message}"
664
+ if @logger
665
+ @logger.public_send(level, text)
666
+ elsif level == :warn || @debug
667
+ warn(text)
668
+ end
669
+ rescue StandardError
670
+ nil
671
+ end
672
+ end
673
+ end
674
+ end