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,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NaijaCloud
|
|
4
|
+
module Argus
|
|
5
|
+
# User, tags, contexts and breadcrumbs that are attached to the next event.
|
|
6
|
+
#
|
|
7
|
+
# There is one global scope, plus one per request or job: the Rack
|
|
8
|
+
# middleware and the ActiveJob hook open a scope around each unit of work,
|
|
9
|
+
# so `set_user` in one Puma thread never labels another thread's error.
|
|
10
|
+
# Outside a request, writes go to the global scope.
|
|
11
|
+
class Scope
|
|
12
|
+
attr_reader :tags, :contexts, :breadcrumbs
|
|
13
|
+
attr_accessor :user, :transaction, :request
|
|
14
|
+
|
|
15
|
+
def initialize(max_breadcrumbs = 100)
|
|
16
|
+
@max_breadcrumbs = max_breadcrumbs
|
|
17
|
+
@tags = {}
|
|
18
|
+
@contexts = {}
|
|
19
|
+
@breadcrumbs = []
|
|
20
|
+
@user = nil
|
|
21
|
+
@transaction = nil
|
|
22
|
+
@request = nil
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def set_tag(key, value)
|
|
27
|
+
@mutex.synchronize { @tags[key.to_s] = value.to_s }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def set_context(name, ctx)
|
|
31
|
+
@mutex.synchronize { ctx.nil? ? @contexts.delete(name.to_s) : @contexts[name.to_s] = ctx }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def add_breadcrumb(crumb)
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
@breadcrumbs << crumb
|
|
37
|
+
@breadcrumbs.shift(@breadcrumbs.length - @max_breadcrumbs) if @breadcrumbs.length > @max_breadcrumbs
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# `transaction` may be a callable: the Rack middleware only learns the
|
|
42
|
+
# route after the router has run, which is after an error raised inside
|
|
43
|
+
# the app may already have been reported.
|
|
44
|
+
def snapshot
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
t = @transaction
|
|
47
|
+
t = (t.call rescue nil) if t.respond_to?(:call) # rubocop:disable Style/RescueModifier
|
|
48
|
+
{ user: @user, tags: @tags.dup, contexts: @contexts.dup, breadcrumbs: @breadcrumbs.dup,
|
|
49
|
+
transaction: t, request: @request }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.current
|
|
54
|
+
Thread.current[:naijacloud_argus_scope]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.with(scope)
|
|
58
|
+
prev = Thread.current[:naijacloud_argus_scope]
|
|
59
|
+
Thread.current[:naijacloud_argus_scope] = scope
|
|
60
|
+
yield scope
|
|
61
|
+
ensure
|
|
62
|
+
Thread.current[:naijacloud_argus_scope] = prev
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NaijaCloud
|
|
4
|
+
module Argus
|
|
5
|
+
# Privacy defaults (spec §7). Ingest applies the same rules again on
|
|
6
|
+
# arrival; the SDK does not rely on that, because a value that never leaves
|
|
7
|
+
# the process cannot leak in transit or in a proxy's log.
|
|
8
|
+
module Scrub
|
|
9
|
+
FILTERED = "[Filtered]"
|
|
10
|
+
|
|
11
|
+
CONTAINS = %w[
|
|
12
|
+
password passwd secret token apikey accesskey authorization credential
|
|
13
|
+
cookie privatekey cardnumber creditcard bvn cvv cvc sessionid
|
|
14
|
+
sessiontoken signature
|
|
15
|
+
].freeze
|
|
16
|
+
EXACT = %w[auth pwd pin otp session card sid].freeze
|
|
17
|
+
|
|
18
|
+
CARD = /\b(?:\d[ -]?){12,18}\d\b/.freeze
|
|
19
|
+
CREDENTIAL = %r{\b(bearer|basic|token)\s+[A-Za-z0-9._~+/=-]{8,}}i.freeze
|
|
20
|
+
JWT = /\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\b/.freeze
|
|
21
|
+
KV = /\b([a-z0-9_-]*(?:password|passwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credential|bvn|cvv)[a-z0-9_-]*)(\s*[:=]\s*)(["']?)([^"'\s,;&})]+)/i.freeze
|
|
22
|
+
|
|
23
|
+
MAX_DEPTH = 8
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Whether a field's value should be filtered: lowercased with `-`, `_`
|
|
28
|
+
# and spaces removed, it contains or equals one of the listed words.
|
|
29
|
+
def sensitive_key?(key)
|
|
30
|
+
n = key.to_s.downcase.delete("-_ ")
|
|
31
|
+
EXACT.include?(n) || CONTAINS.any? { |c| n.include?(c) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Filters card numbers, credentials, JWTs and sensitive key=value pairs
|
|
35
|
+
# inside a string.
|
|
36
|
+
def scrub_string(str)
|
|
37
|
+
return str if str.nil? || str.empty?
|
|
38
|
+
|
|
39
|
+
str = str.dup.force_encoding(Encoding::UTF_8)
|
|
40
|
+
str = str.scrub("?") unless str.valid_encoding?
|
|
41
|
+
str
|
|
42
|
+
.gsub(CARD) { |m| card?(m.delete(" -")) ? FILTERED : m }
|
|
43
|
+
.gsub(CREDENTIAL) { "#{Regexp.last_match(1)} #{FILTERED}" }
|
|
44
|
+
.gsub(JWT, FILTERED)
|
|
45
|
+
.gsub(KV) { "#{Regexp.last_match(1)}#{Regexp.last_match(2)}#{Regexp.last_match(3)}#{FILTERED}" }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Deep-scrubs a JSON-like value into a new one. Hash keys become strings.
|
|
49
|
+
# Cycles and anything nested deeper than 8 are cut rather than followed,
|
|
50
|
+
# so a self-referencing object cannot hang the request that reports it.
|
|
51
|
+
def scrub(value, depth = 0, seen = {})
|
|
52
|
+
case value
|
|
53
|
+
when String then scrub_string(value)
|
|
54
|
+
when Symbol then scrub_string(value.to_s)
|
|
55
|
+
when nil, true, false, Integer then value
|
|
56
|
+
when Float then value.finite? ? value : value.to_s
|
|
57
|
+
when Hash, Array
|
|
58
|
+
return "[…]" if depth > MAX_DEPTH || seen.key?(value.object_id)
|
|
59
|
+
|
|
60
|
+
seen[value.object_id] = true
|
|
61
|
+
out = if value.is_a?(Array)
|
|
62
|
+
value.map { |v| scrub(v, depth + 1, seen) }
|
|
63
|
+
else
|
|
64
|
+
value.each_with_object({}) do |(k, v), acc|
|
|
65
|
+
acc[k.to_s] = sensitive_key?(k) && !blank?(v) ? FILTERED : scrub(v, depth + 1, seen)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
seen.delete(value.object_id)
|
|
69
|
+
out
|
|
70
|
+
else
|
|
71
|
+
scrub_string(safe_to_s(value))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Removes the query string and fragment from a URL (spec §7).
|
|
76
|
+
def strip_query(url)
|
|
77
|
+
url = url.to_s
|
|
78
|
+
i = url.index(/[?#]/)
|
|
79
|
+
i ? url[0, i] : url
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def card?(digits)
|
|
83
|
+
return false unless digits.length.between?(13, 19)
|
|
84
|
+
|
|
85
|
+
sum = 0
|
|
86
|
+
digits.reverse.each_char.with_index do |ch, i|
|
|
87
|
+
d = ch.ord - 48
|
|
88
|
+
if i.odd?
|
|
89
|
+
d *= 2
|
|
90
|
+
d -= 9 if d > 9
|
|
91
|
+
end
|
|
92
|
+
sum += d
|
|
93
|
+
end
|
|
94
|
+
(sum % 10).zero?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def blank?(value)
|
|
98
|
+
value.nil? || value == ""
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def safe_to_s(value)
|
|
102
|
+
value.to_s
|
|
103
|
+
rescue StandardError
|
|
104
|
+
"#<#{value.class}>"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module NaijaCloud
|
|
6
|
+
module Argus
|
|
7
|
+
# A unit of work in a trace. Finish it once.
|
|
8
|
+
class Span
|
|
9
|
+
KIND_INTERNAL = 1
|
|
10
|
+
KIND_SERVER = 2
|
|
11
|
+
KIND_CLIENT = 3
|
|
12
|
+
|
|
13
|
+
TRACEPARENT = /\A00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})\z/.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :trace_id, :span_id, :parent_span_id, :kind, :start_time, :attributes
|
|
16
|
+
attr_accessor :name
|
|
17
|
+
|
|
18
|
+
# Parses a W3C traceparent header into `{trace_id:, span_id:, sampled:}`.
|
|
19
|
+
def self.parse_traceparent(header)
|
|
20
|
+
m = header && TRACEPARENT.match(header.to_s.strip)
|
|
21
|
+
return nil if m.nil? || m[1] =~ /\A0+\z/ || m[2] =~ /\A0+\z/
|
|
22
|
+
|
|
23
|
+
{ trace_id: m[1], span_id: m[2], sampled: (m[3].to_i(16) & 1) == 1 }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The span in progress on this thread (or fiber), if any.
|
|
27
|
+
def self.current
|
|
28
|
+
Thread.current[:naijacloud_argus_span]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Runs the block with span as the current one.
|
|
32
|
+
def self.with(span)
|
|
33
|
+
prev = Thread.current[:naijacloud_argus_span]
|
|
34
|
+
Thread.current[:naijacloud_argus_span] = span
|
|
35
|
+
yield
|
|
36
|
+
ensure
|
|
37
|
+
Thread.current[:naijacloud_argus_span] = prev
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(client, name, kind: KIND_INTERNAL, parent: nil, attributes: nil)
|
|
41
|
+
@client = client
|
|
42
|
+
@name = name.to_s
|
|
43
|
+
@kind = kind
|
|
44
|
+
@attributes = attributes ? attributes.dup : {}
|
|
45
|
+
@trace_id = parent ? parent[:trace_id] : SecureRandom.hex(16)
|
|
46
|
+
@parent_span_id = parent && parent[:span_id]
|
|
47
|
+
@span_id = SecureRandom.hex(8)
|
|
48
|
+
@sampled = parent ? parent[:sampled] : (client ? client.sample_trace? : false)
|
|
49
|
+
@start_time = Time.now.to_f
|
|
50
|
+
@finished = false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def sampled?
|
|
54
|
+
@sampled
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Whether finishing this span sends anything.
|
|
58
|
+
def recording?
|
|
59
|
+
@sampled && !@client.nil?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def set_attribute(key, value)
|
|
63
|
+
@attributes[key.to_s] = value
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def set_attributes(hash)
|
|
68
|
+
hash.each { |k, v| set_attribute(k, v) }
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The W3C traceparent for an outgoing request, so the service it calls
|
|
73
|
+
# continues this trace.
|
|
74
|
+
def traceparent
|
|
75
|
+
"00-#{@trace_id}-#{@span_id}-#{@sampled ? '01' : '00'}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Ends the span. Pass the error (or a message) when the work failed.
|
|
79
|
+
def finish(error = nil)
|
|
80
|
+
return self if @finished
|
|
81
|
+
|
|
82
|
+
@finished = true
|
|
83
|
+
return self unless recording?
|
|
84
|
+
|
|
85
|
+
message = case error
|
|
86
|
+
when nil, false then nil
|
|
87
|
+
when Exception then "#{error.class}: #{error.message}"
|
|
88
|
+
else error.to_s
|
|
89
|
+
end
|
|
90
|
+
@client.record_span(
|
|
91
|
+
trace_id: @trace_id, span_id: @span_id, parent_span_id: @parent_span_id,
|
|
92
|
+
name: @name, kind: @kind, start: @start_time, end: Time.now.to_f,
|
|
93
|
+
error: message, attributes: @attributes,
|
|
94
|
+
)
|
|
95
|
+
self
|
|
96
|
+
rescue StandardError
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def finished?
|
|
101
|
+
@finished
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def inspect
|
|
105
|
+
"#<NaijaCloud::Argus::Span #{@name.inspect} trace=#{@trace_id} span=#{@span_id}>"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
module NaijaCloud
|
|
6
|
+
module Argus
|
|
7
|
+
# Turns a Ruby backtrace into Sentry frames, oldest first (spec §4).
|
|
8
|
+
class Stack
|
|
9
|
+
# "path:12:in `meth'" (Ruby < 3.4) and "path:12:in 'Klass#meth'" (3.4+).
|
|
10
|
+
LINE = /\A(.+?):(\d+)(?::in [`'](.*)')?\z/.freeze
|
|
11
|
+
CONTEXT_LINES = 5
|
|
12
|
+
MAX_FRAMES = 250
|
|
13
|
+
MAX_FILE_BYTES = 1 << 20
|
|
14
|
+
CACHE_SIZE = 64
|
|
15
|
+
|
|
16
|
+
def initialize(project_root: nil, context_lines: true)
|
|
17
|
+
@root = project_root && File.expand_path(project_root.to_s)
|
|
18
|
+
@context_lines = context_lines
|
|
19
|
+
@cache = {}
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@library_paths = library_paths
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :root
|
|
25
|
+
|
|
26
|
+
def frames(exception)
|
|
27
|
+
lines = exception.backtrace
|
|
28
|
+
return [] if lines.nil? || lines.empty?
|
|
29
|
+
|
|
30
|
+
lines.first(MAX_FRAMES).reverse.filter_map { |line| frame(line) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def frame(line)
|
|
36
|
+
m = LINE.match(line.to_s)
|
|
37
|
+
return nil unless m
|
|
38
|
+
|
|
39
|
+
abs = m[1]
|
|
40
|
+
lineno = m[2].to_i
|
|
41
|
+
fn = m[3]
|
|
42
|
+
f = { abs_path: abs, filename: relative(abs), lineno: lineno, in_app: in_app?(abs) }
|
|
43
|
+
f[:function] = fn.sub(/\A(?:block|rescue|ensure)(?: \(\d+ levels\))? in /, "") if fn && !fn.empty?
|
|
44
|
+
add_context(f, abs, lineno) if @context_lines && f[:in_app]
|
|
45
|
+
f
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def relative(abs)
|
|
49
|
+
return abs.delete_prefix("#{@root}/") if @root && abs.start_with?("#{@root}/")
|
|
50
|
+
|
|
51
|
+
gem_path = abs[%r{/gems/([^/]+/.+)\z}, 1]
|
|
52
|
+
gem_path || abs
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def in_app?(abs)
|
|
56
|
+
return false if abs.start_with?("<internal:", "<main>") || abs.include?("/gems/") || abs.include?("/vendor/bundle/")
|
|
57
|
+
return false if @library_paths.any? { |p| abs.start_with?(p) }
|
|
58
|
+
return abs.start_with?("#{@root}/") if @root
|
|
59
|
+
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def library_paths
|
|
64
|
+
paths = [RbConfig::CONFIG["rubylibdir"], RbConfig::CONFIG["rubyarchdir"]]
|
|
65
|
+
paths.concat(Gem.path) if defined?(Gem)
|
|
66
|
+
paths.compact.map { |p| File.expand_path(p) }.uniq
|
|
67
|
+
rescue StandardError
|
|
68
|
+
[]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def add_context(frame, path, lineno)
|
|
72
|
+
src = source(path)
|
|
73
|
+
return unless src && lineno.positive? && lineno <= src.length
|
|
74
|
+
|
|
75
|
+
i = lineno - 1
|
|
76
|
+
frame[:pre_context] = src[[0, i - CONTEXT_LINES].max...i]
|
|
77
|
+
frame[:context_line] = src[i]
|
|
78
|
+
frame[:post_context] = src[(i + 1)..(i + CONTEXT_LINES)] || []
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Source files are read once, capped in size, and the cache is bounded,
|
|
82
|
+
# so a deep stack in a hot error path costs a hash lookup, not disk I/O.
|
|
83
|
+
def source(path)
|
|
84
|
+
@mutex.synchronize do
|
|
85
|
+
return @cache[path] if @cache.key?(path)
|
|
86
|
+
|
|
87
|
+
@cache.shift if @cache.size >= CACHE_SIZE
|
|
88
|
+
@cache[path] = read_source(path)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def read_source(path)
|
|
93
|
+
return nil unless File.file?(path) && File.size(path) <= MAX_FILE_BYTES
|
|
94
|
+
|
|
95
|
+
File.readlines(path, chomp: true).map { |l| l.length > 500 ? "#{l[0, 500]}…" : l }
|
|
96
|
+
rescue StandardError
|
|
97
|
+
nil
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|