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