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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/exe/sillydev +6 -0
- data/lib/sillydev/cli.rb +295 -0
- data/lib/sillydev/client.rb +199 -0
- data/lib/sillydev/console.rb +1131 -0
- data/lib/sillydev/envelope.rb +84 -0
- data/lib/sillydev/errors.rb +160 -0
- data/lib/sillydev/mcp_server.rb +193 -0
- data/lib/sillydev/monitoring/aggregator.rb +107 -0
- data/lib/sillydev/monitoring/client.rb +674 -0
- data/lib/sillydev/monitoring/discordrb.rb +131 -0
- data/lib/sillydev/monitoring/http.rb +31 -0
- data/lib/sillydev/monitoring/rack.rb +114 -0
- data/lib/sillydev/monitoring/rails.rb +41 -0
- data/lib/sillydev/monitoring/runtime.rb +96 -0
- data/lib/sillydev/monitoring/serialize.rb +168 -0
- data/lib/sillydev/monitoring.rb +227 -0
- data/lib/sillydev/resources.rb +844 -0
- data/lib/sillydev/transport.rb +71 -0
- data/lib/sillydev/version.rb +5 -0
- data/lib/sillydev/websocket.rb +248 -0
- data/lib/sillydev.rb +20 -0
- metadata +67 -0
|
@@ -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
|