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,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
|