foam-otel 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/README.md +76 -0
- data/lib/foam/otel/config.rb +66 -0
- data/lib/foam/otel/constants.rb +62 -0
- data/lib/foam/otel/errors.rb +54 -0
- data/lib/foam/otel/init.rb +351 -0
- data/lib/foam/otel/logger_bridge.rb +75 -0
- data/lib/foam/otel/rack.rb +243 -0
- data/lib/foam/otel/railtie.rb +66 -0
- data/lib/foam/otel/redaction.rb +68 -0
- data/lib/foam/otel/sidekiq.rb +88 -0
- data/lib/foam/otel/stamps.rb +104 -0
- data/lib/foam/otel/vendor/datadog.rb +35 -0
- data/lib/foam/otel/vendor/rollbar.rb +46 -0
- data/lib/foam/otel/version.rb +9 -0
- data/lib/foam/otel.rb +23 -0
- data/lib/foam-otel.rb +4 -0
- metadata +154 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rack"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
require_relative "config"
|
|
8
|
+
require_relative "constants"
|
|
9
|
+
require_relative "errors"
|
|
10
|
+
require_relative "redaction"
|
|
11
|
+
|
|
12
|
+
module Foam
|
|
13
|
+
module Otel
|
|
14
|
+
module Rack
|
|
15
|
+
# Inbound HTTP adapter — one span per request named "METHOD route",
|
|
16
|
+
# stamped inbound.http, per contract/SPEC.md sections 5, 6, and 10.
|
|
17
|
+
# Parent: traceparent header -> active context -> root. foam.rid baggage
|
|
18
|
+
# from x-request-id (or generated). request.context event with redacted
|
|
19
|
+
# query/headers/body/ip unless the route is exempted via health_routes.
|
|
20
|
+
# Anti-fabrication: exception event + ERROR only for unhandled raises;
|
|
21
|
+
# >=500 -> ERROR with no fabricated event; clean 4xx -> OK. Streaming
|
|
22
|
+
# (SSE) safe: the span ends when the response body closes, via
|
|
23
|
+
# Rack::BodyProxy — bodies are never buffered.
|
|
24
|
+
class Middleware
|
|
25
|
+
# `auto` is set only when the railtie inserts this middleware; when
|
|
26
|
+
# true, an init(rails: false) opt-out (or init never having run)
|
|
27
|
+
# makes it a passthrough. The check is per-request, so it doesn't
|
|
28
|
+
# depend on init() having run before Rails built the middleware stack
|
|
29
|
+
# (config/initializers run after that). A middleware added manually to
|
|
30
|
+
# a bare-Rack app has auto=false and is always active.
|
|
31
|
+
def initialize(app, auto = false)
|
|
32
|
+
@app = app
|
|
33
|
+
@auto = auto
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(env)
|
|
37
|
+
return @app.call(env) if @auto && !Foam::Otel.rails_enabled?
|
|
38
|
+
|
|
39
|
+
Foam::Otel.check_fork!
|
|
40
|
+
begin
|
|
41
|
+
span, ctx, started_at, headers = start_span(env)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
return @app.call(env) # never crash the request
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
token = OpenTelemetry::Context.attach(ctx)
|
|
47
|
+
begin
|
|
48
|
+
status, resp_headers, body = @app.call(env)
|
|
49
|
+
wrapped = ::Rack::BodyProxy.new(body) do
|
|
50
|
+
finish_span(span, env, status, started_at, headers)
|
|
51
|
+
end
|
|
52
|
+
[status, resp_headers, wrapped]
|
|
53
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
54
|
+
record_unhandled(span, e)
|
|
55
|
+
finish_span(span, env, 500, started_at, headers, error: true)
|
|
56
|
+
raise
|
|
57
|
+
ensure
|
|
58
|
+
OpenTelemetry::Context.detach(token)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def start_span(env)
|
|
65
|
+
method = env["REQUEST_METHOD"] || "UNKNOWN"
|
|
66
|
+
path = env["PATH_INFO"] || "/"
|
|
67
|
+
# Parsed once and threaded to finish (never written into the caller's
|
|
68
|
+
# Rack env).
|
|
69
|
+
headers = request_headers(env)
|
|
70
|
+
|
|
71
|
+
parent_ctx = OpenTelemetry::Context.current
|
|
72
|
+
if headers.key?("traceparent")
|
|
73
|
+
extracted = OpenTelemetry.propagation.extract(headers)
|
|
74
|
+
parent_ctx = extracted if extracted
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
rid = presence(headers[REQUEST_ID_HEADER]) || SecureRandom.hex(16)
|
|
78
|
+
parent_ctx = OpenTelemetry::Baggage.set_value(RID_KEY, rid, context: parent_ctx)
|
|
79
|
+
|
|
80
|
+
span = Foam::Otel.tracer("foam-otel/rack").start_span(
|
|
81
|
+
"HTTP #{method}",
|
|
82
|
+
with_parent: parent_ctx,
|
|
83
|
+
kind: :server,
|
|
84
|
+
attributes: {
|
|
85
|
+
"http.method" => method,
|
|
86
|
+
"http.target" => env["REQUEST_URI"] || path,
|
|
87
|
+
INSTRUMENTATION_KEY => STAMPS::INBOUND_HTTP,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
ctx = OpenTelemetry::Trace.context_with_span(span, parent_context: parent_ctx)
|
|
91
|
+
[span, ctx, monotonic_ms, headers]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def record_unhandled(span, error)
|
|
95
|
+
Errors.record_once(span, error)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def finish_span(span, env, status, started_at, headers, error: false)
|
|
99
|
+
return if span.nil? || !span.recording?
|
|
100
|
+
|
|
101
|
+
route = route_for(env)
|
|
102
|
+
span.name = "#{env['REQUEST_METHOD']} #{route}"
|
|
103
|
+
span.set_attribute("http.status_code", status.to_i)
|
|
104
|
+
span.set_attribute("http.route", route)
|
|
105
|
+
span.set_attribute("http.duration_ms", (monotonic_ms - started_at).round)
|
|
106
|
+
|
|
107
|
+
# An exception recorded by the raise path, the notification hook,
|
|
108
|
+
# the tracker bridge, or record_exception already set ERROR — a
|
|
109
|
+
# thrown-then-mapped error stays recorded (SPEC section 6); never
|
|
110
|
+
# override with OK. Otherwise: >=500 -> ERROR with NO fabricated
|
|
111
|
+
# exception event; anything else (clean 4xx included) -> OK.
|
|
112
|
+
unless error || Errors.recorded?(span)
|
|
113
|
+
span.status = if status.to_i >= 500
|
|
114
|
+
OpenTelemetry::Trace::Status.error("")
|
|
115
|
+
else
|
|
116
|
+
OpenTelemetry::Trace::Status.ok
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
config = Foam::Otel.active_config
|
|
121
|
+
path = env["PATH_INFO"] || "/"
|
|
122
|
+
unless config.health_routes.include?(route) || config.health_routes.include?(path)
|
|
123
|
+
capture_request_context(span, env, headers)
|
|
124
|
+
end
|
|
125
|
+
rescue StandardError
|
|
126
|
+
nil
|
|
127
|
+
ensure
|
|
128
|
+
begin
|
|
129
|
+
span&.finish
|
|
130
|
+
rescue StandardError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Each field is captured under its OWN rescue: a failure serializing
|
|
136
|
+
# one input (e.g. a header carrying invalid UTF-8 that JSON.generate
|
|
137
|
+
# rejects) must not drop the whole request.context event.
|
|
138
|
+
def capture_request_context(span, env, headers)
|
|
139
|
+
keys = Foam::Otel.active_config.redacted_keys
|
|
140
|
+
attrs = {}
|
|
141
|
+
|
|
142
|
+
begin
|
|
143
|
+
query = env["QUERY_STRING"].to_s
|
|
144
|
+
attrs["query"] = Redaction.redact_query(query, keys) unless query.empty?
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
begin
|
|
150
|
+
unless headers.empty?
|
|
151
|
+
redacted = headers.to_h { |k, v| [k, keys.include?(k) ? "[REDACTED]" : safe_utf8(v)] }
|
|
152
|
+
attrs["headers"] = JSON.generate(redacted)
|
|
153
|
+
end
|
|
154
|
+
rescue StandardError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
begin
|
|
159
|
+
body = read_body(env)
|
|
160
|
+
if body && !body.empty?
|
|
161
|
+
redacted = Redaction.redact(body, keys)
|
|
162
|
+
redacted = "#{redacted[0, BODY_TRUNCATION_LIMIT]}...[truncated]" if redacted.length > BODY_TRUNCATION_LIMIT
|
|
163
|
+
attrs["body"] = redacted
|
|
164
|
+
end
|
|
165
|
+
rescue StandardError
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
begin
|
|
170
|
+
ip = env["action_dispatch.remote_ip"]&.to_s || env["REMOTE_ADDR"]
|
|
171
|
+
attrs["ip"] = ip.to_s if ip
|
|
172
|
+
rescue StandardError
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
span.add_event("request.context", attributes: attrs) unless attrs.empty?
|
|
177
|
+
rescue StandardError
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def read_body(env)
|
|
182
|
+
input = env["rack.input"]
|
|
183
|
+
return nil unless input.respond_to?(:read)
|
|
184
|
+
|
|
185
|
+
# Rewind BEFORE reading: at finish time the app has already consumed
|
|
186
|
+
# the stream (Rack 3 never auto-rewinds), so a read-then-rewind
|
|
187
|
+
# starts at EOF. Rewind after too, in case anything reads it later.
|
|
188
|
+
input.rewind if input.respond_to?(:rewind)
|
|
189
|
+
body = input.read(BODY_TRUNCATION_LIMIT)
|
|
190
|
+
input.rewind if input.respond_to?(:rewind)
|
|
191
|
+
safe_utf8(body)
|
|
192
|
+
rescue StandardError
|
|
193
|
+
nil
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def safe_utf8(value)
|
|
197
|
+
return value if value.nil?
|
|
198
|
+
|
|
199
|
+
value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def route_for(env)
|
|
203
|
+
# The route TEMPLATE (e.g. "/users/:id") — a low-cardinality name,
|
|
204
|
+
# matching the path-style http.route the other language cores emit.
|
|
205
|
+
# Rails exposes it as action_dispatch.route_uri_pattern when the
|
|
206
|
+
# journey router populates it; strip the trailing "(.:format)".
|
|
207
|
+
# When it's absent (older Rails, bare Rack, or a route the router
|
|
208
|
+
# didn't stamp) the raw path is the honest fallback — see the
|
|
209
|
+
# cardinality note in the README.
|
|
210
|
+
pattern = env["action_dispatch.route_uri_pattern"]
|
|
211
|
+
return pattern.sub("(.:format)", "") if pattern.is_a?(String) && !pattern.empty?
|
|
212
|
+
|
|
213
|
+
env["PATH_INFO"] || "/"
|
|
214
|
+
rescue StandardError
|
|
215
|
+
env["PATH_INFO"] || "/"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def request_headers(env)
|
|
219
|
+
headers = {}
|
|
220
|
+
env.each do |key, value|
|
|
221
|
+
next unless key.is_a?(String) && key.start_with?("HTTP_")
|
|
222
|
+
|
|
223
|
+
headers[key[5..].downcase.tr("_", "-")] = value.to_s
|
|
224
|
+
end
|
|
225
|
+
%w[CONTENT_TYPE CONTENT_LENGTH].each do |key|
|
|
226
|
+
headers[key.downcase.tr("_", "-")] = env[key].to_s if env[key]
|
|
227
|
+
end
|
|
228
|
+
headers
|
|
229
|
+
rescue StandardError
|
|
230
|
+
{}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def presence(value)
|
|
234
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def monotonic_ms
|
|
238
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "logger_bridge"
|
|
4
|
+
require_relative "rack"
|
|
5
|
+
|
|
6
|
+
module Foam
|
|
7
|
+
module Otel
|
|
8
|
+
# Rails auto-wiring (zero customer effort): inserts the Rack middleware at
|
|
9
|
+
# position 0, records controller exceptions that rescue paths swallow
|
|
10
|
+
# before Rack sees them (ActionDispatch::ShowExceptions renders the 500
|
|
11
|
+
# below any middleware), and attaches the logger bridge as a broadcast
|
|
12
|
+
# target. Loaded by lib/foam/otel.rb when Rails is present, but the hooks
|
|
13
|
+
# only ACTIVATE once init(rails: true) has run (Foam::Otel.rails_enabled?)
|
|
14
|
+
# — merely bundling the gem installs nothing, and rails: false opts out.
|
|
15
|
+
class Railtie < ::Rails::Railtie
|
|
16
|
+
initializer "foam_otel.middleware", before: :build_middleware_stack do |app|
|
|
17
|
+
# Inserted unconditionally (auto: true) so it never depends on init()
|
|
18
|
+
# having run before Rails built the middleware stack. The middleware
|
|
19
|
+
# passes through per-request until rails_enabled? — see Rack::Middleware.
|
|
20
|
+
app.middleware.insert_before(0, Foam::Otel::Rack::Middleware, true)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
config.after_initialize do
|
|
24
|
+
# Installed unconditionally (once), like the middleware — each fires
|
|
25
|
+
# only when rails_enabled?, so activation doesn't depend on init()
|
|
26
|
+
# having run before after_initialize. A late init() therefore gets
|
|
27
|
+
# exception capture and log bridging along with inbound spans, and
|
|
28
|
+
# rails: false keeps them inert.
|
|
29
|
+
unless Foam::Otel::Railtie.hooks_installed
|
|
30
|
+
Foam::Otel::Railtie.hooks_installed = true
|
|
31
|
+
|
|
32
|
+
# Exceptions that escape controller actions surface here even when
|
|
33
|
+
# ShowExceptions (below our middleware) turns them into a rendered
|
|
34
|
+
# 500 — the raise never reaches Rack. Thrown-then-mapped errors are
|
|
35
|
+
# genuine fault signals (SPEC section 6). The shared Errors dedupe
|
|
36
|
+
# (carried on the span) guards against the middleware's own rescue
|
|
37
|
+
# and the tracker bridge double-recording.
|
|
38
|
+
ActiveSupport::Notifications.subscribe("process_action.action_controller") do |*args|
|
|
39
|
+
begin
|
|
40
|
+
if Foam::Otel.rails_enabled?
|
|
41
|
+
payload = args.last
|
|
42
|
+
error = payload[:exception_object] if payload.is_a?(Hash)
|
|
43
|
+
Foam::Otel::Errors.record_once(OpenTelemetry::Trace.current_span, error) if error
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
if ::Rails.logger.respond_to?(:broadcast_to)
|
|
52
|
+
bridge = Foam::Otel::LoggerBridge.new(tagged_source: ::Rails.logger)
|
|
53
|
+
::Rails.logger.broadcast_to(bridge)
|
|
54
|
+
end
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class << self
|
|
62
|
+
attr_accessor :hooks_installed
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
require_relative "constants"
|
|
7
|
+
|
|
8
|
+
module Foam
|
|
9
|
+
module Otel
|
|
10
|
+
# Central redaction engine — the ONE place sensitive keys are stripped.
|
|
11
|
+
# Case-insensitive key match; deep-redacts nested structures with a depth
|
|
12
|
+
# guard; values become the literal "[REDACTED]".
|
|
13
|
+
module Redaction
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def redact_value(obj, keys, depth = 0)
|
|
17
|
+
return "[depth limit]" if depth > MAX_REDACT_DEPTH
|
|
18
|
+
|
|
19
|
+
case obj
|
|
20
|
+
when Array
|
|
21
|
+
obj.map { |item| redact_value(item, keys, depth + 1) }
|
|
22
|
+
when Hash
|
|
23
|
+
obj.each_with_object({}) do |(key, val), result|
|
|
24
|
+
if key.is_a?(String) && keys.include?(key.downcase)
|
|
25
|
+
result[key] = "[REDACTED]"
|
|
26
|
+
else
|
|
27
|
+
result[key] = redact_value(val, keys, depth + 1)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
else
|
|
31
|
+
obj
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Redacts a raw string: JSON payloads are deep-redacted; non-JSON
|
|
36
|
+
# strings pass through unchanged.
|
|
37
|
+
def redact(raw, keys)
|
|
38
|
+
parsed = JSON.parse(raw)
|
|
39
|
+
JSON.generate(redact_value(parsed, keys))
|
|
40
|
+
rescue StandardError
|
|
41
|
+
raw
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Per-key query-string redaction ('a=1&token=x' form), mirroring the
|
|
45
|
+
# other cores: split on '&', split each pair on the FIRST '=', URL-decode
|
|
46
|
+
# the key for matching ONLY, emit the RAW key + '=[REDACTED]' (matched)
|
|
47
|
+
# or the raw pair unchanged — never re-encode, never leak on failure.
|
|
48
|
+
def redact_query(query, keys)
|
|
49
|
+
return query if query.nil? || query.empty?
|
|
50
|
+
|
|
51
|
+
query.split("&", -1).map do |pair|
|
|
52
|
+
eq = pair.index("=")
|
|
53
|
+
next pair if eq.nil?
|
|
54
|
+
|
|
55
|
+
raw_key = pair[0...eq]
|
|
56
|
+
decoded_key = URI.decode_www_form_component(raw_key)
|
|
57
|
+
if keys.include?(decoded_key.downcase)
|
|
58
|
+
"#{raw_key}=[REDACTED]"
|
|
59
|
+
else
|
|
60
|
+
pair
|
|
61
|
+
end
|
|
62
|
+
end.join("&")
|
|
63
|
+
rescue StandardError
|
|
64
|
+
""
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "constants"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Foam
|
|
9
|
+
module Otel
|
|
10
|
+
# Sidekiq consumer adapter (contract SPEC.md sections 2, 3, 10): one root
|
|
11
|
+
# span per job named "sidekiq.consume <queue>", kind consumer, stamped
|
|
12
|
+
# sidekiq.consume. foam.rid baggage is jid-derived (queue consumers with
|
|
13
|
+
# no inbound rid derive it from the job id). Failures record + ERROR and
|
|
14
|
+
# RE-RAISE identically — Sidekiq's retry behavior is untouched.
|
|
15
|
+
# Auto-registered by init() when Sidekiq is present (opt out with
|
|
16
|
+
# `sidekiq: false`).
|
|
17
|
+
module Sidekiq
|
|
18
|
+
def self.install!
|
|
19
|
+
return false if @installed
|
|
20
|
+
return false unless defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
|
|
21
|
+
|
|
22
|
+
::Sidekiq.configure_server do |config|
|
|
23
|
+
config.server_middleware do |chain|
|
|
24
|
+
chain.add(ServerMiddleware)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
@installed = true
|
|
28
|
+
true
|
|
29
|
+
rescue StandardError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class ServerMiddleware
|
|
34
|
+
def call(worker, job, queue)
|
|
35
|
+
Foam::Otel.check_fork!
|
|
36
|
+
begin
|
|
37
|
+
span, ctx = start_span(job, queue)
|
|
38
|
+
rescue StandardError
|
|
39
|
+
return yield # instrumentation must never break job processing
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
token = OpenTelemetry::Context.attach(ctx)
|
|
43
|
+
begin
|
|
44
|
+
yield
|
|
45
|
+
# Never clobber an ERROR recorded during the job (Rollbar hook,
|
|
46
|
+
# record_exception): a thrown-then-mapped error stays ERROR
|
|
47
|
+
# (SPEC section 6/12), matching the Rack adapter.
|
|
48
|
+
span.status = OpenTelemetry::Trace::Status.ok unless Errors.recorded?(span)
|
|
49
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
50
|
+
Errors.record_once(span, e)
|
|
51
|
+
raise
|
|
52
|
+
ensure
|
|
53
|
+
OpenTelemetry::Context.detach(token)
|
|
54
|
+
begin
|
|
55
|
+
span.finish
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def start_span(job, queue)
|
|
65
|
+
jid = job["jid"].to_s
|
|
66
|
+
ctx = OpenTelemetry::Baggage.set_value(
|
|
67
|
+
RID_KEY, jid.empty? ? SecureRandom.hex(16) : jid,
|
|
68
|
+
context: OpenTelemetry::Context.current
|
|
69
|
+
)
|
|
70
|
+
span = Foam::Otel.tracer("foam-otel/sidekiq").start_span(
|
|
71
|
+
"sidekiq.consume #{queue}",
|
|
72
|
+
with_parent: ctx,
|
|
73
|
+
kind: :consumer,
|
|
74
|
+
attributes: {
|
|
75
|
+
INSTRUMENTATION_KEY => STAMPS::SIDEKIQ_CONSUME,
|
|
76
|
+
"messaging.system" => "sidekiq",
|
|
77
|
+
"messaging.destination" => queue.to_s,
|
|
78
|
+
"sidekiq.job_class" => (job["wrapped"] || job["class"]).to_s,
|
|
79
|
+
"sidekiq.jid" => jid,
|
|
80
|
+
"sidekiq.retry_count" => job["retry_count"].to_i,
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
[span, OpenTelemetry::Trace.context_with_span(span, parent_context: ctx)]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constants"
|
|
4
|
+
require_relative "vendor/datadog"
|
|
5
|
+
|
|
6
|
+
module Foam
|
|
7
|
+
module Otel
|
|
8
|
+
# Shared stamping logic for the span and log processors: default
|
|
9
|
+
# foam.instrumentation to app.custom, copy foam.rid from baggage, and
|
|
10
|
+
# attach dd.trace_id/dd.span_id verbatim when a Datadog trace is active
|
|
11
|
+
# (SPEC section 7.3). One implementation so spans and logs can never
|
|
12
|
+
# disagree. `get`/`set` abstract the two write shapes (span setter vs a
|
|
13
|
+
# plain attributes hash). Returns the number of attributes ADDED.
|
|
14
|
+
module Stamping
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# The stamp attributes to apply, as [[key, value], ...]. The caller
|
|
18
|
+
# sets each only when absent. No per-call closures (this is the hottest
|
|
19
|
+
# path); Datadog correlation is resolved once here, not per attribute.
|
|
20
|
+
def candidates(context)
|
|
21
|
+
pairs = [[INSTRUMENTATION_KEY, STAMPS::APP_CUSTOM]]
|
|
22
|
+
rid = baggage_rid(context)
|
|
23
|
+
pairs << [RID_KEY, rid] if rid
|
|
24
|
+
Vendor::Datadog.correlation_attributes&.each { |key, value| pairs << [key, value] }
|
|
25
|
+
pairs
|
|
26
|
+
rescue StandardError
|
|
27
|
+
pairs || []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def baggage_rid(context)
|
|
31
|
+
value = OpenTelemetry::Baggage.value(RID_KEY, context: context)
|
|
32
|
+
value.is_a?(String) && !value.empty? ? value : nil
|
|
33
|
+
rescue StandardError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class StampSpanProcessor
|
|
39
|
+
def on_start(span, parent_context)
|
|
40
|
+
# One frozen-clone read of the attributes to test presence against.
|
|
41
|
+
current = span.attributes
|
|
42
|
+
Stamping.candidates(parent_context).each do |key, value|
|
|
43
|
+
span.set_attribute(key, value) if current[key].nil?
|
|
44
|
+
end
|
|
45
|
+
rescue StandardError
|
|
46
|
+
nil # stamping must never break telemetry
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def on_finish(span); end
|
|
50
|
+
|
|
51
|
+
def force_flush(timeout: nil)
|
|
52
|
+
OpenTelemetry::SDK::Trace::Export::SUCCESS
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def shutdown(timeout: nil)
|
|
56
|
+
OpenTelemetry::SDK::Trace::Export::SUCCESS
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class StampLogRecordProcessor
|
|
61
|
+
def on_emit(log_record, context)
|
|
62
|
+
attributes = log_record.attributes ||= {}
|
|
63
|
+
added = 0
|
|
64
|
+
Stamping.candidates(context).each do |key, value|
|
|
65
|
+
next unless attributes[key].nil?
|
|
66
|
+
|
|
67
|
+
attributes[key] = value
|
|
68
|
+
added += 1
|
|
69
|
+
end
|
|
70
|
+
sync_recorded_count(log_record, added)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def force_flush(timeout: nil)
|
|
76
|
+
OpenTelemetry::SDK::Logs::Export::SUCCESS
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def shutdown(timeout: nil)
|
|
80
|
+
OpenTelemetry::SDK::Logs::Export::SUCCESS
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# The logs SDK snapshots total_recorded_attributes at record
|
|
86
|
+
# construction and the OTLP exporter encodes
|
|
87
|
+
# dropped = total_recorded - current_size; attributes ADDED by a
|
|
88
|
+
# processor make that negative and the uint32 protobuf field rejects
|
|
89
|
+
# the whole batch. Bump the snapshot by exactly the number we added
|
|
90
|
+
# (NOT max(), which would under-report genuine limit-drops). Guarded
|
|
91
|
+
# so a future SDK ivar rename can't crash — but a spec asserts the ivar
|
|
92
|
+
# still exists so the guard can't silently mask the regression.
|
|
93
|
+
def sync_recorded_count(log_record, added)
|
|
94
|
+
return if added.zero?
|
|
95
|
+
return unless log_record.instance_variable_defined?(:@total_recorded_attributes)
|
|
96
|
+
|
|
97
|
+
total = log_record.instance_variable_get(:@total_recorded_attributes).to_i
|
|
98
|
+
log_record.instance_variable_set(:@total_recorded_attributes, total + added)
|
|
99
|
+
rescue StandardError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Foam
|
|
4
|
+
module Otel
|
|
5
|
+
module Vendor
|
|
6
|
+
# Datadog coexistence (contract SPEC.md section 7.3): correlation-only.
|
|
7
|
+
# Foam never parents from, mutates, or patches the DD tracer — it reads
|
|
8
|
+
# DD's correlation ids VERBATIM (no format conversion; DD ids are
|
|
9
|
+
# 64/128-bit numerics, not W3C hex) and attaches them to foam spans and
|
|
10
|
+
# log records so one record cross-links both backends. No active DD
|
|
11
|
+
# trace -> nil, and the attributes are simply absent (never fabricated).
|
|
12
|
+
module Datadog
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# -> { "dd.trace_id" => ..., "dd.span_id" => ... } or nil. Never raises.
|
|
16
|
+
def correlation_attributes
|
|
17
|
+
return nil unless defined?(::Datadog::Tracing)
|
|
18
|
+
|
|
19
|
+
correlation = ::Datadog::Tracing.correlation
|
|
20
|
+
return nil if correlation.nil?
|
|
21
|
+
|
|
22
|
+
trace_id = correlation.trace_id
|
|
23
|
+
span_id = correlation.span_id
|
|
24
|
+
return nil if trace_id.nil? || trace_id.to_s == "0" || trace_id == 0
|
|
25
|
+
|
|
26
|
+
attrs = { "dd.trace_id" => trace_id.to_s }
|
|
27
|
+
attrs["dd.span_id"] = span_id.to_s unless span_id.nil? || span_id == 0
|
|
28
|
+
attrs
|
|
29
|
+
rescue StandardError
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module Foam
|
|
6
|
+
module Otel
|
|
7
|
+
module Vendor
|
|
8
|
+
# Error-tracker coexistence (contract SPEC.md section 12): the bridge
|
|
9
|
+
# direction is tracker -> foam only. When the app notifies Rollbar of an
|
|
10
|
+
# exception (e.g. inside a rescue_from handler foam's middleware never
|
|
11
|
+
# sees), the exception is also recorded on the active foam span — then
|
|
12
|
+
# Rollbar proceeds exactly as before (side-effect-free observation:
|
|
13
|
+
# arguments, result, and raise behavior untouched). Installed by
|
|
14
|
+
# presence detection at init(); opt out with `rollbar: false`. The
|
|
15
|
+
# shared Errors registry guarantees one exception event per span even
|
|
16
|
+
# when Rollbar's own middleware AND foam's middleware see the same raise.
|
|
17
|
+
module Rollbar
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def install!
|
|
21
|
+
return false unless defined?(::Rollbar)
|
|
22
|
+
return false if @installed
|
|
23
|
+
|
|
24
|
+
::Rollbar.singleton_class.prepend(Hook)
|
|
25
|
+
@installed = true
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
module Hook
|
|
30
|
+
# Rollbar.log(level, *args) is the funnel every Rollbar.error/
|
|
31
|
+
# critical/warning call goes through. Observe, then defer to Rollbar
|
|
32
|
+
# unchanged.
|
|
33
|
+
def log(level, *args, **kwargs, &block)
|
|
34
|
+
begin
|
|
35
|
+
error = args.find { |arg| arg.is_a?(Exception) }
|
|
36
|
+
Foam::Otel::Errors.record_once(OpenTelemetry::Trace.current_span, error) if error
|
|
37
|
+
rescue StandardError
|
|
38
|
+
# observation must never break tracker reporting
|
|
39
|
+
end
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/foam/otel.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# foam-otel — the shared foam OpenTelemetry core for Ruby services,
|
|
4
|
+
# implementing contract/SPEC.md (see that file for every ruling).
|
|
5
|
+
#
|
|
6
|
+
# Foam::Otel.init(service_name: "my-service")
|
|
7
|
+
#
|
|
8
|
+
# Zero customer effort: the Rails railtie, Rack middleware, Sidekiq server
|
|
9
|
+
# middleware, logger bridge, and vendor coexistence (Datadog correlation,
|
|
10
|
+
# Rollbar hook) all self-wire by presence detection. Opt-outs only.
|
|
11
|
+
|
|
12
|
+
require_relative "otel/version"
|
|
13
|
+
require_relative "otel/constants"
|
|
14
|
+
require_relative "otel/config"
|
|
15
|
+
require_relative "otel/redaction"
|
|
16
|
+
require_relative "otel/errors"
|
|
17
|
+
require_relative "otel/init"
|
|
18
|
+
require_relative "otel/rack"
|
|
19
|
+
require_relative "otel/logger_bridge"
|
|
20
|
+
require_relative "otel/vendor/datadog"
|
|
21
|
+
require_relative "otel/vendor/rollbar"
|
|
22
|
+
|
|
23
|
+
require_relative "otel/railtie" if defined?(::Rails::Railtie)
|
data/lib/foam-otel.rb
ADDED