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.
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ # The publish pipeline parses this literal (tools/publish-packages) —
6
+ # strict semver only; gem-style prereleases (0.1.0.alpha1) are unsupported.
7
+ VERSION = "0.1.0"
8
+ end
9
+ 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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler default-require shim: `gem "foam-otel"` loads the real entrypoint.
4
+ require_relative "foam/otel"