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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8650474eaaa3dec06f5500839b073d5c2b211750da4ad2a068459cbbb5feb3f1
4
+ data.tar.gz: 6c7b40fe1a9297f0c81547222f4ea7a36c716a9bc1eb6a2f97c5e44d49f6b9eb
5
+ SHA512:
6
+ metadata.gz: 39fd5531d0478acdff0ae4e36ae901ee3975a2b2c55e84319bba959683ac3fa69dd226639f2c1c7f4682eec9d064843071209f7ed56bf149d30591cecc732ba8
7
+ data.tar.gz: b32886c34c0b60fd1f7751a7cb7e1a81152804c7c495078217ef7e63a83025b15b5336acffad32ebc23e730c42120aa8e32519cd3a9d1154f049130b75ad8160
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # foam-otel
2
+
3
+ The shared foam OpenTelemetry core for Ruby services — traces, logs, and
4
+ metrics over OTLP HTTP, implementing the fleet wire contract
5
+ ([`contract/SPEC.md`](../../contract/SPEC.md)). Verified end-to-end by the
6
+ `test-apps/ruby-rails` conformance gate on every PR.
7
+
8
+ ```ruby
9
+ # Gemfile
10
+ gem "foam-otel"
11
+
12
+ # config/initializers/foam.rb (or before Rails loads)
13
+ Foam::Otel.init(service_name: "my-service")
14
+ ```
15
+
16
+ That's the whole integration. Everything else self-wires by presence
17
+ detection (zero customer effort):
18
+
19
+ - **Rails**: railtie inserts the Rack middleware at position 0, captures
20
+ controller exceptions that `rescue_from` swallows (via
21
+ `process_action.action_controller`), and broadcasts `Rails.logger` lines to
22
+ foam as `log.rails` records — the app's own logger chain is never touched.
23
+ - **Sidekiq**: server middleware auto-registers; one `sidekiq.consume <queue>`
24
+ consumer span per job, failures re-raised identically (retries untouched).
25
+ - **Datadog** (SPEC §7.3): foam spans and logs carry `dd.trace_id`/`dd.span_id`
26
+ verbatim from the DD correlation API when a DD trace is active. Foam never
27
+ patches, parents from, or reconfigures the DD tracer.
28
+ - **Rollbar** (SPEC §12): exceptions the app reports to Rollbar (e.g. inside
29
+ `rescue_from` handlers) are also recorded on the active foam span — Rollbar
30
+ itself is untouched, and each exception records at most once per span.
31
+
32
+ | option | default | |
33
+ |---|---|---|
34
+ | `service_name:` | (required) | resource `service.name` |
35
+ | `token:` | `FOAM_OTEL_TOKEN` env | missing → warn + inert |
36
+ | `endpoint:` | `OTEL_EXPORTER_OTLP_ENDPOINT` env → `https://otel.api.foam.ai` | |
37
+ | `force_export:` | `false` | export outside production |
38
+ | `redact_contact_info:` | `false` | append email/phone/telefono/celular to redaction |
39
+ | `redact_keys:` | `[]` | extra redacted keys |
40
+ | `health_routes:` | `[]` | routes exempted from request-context capture (none by default — health checks are captured) |
41
+ | `sidekiq:` / `rollbar:` / `rails:` | `true` | opt out of auto-wiring that adapter |
42
+
43
+ Export is production-gated (`RAILS_ENV`/`RACK_ENV`/`ENVIRONMENT`/`ENV`/
44
+ `NODE_ENV` ∈ {production, prod}) unless `force_export`. Batch cadence comes
45
+ from the standard OTel env vars (`OTEL_BSP_SCHEDULE_DELAY`,
46
+ `OTEL_BLRP_SCHEDULE_DELAY`, `OTEL_METRIC_EXPORT_INTERVAL`).
47
+
48
+ **Route names**: span names and `http.route` use Rails' route template
49
+ (`action_dispatch.route_uri_pattern`, e.g. `/users/:id`) when available. On
50
+ older Rails or bare Rack where it isn't populated, the raw path is used —
51
+ which raises span-name cardinality for routes with dynamic segments; prefer
52
+ Rails 7.1+ for automatic low-cardinality route names.
53
+
54
+ **Forked servers** (Puma workers, Sidekiq): span/log pipelines self-heal
55
+ after fork; the metrics reader is re-armed automatically by the first
56
+ request/job in the child via a pid check. Belt-and-braces:
57
+ `on_worker_boot { Foam::Otel.after_fork! }`.
58
+
59
+ Custom telemetry passthrough (stamped `app.custom` automatically). Create
60
+ each instrument ONCE and reuse it — creating an instrument per call
61
+ re-registers it under a mutex and logs a duplicate-registration warning:
62
+
63
+ ```ruby
64
+ Foam::Otel.tracer.in_span("work") { |span| ... }
65
+
66
+ JOBS_DONE = Foam::Otel.meter.create_counter("jobs_done") # once, at load
67
+ JOBS_DONE.add(1) # per event
68
+
69
+ Foam::Otel.logger.on_emit(severity_number: 9, body: "hi", context: OpenTelemetry::Context.current)
70
+ Foam::Otel.record_exception(error) # foam span only; never notifies a tracker
71
+ ```
72
+
73
+ ```sh
74
+ bundle exec rspec # unit suite
75
+ bash ../../test-apps/ruby-rails/run-verify.sh # full conformance gate
76
+ ```
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ require_relative "constants"
6
+
7
+ module Foam
8
+ module Otel
9
+ # Resolved configuration — options + env vars, per contract/SPEC.md:
10
+ # endpoint is option > OTEL_EXPORTER_OTLP_ENDPOINT > default; batch
11
+ # cadence comes from the standard OTel env vars, which the Ruby SDK reads
12
+ # natively (never hardcoded here).
13
+ Config = Struct.new(:service_name, :endpoint, :redacted_keys, :health_routes, keyword_init: true)
14
+
15
+ DEFAULT_CONFIG = Config.new(
16
+ service_name: "",
17
+ endpoint: FOAM_OTEL_ENDPOINT,
18
+ redacted_keys: DEFAULT_REDACTED_KEYS.to_set.freeze,
19
+ health_routes: DEFAULT_HEALTH_ROUTES.to_set.freeze
20
+ ).freeze
21
+
22
+ @active_config = DEFAULT_CONFIG
23
+
24
+ class << self
25
+ # The config of the most recently initialized service. Adapters read
26
+ # through this so they work (with safe defaults) even before init().
27
+ def active_config
28
+ @active_config
29
+ end
30
+
31
+ def active_config=(config)
32
+ @active_config = config
33
+ end
34
+
35
+ def resolve_endpoint(endpoint)
36
+ resolved = endpoint || ENV.fetch("OTEL_EXPORTER_OTLP_ENDPOINT", nil) || FOAM_OTEL_ENDPOINT
37
+ resolved.sub(%r{/+\z}, "")
38
+ end
39
+
40
+ def resolve_config(service_name:, endpoint: nil, redact_contact_info: false,
41
+ redact_keys: nil, health_routes: nil)
42
+ keys = DEFAULT_REDACTED_KEYS.to_set
43
+ keys.merge(CONTACT_INFO_KEYS) if redact_contact_info
44
+ (redact_keys || []).each { |key| keys.add(key.downcase) }
45
+
46
+ routes = DEFAULT_HEALTH_ROUTES.to_set
47
+ (health_routes || []).each { |route| routes.add(route) }
48
+
49
+ Config.new(
50
+ service_name: service_name,
51
+ endpoint: resolve_endpoint(endpoint),
52
+ redacted_keys: keys.freeze,
53
+ health_routes: routes.freeze
54
+ ).freeze
55
+ end
56
+
57
+ # Production detection across common conventions (RAILS_ENV/RACK_ENV
58
+ # first — the Ruby idioms — then the fleet-shared names).
59
+ def production?
60
+ %w[RAILS_ENV RACK_ENV ENVIRONMENT ENV NODE_ENV].any? do |var|
61
+ %w[production prod].include?(ENV.fetch(var, "").downcase)
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ # Fleet-wide defaults. The canonical source is contract/defaults.json;
6
+ # contract/verify/check-defaults.mjs extracts the literals below from THIS
7
+ # file on every conformance run — keep them flat quoted-string array
8
+ # literals (no %w[], no computed values) or extraction breaks.
9
+
10
+ FOAM_OTEL_ENDPOINT = "https://otel.api.foam.ai"
11
+
12
+ BODY_TRUNCATION_LIMIT = 4096
13
+ MAX_REDACT_DEPTH = 10
14
+
15
+ DEFAULT_REDACTED_KEYS = [
16
+ "password",
17
+ "token",
18
+ "authorization",
19
+ "secret",
20
+ "api_key",
21
+ "apikey",
22
+ "access_token",
23
+ "accesstoken",
24
+ "jwt",
25
+ "x-api-key",
26
+ "x-service-token",
27
+ "cookie",
28
+ "set-cookie",
29
+ "creditcard",
30
+ "credit_card",
31
+ "card_number",
32
+ "cardnumber",
33
+ "cvv",
34
+ "cvc",
35
+ "dni",
36
+ "ssn",
37
+ ].freeze
38
+
39
+ # Opt-in contact-PII list. Enabled via `redact_contact_info: true`.
40
+ CONTACT_INFO_KEYS = ["email", "phone", "telefono", "celular"].freeze
41
+
42
+ # Empty by default: health checks are captured like any other route
43
+ # (contract SPEC.md section 5); services exempt routes via health_routes.
44
+ DEFAULT_HEALTH_ROUTES = [].freeze
45
+
46
+ REQUEST_ID_HEADER = "x-request-id"
47
+
48
+ INSTRUMENTATION_KEY = "foam.instrumentation"
49
+ RID_KEY = "foam.rid"
50
+
51
+ module STAMPS
52
+ INBOUND_HTTP = "inbound.http"
53
+ OUTBOUND_HTTP = "outbound.http"
54
+ SQS_CONSUME = "sqs.consume"
55
+ SIDEKIQ_CONSUME = "sidekiq.consume"
56
+ QUEUE_PRODUCE = "queue.produce"
57
+ LOG_RAILS = "log.rails"
58
+ LOG_CONSOLE = "log.console"
59
+ APP_CUSTOM = "app.custom"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ # One exception, one event (contract SPEC.md section 12): every capture
6
+ # path — the Rack middleware's unhandled-raise rescue, the railtie's
7
+ # process_action notification hook, the error-tracker bridge, and the
8
+ # public record_exception API — records through here, deduped per span by
9
+ # exception object identity.
10
+ #
11
+ # The seen-set lives ON the span object (an instance variable), not in a
12
+ # thread-local: a request span can be recorded on one thread and finished
13
+ # on another (ActionController::Live streams the response body from a
14
+ # separate thread), so a thread-local would both miss the dedupe/recorded
15
+ # check across threads and leak entries for spans that never pass back
16
+ # through their owning thread. Span-carried state is GC'd with the span
17
+ # and travels with it across threads.
18
+ module Errors
19
+ IVAR = :@__foam_otel_recorded
20
+
21
+ module_function
22
+
23
+ # Records `error` on `span` (exception event + status ERROR) unless this
24
+ # exact exception object was already recorded there. Never raises.
25
+ def record_once(span, error)
26
+ return false unless span.respond_to?(:context) && span.context.valid?
27
+ return false unless span.respond_to?(:recording?) && span.recording?
28
+
29
+ seen = span.instance_variable_get(IVAR)
30
+ unless seen
31
+ seen = []
32
+ span.instance_variable_set(IVAR, seen)
33
+ end
34
+ return false if seen.include?(error.object_id)
35
+
36
+ seen << error.object_id
37
+ span.record_exception(error)
38
+ span.status = OpenTelemetry::Trace::Status.error(error.message.to_s)
39
+ true
40
+ rescue StandardError
41
+ false
42
+ end
43
+
44
+ # Whether any exception has been recorded on this span (so a later OK
45
+ # status never clobbers a thrown-then-mapped ERROR — SPEC section 6).
46
+ def recorded?(span)
47
+ seen = span.instance_variable_get(IVAR)
48
+ !seen.nil? && !seen.empty?
49
+ rescue StandardError
50
+ false
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,351 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry/sdk"
4
+ require "opentelemetry-logs-sdk"
5
+ require "opentelemetry-metrics-sdk"
6
+ require "opentelemetry-exporter-otlp"
7
+ require "opentelemetry-exporter-otlp-logs"
8
+ require "opentelemetry-exporter-otlp-metrics"
9
+
10
+ require_relative "config"
11
+ require_relative "stamps"
12
+ require_relative "errors"
13
+ require_relative "vendor/rollbar"
14
+
15
+ module Foam
16
+ module Otel
17
+ # Handle returned by init(). Providers are global — this mostly carries
18
+ # the resolved config and shutdown.
19
+ class Instance
20
+ attr_reader :service_name, :config
21
+ attr_accessor :providers
22
+
23
+ def initialize(service_name, config)
24
+ @service_name = service_name
25
+ @config = config
26
+ @providers = []
27
+ end
28
+
29
+ def shutdown
30
+ if @providers.empty?
31
+ # This instance owns no providers (inert, or a later init whose
32
+ # signals were already wired). It must not tear down the owner's
33
+ # pipelines, but — flush parity — it still best-effort flushes
34
+ # foam's live pipelines so buffered telemetry isn't dropped.
35
+ Foam::Otel.flush_foam_pipelines
36
+ return
37
+ end
38
+ @providers.each do |provider|
39
+ begin
40
+ provider.shutdown
41
+ rescue StandardError
42
+ nil
43
+ end
44
+ Foam::Otel.forget_provider(provider)
45
+ end
46
+ Foam::Otel.clear_fallback_meter_provider_if_in(@providers)
47
+ @providers = []
48
+ end
49
+ end
50
+
51
+ @instances = {}
52
+ # Providers foam itself created AND registered globally. A second init()
53
+ # must not attach another stamp+batch pipeline to them (double export) and
54
+ # must not misclassify them as "another SDK's" provider.
55
+ @foam_providers = []
56
+ # Foreign providers foam already attached its stamp+batch pipeline to.
57
+ @attached_providers = []
58
+ # Live flushable pipelines foam owns (providers foam created + batch
59
+ # processors foam attached to foreign providers).
60
+ @flushables = []
61
+ # Metrics-coexistence fallback (SPEC section 9): when another SDK owns the
62
+ # global MeterProvider, foam's provider lives here and meter() serves it.
63
+ @fallback_meter_provider = nil
64
+ # Foam-owned periodic metric readers — the one pipeline piece that does
65
+ # NOT self-heal across forks (batch span/log processors reset_on_fork
66
+ # automatically; verified upstream) and needs an explicit after_fork.
67
+ @metric_readers = []
68
+ # PID at last check_fork! — re-arms metric readers once per fork.
69
+ @pid = Process.pid
70
+
71
+ class << self
72
+ # rubocop:disable Metrics/MethodLength
73
+ def init(service_name:, token: nil, endpoint: nil, force_export: false,
74
+ redact_contact_info: false, redact_keys: nil, health_routes: nil,
75
+ sidekiq: true, rollbar: true, rails: true)
76
+ existing = @instances[service_name]
77
+ if existing
78
+ warn "[foam] init() already called for \"#{service_name}\" — returning existing instance"
79
+ return existing
80
+ end
81
+ unless @instances.empty?
82
+ warn "[foam] init(\"#{service_name}\") called after #{@instances.keys.sort} — " \
83
+ "the active adapter config is process-global and last-write-wins"
84
+ end
85
+
86
+ # The resolved config becomes the active config on EVERY path — even
87
+ # inert init: another SDK may own the global providers and the
88
+ # adapters still emit real spans through it.
89
+ config = resolve_config(
90
+ service_name: service_name, endpoint: endpoint,
91
+ redact_contact_info: redact_contact_info, redact_keys: redact_keys,
92
+ health_routes: health_routes
93
+ )
94
+ self.active_config = config
95
+
96
+ # Zero-customer-effort: adapters and vendor bridges self-wire by
97
+ # presence detection on every init path (inert included — a foreign
98
+ # SDK may still export what the adapters produce). Opt-outs only.
99
+ auto_wire(sidekiq: sidekiq, rollbar: rollbar, rails: rails)
100
+
101
+ unless production? || force_export
102
+ warn "[foam] skipping init for \"#{service_name}\" (not production, force_export false)"
103
+ return remember(Instance.new(service_name, config))
104
+ end
105
+
106
+ resolved_token = token || ENV.fetch("FOAM_OTEL_TOKEN", nil)
107
+ if resolved_token.nil? || resolved_token.empty?
108
+ warn "[foam] token is missing — telemetry will not be exported. " \
109
+ "Set token or the FOAM_OTEL_TOKEN env var."
110
+ return remember(Instance.new(service_name, config))
111
+ end
112
+
113
+ instance = Instance.new(service_name, config)
114
+ headers = { "Authorization" => "Bearer #{resolved_token}" }
115
+ resource = OpenTelemetry::SDK::Resources::Resource.create("service.name" => service_name)
116
+
117
+ # We bypass OpenTelemetry::SDK.configure, so propagation must be set
118
+ # explicitly: W3C TraceContext + Baggage.
119
+ OpenTelemetry.propagation = OpenTelemetry::Context::Propagation::CompositeTextMapPropagator
120
+ .compose_propagators([
121
+ OpenTelemetry::Trace::Propagation::TraceContext.text_map_propagator,
122
+ OpenTelemetry::Baggage::Propagation.text_map_propagator,
123
+ ])
124
+
125
+ # Each signal is independent — a failure in one never blocks the rest.
126
+ %i[setup_traces setup_logs setup_metrics].each do |setup|
127
+ provider = send(setup, config.endpoint, headers, resource)
128
+ instance.providers << provider if provider
129
+ rescue StandardError => e
130
+ warn "[foam] #{setup} failed: #{e.class}: #{e.message}"
131
+ end
132
+
133
+ at_exit { instance.shutdown }
134
+ warn "[foam] OTLP export enabled for #{service_name} -> #{config.endpoint}"
135
+ remember(instance)
136
+ end
137
+ # rubocop:enable Metrics/MethodLength
138
+
139
+ # Signal passthrough: a tracer/meter/logger that exports to foam after
140
+ # init(). meter() serves the fallback provider when another SDK owns the
141
+ # global MeterProvider (SPEC section 9).
142
+ def tracer(name = "foam")
143
+ OpenTelemetry.tracer_provider.tracer(name)
144
+ end
145
+
146
+ def meter(name = "foam")
147
+ return @fallback_meter_provider.meter(name) if @fallback_meter_provider
148
+
149
+ OpenTelemetry.meter_provider.meter(name)
150
+ end
151
+
152
+ def logger(name = "foam")
153
+ OpenTelemetry.logger_provider.logger(name: name)
154
+ end
155
+
156
+ # Records an exception on the active span (status ERROR) when one
157
+ # exists. Records to the foam span ONLY — never notifies any error
158
+ # tracker (SPEC section 12). Never raises.
159
+ def record_exception(error)
160
+ Errors.record_once(OpenTelemetry::Trace.current_span, error)
161
+ nil
162
+ rescue StandardError
163
+ nil
164
+ end
165
+
166
+ # Metrics readers do not self-heal across forks (unlike the batch
167
+ # span/log processors, which reset_on_fork automatically). Call in
168
+ # Puma's on_worker_boot / Sidekiq lifecycle — the Rack and Sidekiq
169
+ # adapters also invoke it via check_fork! per call.
170
+ def after_fork!
171
+ @metric_readers.each do |reader|
172
+ reader.after_fork if reader.respond_to?(:after_fork)
173
+ rescue StandardError
174
+ nil
175
+ end
176
+ end
177
+
178
+ # One authoritative process-level fork check the adapters call per
179
+ # request/job — re-arms the metric readers exactly once per fork.
180
+ def check_fork!
181
+ pid = Process.pid
182
+ return if @pid == pid
183
+
184
+ @pid = pid
185
+ after_fork!
186
+ end
187
+
188
+ # Whether Rails adapter hooks (middleware / exception notification /
189
+ # logger bridge) should activate. False until an init() with rails:true
190
+ # runs, so merely bundling the gem never installs global Rails hooks —
191
+ # the railtie checks this in after_initialize (opt-out symmetry with
192
+ # sidekiq:/rollbar:).
193
+ def rails_enabled?
194
+ @rails_enabled == true
195
+ end
196
+
197
+ # --- registries (used by Instance#shutdown and the pipelines) ---------
198
+
199
+ def flush_foam_pipelines
200
+ flushables = @flushables.dup
201
+ flushables << @fallback_meter_provider if @fallback_meter_provider
202
+ flushables.each do |flushable|
203
+ flushable.force_flush
204
+ rescue StandardError
205
+ nil
206
+ end
207
+ end
208
+
209
+ def forget_provider(provider)
210
+ @foam_providers.delete_if { |p| p.equal?(provider) }
211
+ @flushables.delete_if { |p| p.equal?(provider) }
212
+ end
213
+
214
+ def clear_fallback_meter_provider_if_in(providers)
215
+ return unless @fallback_meter_provider
216
+
217
+ @fallback_meter_provider = nil if providers.any? { |p| p.equal?(@fallback_meter_provider) }
218
+ end
219
+
220
+ # Test hook: full reset of module state (NOT for production use — OTel
221
+ # globals cannot be re-registered after a real shutdown).
222
+ def reset_for_tests!
223
+ @instances = {}
224
+ @foam_providers = []
225
+ @attached_providers = []
226
+ @flushables = []
227
+ @fallback_meter_provider = nil
228
+ @metric_readers = []
229
+ @pid = Process.pid
230
+ @rails_enabled = false
231
+ self.active_config = DEFAULT_CONFIG
232
+ end
233
+
234
+ private
235
+
236
+ def remember(instance)
237
+ @instances[instance.service_name] = instance
238
+ instance
239
+ end
240
+
241
+ def foam_provider?(provider)
242
+ @foam_providers.any? { |p| p.equal?(provider) }
243
+ end
244
+
245
+ def attached?(provider)
246
+ @attached_providers.any? { |p| p.equal?(provider) }
247
+ end
248
+
249
+ def auto_wire(sidekiq:, rollbar:, rails:)
250
+ # install! calls are individually idempotent, so a later init() can
251
+ # still wire an integration an earlier one opted out of.
252
+ @rails_enabled ||= rails
253
+ Vendor::Rollbar.install! if rollbar
254
+ if sidekiq && defined?(::Sidekiq) && ::Sidekiq.respond_to?(:configure_server)
255
+ require_relative "sidekiq"
256
+ Foam::Otel::Sidekiq.install!
257
+ end
258
+ # Rails hooks activate lazily in the railtie's after_initialize once
259
+ # rails_enabled? is true (see railtie.rb).
260
+ rescue StandardError => e
261
+ warn "[foam] adapter auto-wiring failed: #{e.class}: #{e.message}"
262
+ end
263
+
264
+ def setup_traces(endpoint, headers, resource)
265
+ current = OpenTelemetry.tracer_provider
266
+ return nil if foam_provider?(current)
267
+
268
+ exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
269
+ endpoint: "#{endpoint}/v1/traces", headers: headers
270
+ )
271
+ if current.is_a?(OpenTelemetry::SDK::Trace::TracerProvider)
272
+ return nil if attached?(current)
273
+
274
+ # Another SDK registered a real provider — attach foam's pipeline to
275
+ # it rather than displacing it (SPEC section 9).
276
+ current.add_span_processor(StampSpanProcessor.new)
277
+ batch = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
278
+ current.add_span_processor(batch)
279
+ @attached_providers << current
280
+ @flushables << batch
281
+ return batch
282
+ end
283
+
284
+ provider = OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource)
285
+ provider.add_span_processor(StampSpanProcessor.new)
286
+ provider.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
287
+ OpenTelemetry.tracer_provider = provider
288
+ @foam_providers << provider
289
+ @flushables << provider
290
+ provider
291
+ end
292
+
293
+ def setup_logs(endpoint, headers, resource)
294
+ current = OpenTelemetry.logger_provider
295
+ return nil if foam_provider?(current)
296
+
297
+ exporter = OpenTelemetry::Exporter::OTLP::Logs::LogsExporter.new(
298
+ endpoint: "#{endpoint}/v1/logs", headers: headers
299
+ )
300
+ if current.is_a?(OpenTelemetry::SDK::Logs::LoggerProvider)
301
+ return nil if attached?(current)
302
+
303
+ current.add_log_record_processor(StampLogRecordProcessor.new)
304
+ batch = OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter)
305
+ current.add_log_record_processor(batch)
306
+ @attached_providers << current
307
+ @flushables << batch
308
+ return batch
309
+ end
310
+
311
+ provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: resource)
312
+ provider.add_log_record_processor(StampLogRecordProcessor.new)
313
+ provider.add_log_record_processor(OpenTelemetry::SDK::Logs::Export::BatchLogRecordProcessor.new(exporter))
314
+ OpenTelemetry.logger_provider = provider
315
+ @foam_providers << provider
316
+ @flushables << provider
317
+ provider
318
+ end
319
+
320
+ def setup_metrics(endpoint, headers, resource)
321
+ current = OpenTelemetry.meter_provider
322
+ return nil if foam_provider?(current)
323
+ return nil if @fallback_meter_provider
324
+
325
+ exporter = OpenTelemetry::Exporter::OTLP::Metrics::MetricsExporter.new(
326
+ endpoint: "#{endpoint}/v1/metrics", headers: headers
327
+ )
328
+ reader = OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader.new(exporter: exporter)
329
+ provider = OpenTelemetry::SDK::Metrics::MeterProvider.new(resource: resource)
330
+ provider.add_metric_reader(reader)
331
+ @metric_readers << reader
332
+
333
+ # Ruby's global meter_provider setter does not refuse replacement the
334
+ # way Python's does — so foam refuses for it: when another real SDK
335
+ # MeterProvider owns the global, keep foam's as the fallback served by
336
+ # Foam::Otel.meter (SPEC section 9) instead of displacing theirs.
337
+ if current.is_a?(OpenTelemetry::SDK::Metrics::MeterProvider)
338
+ @fallback_meter_provider = provider
339
+ warn "[foam] another MeterProvider is already registered globally — " \
340
+ "foam metrics are available through Foam::Otel.meter only; " \
341
+ "metrics created via the OTel metrics API will not export to foam"
342
+ else
343
+ OpenTelemetry.meter_provider = provider
344
+ @foam_providers << provider
345
+ @flushables << provider
346
+ end
347
+ provider
348
+ end
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ require_relative "constants"
6
+
7
+ module Foam
8
+ module Otel
9
+ # Rails.logger bridge — a broadcast target (Rails 7.1+ BroadcastLogger)
10
+ # that re-emits every log line as an OTel log record stamped log.rails.
11
+ # Attached via `Rails.logger.broadcast_to(bridge)` so the app's own logger
12
+ # chain (devices, formatters, TaggedLogging tags) is never wrapped or
13
+ # altered; trace correlation comes from the active context through the
14
+ # stamp/batch pipeline.
15
+ class LoggerBridge < ::Logger
16
+ SEVERITY_TEXT = { 0 => "DEBUG", 1 => "INFO", 2 => "WARN", 3 => "ERROR", 4 => "FATAL", 5 => "UNKNOWN" }.freeze
17
+ # OTel severity numbers: DEBUG 5, INFO 9, WARN 13, ERROR 17, FATAL 21.
18
+ SEVERITY_NUMBER = { 0 => 5, 1 => 9, 2 => 13, 3 => 17, 4 => 21, 5 => 1 }.freeze
19
+
20
+ def initialize(tagged_source: nil)
21
+ super(nil) # no device — emission goes to the OTel logs pipeline
22
+ @tagged_source = tagged_source
23
+ end
24
+
25
+ # Memoized so the hot per-line path is an ivar read, not a
26
+ # LoggerProvider#logger call (which takes a registry mutex + allocates
27
+ # a lookup key on every call).
28
+ def otel_logger
29
+ @otel_logger ||= Foam::Otel.logger("foam-otel/rails")
30
+ end
31
+
32
+ def add(severity, message = nil, progname = nil, &block)
33
+ # The bridge is attached unconditionally by the railtie; it stays
34
+ # inert until init(rails: true) and honors rails: false.
35
+ return true unless Foam::Otel.rails_enabled?
36
+
37
+ severity ||= ::Logger::UNKNOWN
38
+ return true if severity < level
39
+
40
+ body = message || (block ? yield : progname)
41
+ return true if body.nil?
42
+
43
+ attributes = { INSTRUMENTATION_KEY => STAMPS::LOG_RAILS }
44
+ tags = current_tags
45
+ attributes["rails.tags"] = tags.join(" ") unless tags.empty?
46
+
47
+ otel_logger.on_emit(
48
+ severity_text: SEVERITY_TEXT.fetch(severity, "UNKNOWN"),
49
+ severity_number: SEVERITY_NUMBER.fetch(severity, 1),
50
+ timestamp: Time.now,
51
+ body: body.to_s,
52
+ attributes: attributes,
53
+ context: OpenTelemetry::Context.current
54
+ )
55
+ true
56
+ rescue StandardError
57
+ true # the bridge must never break application logging
58
+ end
59
+
60
+ private
61
+
62
+ # TaggedLogging tags (e.g. the request_id from config.log_tags) live on
63
+ # the SOURCE logger's formatter; the bridge reads them without touching
64
+ # the source chain.
65
+ def current_tags
66
+ formatter = @tagged_source&.formatter
67
+ return [] unless formatter.respond_to?(:current_tags)
68
+
69
+ Array(formatter.current_tags).map(&:to_s)
70
+ rescue StandardError
71
+ []
72
+ end
73
+ end
74
+ end
75
+ end