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,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NaijaCloud
|
|
4
|
+
module Argus
|
|
5
|
+
# Options for `NaijaCloud::Argus.init`. Every option can be passed as a
|
|
6
|
+
# keyword or set in the block:
|
|
7
|
+
#
|
|
8
|
+
# NaijaCloud::Argus.init(dsn: "...", release: "1.4.2")
|
|
9
|
+
# NaijaCloud::Argus.init { |c| c.traces_sample_rate = 0.2 }
|
|
10
|
+
class Configuration
|
|
11
|
+
# Exceptions that describe a client's mistake rather than a bug. Matched
|
|
12
|
+
# by name so the gem does not have to load Rails to know them.
|
|
13
|
+
DEFAULT_EXCLUDED = %w[
|
|
14
|
+
ActionController::RoutingError
|
|
15
|
+
ActionController::UnknownFormat
|
|
16
|
+
ActionController::BadRequest
|
|
17
|
+
ActionController::ParameterMissing
|
|
18
|
+
ActionController::InvalidAuthenticityToken
|
|
19
|
+
ActionDispatch::Http::MimeNegotiation::InvalidType
|
|
20
|
+
ActiveRecord::RecordNotFound
|
|
21
|
+
AbstractController::ActionNotFound
|
|
22
|
+
Sinatra::NotFound
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
OPTIONS = %i[
|
|
26
|
+
dsn release environment service_name server_name sample_rate
|
|
27
|
+
traces_sample_rate send_default_pii max_breadcrumbs before_send
|
|
28
|
+
before_breadcrumb debug enabled project_root context_lines
|
|
29
|
+
excluded_exceptions trace_propagation net_http rails_logs
|
|
30
|
+
capture_at_exit debug_output
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
attr_accessor(*OPTIONS)
|
|
34
|
+
|
|
35
|
+
def initialize(**options)
|
|
36
|
+
@dsn = nil
|
|
37
|
+
@release = nil
|
|
38
|
+
@environment = nil
|
|
39
|
+
@service_name = nil
|
|
40
|
+
@server_name = nil
|
|
41
|
+
@sample_rate = 1.0
|
|
42
|
+
@traces_sample_rate = 1.0
|
|
43
|
+
@send_default_pii = false
|
|
44
|
+
@max_breadcrumbs = 100
|
|
45
|
+
@before_send = nil
|
|
46
|
+
@before_breadcrumb = nil
|
|
47
|
+
@debug = false
|
|
48
|
+
@enabled = true
|
|
49
|
+
@project_root = nil
|
|
50
|
+
@context_lines = true
|
|
51
|
+
@excluded_exceptions = DEFAULT_EXCLUDED.dup
|
|
52
|
+
@trace_propagation = true
|
|
53
|
+
@net_http = true
|
|
54
|
+
@rails_logs = false
|
|
55
|
+
@capture_at_exit = true
|
|
56
|
+
@debug_output = $stderr
|
|
57
|
+
options.each do |key, value|
|
|
58
|
+
# A typo in an option must not stop the app booting; say so instead.
|
|
59
|
+
unless OPTIONS.include?(key.to_sym)
|
|
60
|
+
warn("[argus] unknown option #{key.inspect} ignored")
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
public_send("#{key}=", value)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Spec §1: the explicit option wins, then ARGUS_*, then SENTRY_*.
|
|
69
|
+
def resolve!(env = ENV)
|
|
70
|
+
@dsn = first(@dsn, env["ARGUS_DSN"], env["SENTRY_DSN"])
|
|
71
|
+
@release = first(@release, env["ARGUS_RELEASE"], env["SENTRY_RELEASE"])
|
|
72
|
+
@environment = first(@environment, env["ARGUS_ENVIRONMENT"], env["SENTRY_ENVIRONMENT"], "production")
|
|
73
|
+
@service_name = first(@service_name, env["ARGUS_SERVICE_NAME"], env["OTEL_SERVICE_NAME"], "app")
|
|
74
|
+
@debug ||= %w[1 true yes].include?(env["ARGUS_DEBUG"].to_s.downcase)
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def inspect
|
|
79
|
+
"#<NaijaCloud::Argus::Configuration environment=#{@environment.inspect} release=#{@release.inspect}>"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def first(*values)
|
|
85
|
+
values.each do |v|
|
|
86
|
+
next if v.nil?
|
|
87
|
+
|
|
88
|
+
s = v.to_s.strip
|
|
89
|
+
return s unless s.empty?
|
|
90
|
+
end
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module NaijaCloud
|
|
6
|
+
module Argus
|
|
7
|
+
# A parsed DSN: `https://<public_key>@<host>/<project_id>` (spec §1).
|
|
8
|
+
class Dsn
|
|
9
|
+
attr_reader :public_key, :project_id, :origin, :raw
|
|
10
|
+
|
|
11
|
+
# Returns a Dsn, or nil for anything that is not one. Never raises: a
|
|
12
|
+
# typo in an environment variable must not take the app down at boot.
|
|
13
|
+
def self.parse(raw)
|
|
14
|
+
return nil unless raw.is_a?(String)
|
|
15
|
+
|
|
16
|
+
raw = raw.strip
|
|
17
|
+
return nil if raw.empty?
|
|
18
|
+
|
|
19
|
+
uri = URI.parse(raw)
|
|
20
|
+
return nil unless %w[http https].include?(uri.scheme)
|
|
21
|
+
return nil if uri.host.nil? || uri.host.empty?
|
|
22
|
+
|
|
23
|
+
key = uri.user && URI.decode_www_form_component(uri.user)
|
|
24
|
+
parts = uri.path.to_s.split("/").reject(&:empty?)
|
|
25
|
+
project = parts.pop
|
|
26
|
+
return nil if key.nil? || key.empty? || project.nil? || project !~ /\A\d+\z/
|
|
27
|
+
|
|
28
|
+
prefix = parts.empty? ? "" : "/#{parts.join('/')}"
|
|
29
|
+
port = uri.port == uri.default_port ? "" : ":#{uri.port}"
|
|
30
|
+
new(public_key: key, project_id: project, origin: "#{uri.scheme}://#{uri.host}#{port}#{prefix}", raw: raw)
|
|
31
|
+
rescue URI::Error, ArgumentError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(public_key:, project_id:, origin:, raw:)
|
|
36
|
+
@public_key = public_key
|
|
37
|
+
@project_id = project_id
|
|
38
|
+
@origin = origin
|
|
39
|
+
@raw = raw
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The error endpoint (spec §3). Servers authenticate with a header, so
|
|
43
|
+
# the key stays out of the URL and out of any proxy's access log.
|
|
44
|
+
def envelope_url
|
|
45
|
+
"#{origin}/api/#{project_id}/envelope/"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def ingest_url(path)
|
|
49
|
+
"#{origin}#{path}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The key is public by design, but a DSN dumped by an error reporter is
|
|
53
|
+
# still noise nobody needs; show which source it is, not the key.
|
|
54
|
+
def inspect
|
|
55
|
+
"#<NaijaCloud::Argus::Dsn #{origin}/#{project_id}>"
|
|
56
|
+
end
|
|
57
|
+
alias to_s inspect
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
|
|
5
|
+
module NaijaCloud
|
|
6
|
+
module Argus
|
|
7
|
+
module Integrations
|
|
8
|
+
# Outgoing Net::HTTP requests (and every client built on it: Faraday's
|
|
9
|
+
# default adapter, HTTParty, RestClient, ActiveResource): a breadcrumb
|
|
10
|
+
# each, and inside a span a child client span plus a W3C `traceparent`
|
|
11
|
+
# header so the service called continues the trace (spec §5).
|
|
12
|
+
module NetHTTP
|
|
13
|
+
@installed = false
|
|
14
|
+
|
|
15
|
+
def self.install
|
|
16
|
+
return if @installed
|
|
17
|
+
|
|
18
|
+
@installed = true
|
|
19
|
+
::Net::HTTP.prepend(Patch)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module Patch
|
|
23
|
+
def request(req, body = nil, &block)
|
|
24
|
+
# Net::HTTP#request calls itself again from inside `start` when
|
|
25
|
+
# the connection was not open yet; trace the inner call only.
|
|
26
|
+
return super unless started?
|
|
27
|
+
return super if Transport.internal?
|
|
28
|
+
|
|
29
|
+
client = Argus.client
|
|
30
|
+
return super unless client&.enabled?
|
|
31
|
+
|
|
32
|
+
Argus::Integrations::NetHTTP.traced(client, self, req) { super }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.traced(client, http, req)
|
|
37
|
+
method = req.method.to_s
|
|
38
|
+
port = http.port == (http.use_ssl? ? 443 : 80) ? "" : ":#{http.port}"
|
|
39
|
+
url = "#{http.use_ssl? ? 'https' : 'http'}://#{http.address}#{port}#{Scrub.strip_query(req.path)}"
|
|
40
|
+
span = nil
|
|
41
|
+
if Span.current
|
|
42
|
+
span = client.start_span("#{method} #{url}", kind: Span::KIND_CLIENT,
|
|
43
|
+
attributes: { "http.request.method" => method, "url.full" => url,
|
|
44
|
+
"server.address" => http.address })
|
|
45
|
+
req["traceparent"] = span.traceparent if client.config.trace_propagation && req["traceparent"].nil?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
res = yield
|
|
50
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised
|
|
51
|
+
span&.finish(e)
|
|
52
|
+
crumb(client, method, url, nil, "error")
|
|
53
|
+
raise
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
code = res.code.to_i
|
|
57
|
+
if span
|
|
58
|
+
span.set_attribute("http.response.status_code", code)
|
|
59
|
+
span.finish(code >= 500 ? "HTTP #{code}" : nil)
|
|
60
|
+
end
|
|
61
|
+
crumb(client, method, url, code, code >= 500 ? "error" : nil)
|
|
62
|
+
res
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def self.crumb(client, method, url, status, level)
|
|
66
|
+
data = { method: method, url: url }
|
|
67
|
+
data[:status_code] = status if status
|
|
68
|
+
c = { type: "http", category: "net.http", data: data }
|
|
69
|
+
c[:level] = level if level
|
|
70
|
+
client.add_breadcrumb(c)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NaijaCloud
|
|
4
|
+
module Argus
|
|
5
|
+
# Rack middleware: a server span per request (continuing the caller's W3C
|
|
6
|
+
# `traceparent`), named by the route once the router has matched it, and
|
|
7
|
+
# unhandled exceptions reported with the request they came from.
|
|
8
|
+
#
|
|
9
|
+
# use NaijaCloud::Argus::Rack # config.ru, Sinatra, Hanami, Roda
|
|
10
|
+
#
|
|
11
|
+
# Rails does not need this line: the Railtie inserts it.
|
|
12
|
+
class Rack
|
|
13
|
+
# Headers worth seeing on an error. An allowlist, not a denylist: a
|
|
14
|
+
# header nobody thought of is not sent. Cookie is never sent (spec §7).
|
|
15
|
+
HEADERS = %w[
|
|
16
|
+
HTTP_HOST HTTP_USER_AGENT HTTP_ACCEPT HTTP_ACCEPT_LANGUAGE HTTP_REFERER
|
|
17
|
+
HTTP_X_REQUEST_ID HTTP_X_FORWARDED_PROTO HTTP_X_FORWARDED_HOST HTTP_TRACEPARENT
|
|
18
|
+
CONTENT_TYPE CONTENT_LENGTH
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Where frameworks leave an exception they rendered as an error page
|
|
22
|
+
# instead of letting it raise through us.
|
|
23
|
+
EXCEPTION_KEYS = %w[action_dispatch.exception rack.exception sinatra.error].freeze
|
|
24
|
+
|
|
25
|
+
def initialize(app)
|
|
26
|
+
@app = app
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call(env)
|
|
30
|
+
client = Argus.client
|
|
31
|
+
return @app.call(env) unless client&.enabled? && !env["naijacloud.argus.span"]
|
|
32
|
+
|
|
33
|
+
client.with_scope { |scope| call_with(client, scope, env) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The route template for a request, once routing has happened:
|
|
37
|
+
# "/users/:id", or nil when the framework does not say.
|
|
38
|
+
def self.route(env)
|
|
39
|
+
if (pattern = env["action_dispatch.route_uri_pattern"])
|
|
40
|
+
return pattern.to_s.sub(/\(\.:format\)\z/, "")
|
|
41
|
+
end
|
|
42
|
+
if (sinatra = env["sinatra.route"])
|
|
43
|
+
return sinatra.to_s.split(" ", 2).last
|
|
44
|
+
end
|
|
45
|
+
if (grape = env["grape.routing_args"]) && grape.is_a?(Hash) && (info = grape[:route_info])
|
|
46
|
+
return info.path.to_s.sub(/\(\.:format\)\z/, "") if info.respond_to?(:path)
|
|
47
|
+
end
|
|
48
|
+
nil
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# "UsersController#show" for Rails before 7.1, where the URI pattern is
|
|
54
|
+
# not in the env.
|
|
55
|
+
def self.controller_action(env)
|
|
56
|
+
params = env["action_dispatch.request.path_parameters"]
|
|
57
|
+
return nil unless params.is_a?(Hash) && params[:controller] && params[:action]
|
|
58
|
+
|
|
59
|
+
"#{params[:controller]}##{params[:action]}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.url(env)
|
|
63
|
+
scheme = env["HTTP_X_FORWARDED_PROTO"].to_s.split(",").first || env["rack.url_scheme"] || "http"
|
|
64
|
+
host = env["HTTP_HOST"] || "#{env['SERVER_NAME']}:#{env['SERVER_PORT']}"
|
|
65
|
+
"#{scheme.strip}://#{host}#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def call_with(client, scope, env)
|
|
71
|
+
method = env["REQUEST_METHOD"].to_s
|
|
72
|
+
path = "#{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
|
|
73
|
+
url = Scrub.strip_query(Rack.url(env))
|
|
74
|
+
span = client.start_span(
|
|
75
|
+
"#{method} #{path}",
|
|
76
|
+
kind: Span::KIND_SERVER,
|
|
77
|
+
parent: Span.parse_traceparent(env["HTTP_TRACEPARENT"]),
|
|
78
|
+
attributes: { "http.request.method" => method, "url.full" => url, "url.path" => path,
|
|
79
|
+
"server.address" => env["HTTP_HOST"] || env["SERVER_NAME"] },
|
|
80
|
+
)
|
|
81
|
+
env["naijacloud.argus.span"] = span
|
|
82
|
+
scope.request = request_info(client, env, method, url)
|
|
83
|
+
scope.transaction = -> { name_for(env, method) }
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
status, headers, body = Span.with(span) { @app.call(env) }
|
|
87
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised
|
|
88
|
+
finish(span, env, method, 500)
|
|
89
|
+
unless e.is_a?(SignalException) || e.is_a?(SystemExit)
|
|
90
|
+
client.capture_exception(e, tags: { mechanism: "rack" }, transaction: name_for(env, method))
|
|
91
|
+
end
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
status = status.to_i
|
|
96
|
+
if status >= 500 && (err = EXCEPTION_KEYS.lazy.map { |k| env[k] }.find { |x| x.is_a?(Exception) })
|
|
97
|
+
client.capture_exception(err, tags: { mechanism: "rack" }, transaction: name_for(env, method))
|
|
98
|
+
end
|
|
99
|
+
finish(span, env, method, status)
|
|
100
|
+
[status, headers, body]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def finish(span, env, method, status)
|
|
104
|
+
route = Rack.route(env)
|
|
105
|
+
span.set_attribute("http.route", route) if route
|
|
106
|
+
span.name = name_for(env, method)
|
|
107
|
+
span.set_attribute("http.response.status_code", status)
|
|
108
|
+
span.finish(status >= 500 ? "HTTP #{status}" : nil)
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def name_for(env, method)
|
|
114
|
+
route = Rack.route(env)
|
|
115
|
+
return "#{method} #{route}" if route
|
|
116
|
+
|
|
117
|
+
Rack.controller_action(env) || "#{method} #{env['SCRIPT_NAME']}#{env['PATH_INFO']}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def request_info(client, env, method, url)
|
|
121
|
+
headers = {}
|
|
122
|
+
HEADERS.each do |key|
|
|
123
|
+
next unless env[key]
|
|
124
|
+
|
|
125
|
+
name = key.sub(/\AHTTP_/, "").split("_").map(&:capitalize).join("-")
|
|
126
|
+
headers[name] = env[key].to_s
|
|
127
|
+
end
|
|
128
|
+
info = { method: method, url: url, headers: headers }
|
|
129
|
+
info[:env] = { "REMOTE_ADDR" => env["REMOTE_ADDR"] } if client.config.send_default_pii && env["REMOTE_ADDR"]
|
|
130
|
+
info
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NaijaCloud
|
|
4
|
+
module Argus
|
|
5
|
+
module Integrations
|
|
6
|
+
# Reports errors from Rails' error reporter (Rails 7.0+): unhandled
|
|
7
|
+
# request and job errors, and anything an app passes to
|
|
8
|
+
# `Rails.error.report` / `Rails.error.handle`.
|
|
9
|
+
class ErrorSubscriber
|
|
10
|
+
def report(error, handled:, severity: :error, context: {}, source: nil)
|
|
11
|
+
client = Argus.client
|
|
12
|
+
return unless client&.enabled?
|
|
13
|
+
|
|
14
|
+
tags = { "handled" => handled.to_s, "mechanism" => "rails.error" }
|
|
15
|
+
tags["source"] = source.to_s if source
|
|
16
|
+
client.capture_exception(error, level: severity, tags: tags, extra: context.is_a?(Hash) ? context : nil)
|
|
17
|
+
rescue StandardError
|
|
18
|
+
nil
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# ActiveJob: each perform gets its own scope and span, and a failure is
|
|
23
|
+
# reported with the job's class, id and queue. The exception is
|
|
24
|
+
# re-raised, so retry_on / discard_on behave as before.
|
|
25
|
+
module ActiveJob
|
|
26
|
+
def self.included(base)
|
|
27
|
+
base.around_perform do |job, block|
|
|
28
|
+
NaijaCloud::Argus::Integrations::ActiveJob.around(job) { block.call }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.around(job)
|
|
33
|
+
client = Argus.client
|
|
34
|
+
return yield unless client&.enabled?
|
|
35
|
+
|
|
36
|
+
name = job.class.name.to_s
|
|
37
|
+
client.with_scope do |scope|
|
|
38
|
+
scope.transaction = name
|
|
39
|
+
scope.set_tag("job.queue", job.queue_name) if job.respond_to?(:queue_name)
|
|
40
|
+
scope.set_context("job", {
|
|
41
|
+
class: name,
|
|
42
|
+
job_id: job.job_id,
|
|
43
|
+
queue: (job.queue_name if job.respond_to?(:queue_name)),
|
|
44
|
+
executions: (job.executions if job.respond_to?(:executions)),
|
|
45
|
+
provider_job_id: (job.provider_job_id if job.respond_to?(:provider_job_id)),
|
|
46
|
+
})
|
|
47
|
+
attrs = { "messaging.system" => "active_job", "job.id" => job.job_id }
|
|
48
|
+
attrs["messaging.destination.name"] = job.queue_name if job.respond_to?(:queue_name)
|
|
49
|
+
begin
|
|
50
|
+
client.trace(name, attrs) { yield }
|
|
51
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- re-raised
|
|
52
|
+
client.capture_exception(e, tags: { mechanism: "active_job" }) unless e.is_a?(SignalException)
|
|
53
|
+
raise
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
module RailsSetup
|
|
60
|
+
module_function
|
|
61
|
+
|
|
62
|
+
# Called after the app's own initializers, so an explicit
|
|
63
|
+
# `NaijaCloud::Argus.init` in config/initializers wins.
|
|
64
|
+
def start(app)
|
|
65
|
+
unless Argus.initialized?
|
|
66
|
+
options = { project_root: ::Rails.root.to_s }
|
|
67
|
+
# Spec §1 defaults the environment to "production". A Rails app
|
|
68
|
+
# knows better than that when nothing was set.
|
|
69
|
+
if blank?(ENV["ARGUS_ENVIRONMENT"]) && blank?(ENV["SENTRY_ENVIRONMENT"])
|
|
70
|
+
options[:environment] = ::Rails.env.to_s
|
|
71
|
+
end
|
|
72
|
+
if blank?(ENV["ARGUS_SERVICE_NAME"]) && blank?(ENV["OTEL_SERVICE_NAME"])
|
|
73
|
+
options[:service_name] = app_name(app)
|
|
74
|
+
end
|
|
75
|
+
Argus.init(**options)
|
|
76
|
+
end
|
|
77
|
+
attach_logs if Argus.client&.config&.rails_logs && Argus.enabled?
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
warn("[argus] Rails setup failed: #{e.class}: #{e.message}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def attach_logs
|
|
83
|
+
sink = Argus::Logger.new(level: ::Rails.logger.level)
|
|
84
|
+
if ::Rails.logger.respond_to?(:broadcast_to)
|
|
85
|
+
::Rails.logger.broadcast_to(sink)
|
|
86
|
+
elsif defined?(::ActiveSupport::Logger) && ::ActiveSupport::Logger.respond_to?(:broadcast)
|
|
87
|
+
::Rails.logger.extend(::ActiveSupport::Logger.broadcast(sink))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `Shop::Application` -> "shop"; a top-level `ShopApp` -> "shop_app".
|
|
92
|
+
def app_name(app)
|
|
93
|
+
klass = app.class
|
|
94
|
+
parent = klass.module_parent_name if klass.respond_to?(:module_parent_name)
|
|
95
|
+
parent ||= klass.name.to_s.split("::").first.to_s.sub(/Application\z/, "")
|
|
96
|
+
name = parent.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
97
|
+
name.empty? ? nil : name
|
|
98
|
+
rescue StandardError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def blank?(value)
|
|
103
|
+
value.nil? || value.strip.empty?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# `bundle add naijacloud-argus` plus `ARGUS_DSN` is the whole Rails setup.
|
|
109
|
+
class Railtie < ::Rails::Railtie
|
|
110
|
+
initializer "naijacloud_argus.middleware" do |app|
|
|
111
|
+
# First in the stack, so the request span covers every other
|
|
112
|
+
# middleware and the scope is open before anything can fail.
|
|
113
|
+
app.config.middleware.insert(0, NaijaCloud::Argus::Rack)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
initializer "naijacloud_argus.active_job" do
|
|
117
|
+
ActiveSupport.on_load(:active_job) { include NaijaCloud::Argus::Integrations::ActiveJob }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
initializer "naijacloud_argus.error_subscriber" do
|
|
121
|
+
if ::Rails.respond_to?(:error) && ::Rails.error.respond_to?(:subscribe)
|
|
122
|
+
::Rails.error.subscribe(NaijaCloud::Argus::Integrations::ErrorSubscriber.new)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
config.after_initialize do |app|
|
|
127
|
+
NaijaCloud::Argus::Integrations::RailsSetup.start(app)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module NaijaCloud
|
|
6
|
+
module Argus
|
|
7
|
+
# A ::Logger that sends each line to Argus Logs instead of writing it
|
|
8
|
+
# anywhere. Use it as a broadcast target next to your existing logger:
|
|
9
|
+
#
|
|
10
|
+
# Rails.logger.broadcast_to(NaijaCloud::Argus::Logger.new(level: :info)) # Rails 7.1+
|
|
11
|
+
# logger = NaijaCloud::Argus::Logger.new # plain Ruby
|
|
12
|
+
#
|
|
13
|
+
# or set `rails_logs = true` and the Railtie attaches one for you. A line
|
|
14
|
+
# written inside a request carries that request's trace id.
|
|
15
|
+
class Logger < ::Logger
|
|
16
|
+
SEVERITIES = {
|
|
17
|
+
::Logger::DEBUG => :debug, ::Logger::INFO => :info, ::Logger::WARN => :warn,
|
|
18
|
+
::Logger::ERROR => :error, ::Logger::FATAL => :fatal, ::Logger::UNKNOWN => :fatal
|
|
19
|
+
}.freeze
|
|
20
|
+
ANSI = /\e\[[0-9;]*m/.freeze
|
|
21
|
+
|
|
22
|
+
def initialize(level: ::Logger::DEBUG, progname: nil)
|
|
23
|
+
super(nil, level: level, progname: progname)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add(severity, message = nil, progname = nil)
|
|
27
|
+
severity ||= ::Logger::UNKNOWN
|
|
28
|
+
return true if severity < level || Transport.internal?
|
|
29
|
+
|
|
30
|
+
if message.nil?
|
|
31
|
+
if block_given?
|
|
32
|
+
message = yield
|
|
33
|
+
else
|
|
34
|
+
message = progname
|
|
35
|
+
progname = @progname
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
text = case message
|
|
39
|
+
when ::String then message
|
|
40
|
+
when ::Exception then "#{message.class}: #{message.message}"
|
|
41
|
+
else message.inspect
|
|
42
|
+
end
|
|
43
|
+
text = text.gsub(ANSI, "").strip
|
|
44
|
+
return true if text.empty?
|
|
45
|
+
|
|
46
|
+
attrs = progname ? { "logger.name" => progname.to_s } : nil
|
|
47
|
+
Argus.safely { Argus.client&.log(SEVERITIES.fetch(severity, :info), text, attrs) }
|
|
48
|
+
true
|
|
49
|
+
end
|
|
50
|
+
alias log add
|
|
51
|
+
|
|
52
|
+
# Nothing to write to, so nothing to reopen or close.
|
|
53
|
+
def reopen(_logdev = nil)
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def close; end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NaijaCloud
|
|
4
|
+
module Argus
|
|
5
|
+
# OTLP/HTTP JSON builders (spec §5): only the fields Argus reads. JSON, not
|
|
6
|
+
# protobuf, so the gem needs no protobuf dependency.
|
|
7
|
+
module OTLP
|
|
8
|
+
SEVERITY = { trace: 1, debug: 5, info: 9, warn: 13, error: 17, fatal: 21 }.freeze
|
|
9
|
+
SCOPE = { name: "argus" }.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def attrs(hash)
|
|
14
|
+
return [] if hash.nil?
|
|
15
|
+
|
|
16
|
+
hash.each_with_object([]) do |(key, v), out|
|
|
17
|
+
next if v.nil?
|
|
18
|
+
|
|
19
|
+
value = case v
|
|
20
|
+
when true, false then { boolValue: v }
|
|
21
|
+
when Integer then { intValue: v.to_s }
|
|
22
|
+
when Float then v.finite? ? { doubleValue: v } : { stringValue: v.to_s }
|
|
23
|
+
else { stringValue: v.to_s }
|
|
24
|
+
end
|
|
25
|
+
out << { key: key.to_s, value: value }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Unix nanoseconds as a string: JSON numbers lose precision past 2**53.
|
|
30
|
+
def nanos(time_s)
|
|
31
|
+
(time_s * 1_000_000_000).round.to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def logs(resource, records)
|
|
35
|
+
{
|
|
36
|
+
resourceLogs: [{
|
|
37
|
+
resource: { attributes: attrs(resource) },
|
|
38
|
+
scopeLogs: [{
|
|
39
|
+
scope: SCOPE,
|
|
40
|
+
logRecords: records.map do |r|
|
|
41
|
+
rec = {
|
|
42
|
+
timeUnixNano: nanos(r[:time]),
|
|
43
|
+
severityNumber: SEVERITY.fetch(r[:severity]),
|
|
44
|
+
severityText: r[:severity].to_s.upcase,
|
|
45
|
+
body: { stringValue: r[:body] },
|
|
46
|
+
attributes: attrs(r[:attributes]),
|
|
47
|
+
}
|
|
48
|
+
rec[:traceId] = r[:trace_id] if r[:trace_id]
|
|
49
|
+
rec[:spanId] = r[:span_id] if r[:span_id]
|
|
50
|
+
rec
|
|
51
|
+
end,
|
|
52
|
+
}],
|
|
53
|
+
}],
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def traces(resource, spans)
|
|
58
|
+
{
|
|
59
|
+
resourceSpans: [{
|
|
60
|
+
resource: { attributes: attrs(resource) },
|
|
61
|
+
scopeSpans: [{
|
|
62
|
+
scope: SCOPE,
|
|
63
|
+
spans: spans.map do |s|
|
|
64
|
+
span = { traceId: s[:trace_id], spanId: s[:span_id] }
|
|
65
|
+
span[:parentSpanId] = s[:parent_span_id] if s[:parent_span_id]
|
|
66
|
+
span.merge(
|
|
67
|
+
name: s[:name],
|
|
68
|
+
kind: s[:kind],
|
|
69
|
+
startTimeUnixNano: nanos(s[:start]),
|
|
70
|
+
endTimeUnixNano: nanos(s[:end]),
|
|
71
|
+
attributes: attrs(s[:attributes]),
|
|
72
|
+
status: s[:error] ? { code: 2, message: s[:error] } : { code: 1 },
|
|
73
|
+
)
|
|
74
|
+
end,
|
|
75
|
+
}],
|
|
76
|
+
}],
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def metrics(resource, points)
|
|
81
|
+
metrics = points.map do |p|
|
|
82
|
+
base = { name: p[:name] }
|
|
83
|
+
base[:unit] = p[:unit] if p[:unit]
|
|
84
|
+
attributes = attrs(p[:attributes])
|
|
85
|
+
case p[:kind]
|
|
86
|
+
when :sum
|
|
87
|
+
base.merge(sum: {
|
|
88
|
+
aggregationTemporality: 1, # delta (spec §5)
|
|
89
|
+
isMonotonic: true,
|
|
90
|
+
dataPoints: [{ attributes: attributes, startTimeUnixNano: nanos(p[:start]),
|
|
91
|
+
timeUnixNano: nanos(p[:end]), asDouble: p[:value].to_f }],
|
|
92
|
+
})
|
|
93
|
+
when :gauge
|
|
94
|
+
base.merge(gauge: { dataPoints: [{ attributes: attributes, timeUnixNano: nanos(p[:end]), asDouble: p[:value].to_f }] })
|
|
95
|
+
else
|
|
96
|
+
base.merge(histogram: {
|
|
97
|
+
aggregationTemporality: 1,
|
|
98
|
+
dataPoints: [{
|
|
99
|
+
attributes: attributes,
|
|
100
|
+
startTimeUnixNano: nanos(p[:start]),
|
|
101
|
+
timeUnixNano: nanos(p[:end]),
|
|
102
|
+
count: p[:count].to_s,
|
|
103
|
+
sum: p[:sum].to_f,
|
|
104
|
+
min: p[:min].to_f,
|
|
105
|
+
max: p[:max].to_f,
|
|
106
|
+
explicitBounds: p[:bounds],
|
|
107
|
+
bucketCounts: p[:buckets].map(&:to_s),
|
|
108
|
+
}],
|
|
109
|
+
})
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
{ resourceMetrics: [{ resource: { attributes: attrs(resource) }, scopeMetrics: [{ scope: SCOPE, metrics: metrics }] }] }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|