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