foam-otel 1.0.2 → 1.2.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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The door-2 metrics tap. Required LAZILY by the ingest factory AFTER
4
+ # require_sdk! (rule 41): the superclass constant only exists once the
5
+ # metrics SDK is loaded.
6
+ #
7
+ # SUBCLASSING (flagged deviation, fleet-accepted): composition is preferred
8
+ # everywhere else, but MeterProvider#add_metric_reader requires the
9
+ # `metric_store` seam (meter_provider.rb registers every instrument with the
10
+ # reader's store — including BACK-FILLING instruments created before attach,
11
+ # meter.rb) and the periodic ticker thread + after_fork machinery is exactly
12
+ # what the tap must inherit (periodic_metric_reader.rb). This is an alpha-SDK
13
+ # behavioral reliance, capped by the gemspec range and pinned by tests
14
+ # (GOTCHAS R5/R6).
15
+ module Foam
16
+ module Otel
17
+ module Ingest
18
+ class MetricReader < OpenTelemetry::SDK::Metrics::Export::PeriodicMetricReader
19
+ def initialize(exporter:, config:)
20
+ @foam_config = config
21
+ @foam_claim_checked = false
22
+ @foam_double_claim_warned = false
23
+ @foam_stood_down = false
24
+ # super LAST: the superclass constructor starts the ticker thread,
25
+ # which reads the ivars above via collect.
26
+ super(exporter: exporter)
27
+ end
28
+
29
+ # Guarded collect (GOTCHAS R6): PeriodicMetricReader#export has NO
30
+ # rescue — a raising collect (or exporter) would kill the reader
31
+ # thread silently and the tap would go dark with no warning. Foam's
32
+ # exporter chain already rescues everything to FAILURE; this guard
33
+ # closes the collect half. Also the metrics double-claim gate (fleet
34
+ # ruling 2c / audit R9): checked once, on the first non-empty
35
+ # collection, BEFORE anything reaches the exporter — a stand-down
36
+ # exports zero duplicates ever.
37
+ def collect
38
+ batch = super
39
+ admit_metrics(batch.is_a?(Array) ? batch : [])
40
+ rescue StandardError, SystemStackError
41
+ []
42
+ end
43
+
44
+ # A stood-down tap must stay down across forks; everything else
45
+ # inherits the upstream fork recovery (exporter reset + restart),
46
+ # driven by foam's own Process._fork hook via the @ingest_taps
47
+ # registry (GOTCHAS R7 — upstream only wires after_fork through
48
+ # SDK.configure, which door 2 never runs).
49
+ def after_fork
50
+ return if @foam_stood_down
51
+
52
+ super
53
+ end
54
+
55
+ private
56
+
57
+ def admit_metrics(batch)
58
+ return [] if @foam_stood_down
59
+ return batch if batch.empty?
60
+
61
+ # Self-ingest is structural — decided once, on the first non-empty
62
+ # collection.
63
+ unless @foam_claim_checked
64
+ @foam_claim_checked = true
65
+ if Ingest.foam_resource?(batch.first&.resource)
66
+ Diagnostics.warn(Ingest.self_ingest_warning(:metrics))
67
+ @foam_stood_down = true
68
+ # Stop the ticker after this pass: a stood-down tap exports
69
+ # nothing and should not keep waking a thread to do it.
70
+ @continue = false
71
+ return []
72
+ end
73
+ end
74
+ # The same-signal double-claim is RE-CHECKED on every non-empty
75
+ # collection until the W3 warning fires once: init() may register
76
+ # metrics AFTER the foreign reader starts collecting (fleet ruling 2c
77
+ # — a late init() is still caught, matching JS/Python).
78
+ if !@foam_double_claim_warned && Foam::Otel.send(:foam_exports_signal?, :metrics)
79
+ @foam_double_claim_warned = true
80
+ Diagnostics.warn(Ingest.double_claim_warning(:metrics))
81
+ end
82
+ batch
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -14,6 +14,10 @@ require_relative "instance"
14
14
  require_relative "api"
15
15
  require_relative "pipelines"
16
16
  require_relative "fork_hooks"
17
+ require_relative "ingest" # door-2 entries (SDK still lazy — rule 41)
18
+ require_relative "logger_bridge"
19
+ require_relative "runtime_metrics"
20
+ require_relative "llm"
17
21
 
18
22
  module Foam
19
23
  module Otel
@@ -28,10 +32,19 @@ module Foam
28
32
  # NOT self-heal across forks (batch span/log processors reset_on_fork
29
33
  # themselves; verified upstream) and needs explicit after_fork.
30
34
  @metric_readers = []
35
+ # Door-2 ingest metric readers (taps on FOREIGN pipelines) — deliberately
36
+ # a SEPARATE registry from @metric_readers: a post-fork re-init retires
37
+ # foam's own pipelines (retire_foam_pipelines! clears @metric_readers),
38
+ # which would orphan tap readers that belong to the CUSTOMER'S pipeline,
39
+ # not foam's (GOTCHAS R7). Walked by after_fork! alongside foam's own.
40
+ @ingest_taps = []
31
41
  # The provider foam registered per signal, and which signals have already
32
42
  # warned about a late-arrival displacement (rule 11 / 18 step 5).
33
43
  @foam_registered = {}
34
44
  @displacement_warned = {}
45
+ # Whether a post-fork re-init already retired the pipelines inherited
46
+ # from the parent (retired at most once per init).
47
+ @inherited_retired = false
35
48
 
36
49
  class << self
37
50
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -54,13 +67,16 @@ module Foam
54
67
  raise ArgumentError, "Foam::Otel.init requires enabled: true or false (got #{enabled.inspect})"
55
68
  end
56
69
 
57
- # OTEL_SDK_DISABLED=true (exact string) is the operator kill switch and
58
- # SUPERSEDES enabled: true (rule 19). Foam bypasses SDK.configure, so it
59
- # must honor the var itself.
60
- killed = ENV["OTEL_SDK_DISABLED"] == "true"
70
+ # OTEL_SDK_DISABLED=true is the operator kill switch and SUPERSEDES
71
+ # enabled: true (rule 19). Foam bypasses SDK.configure, so it must honor
72
+ # the var itself. Matched trimmed + case-insensitive so the fleet's one
73
+ # emergency switch behaves identically across languages (JS/Python
74
+ # trim + downcase; the OTel boolean-env convention is case-insensitive).
75
+ killed = kill_switch_active?
61
76
  effective_enabled = enabled && !killed
62
77
 
63
78
  endpoint = resolve_endpoint
79
+ warn_inert_per_signal_endpoints(endpoint)
64
80
  config = resolve_config(
65
81
  name: name, environment: environment, version: version,
66
82
  enabled: effective_enabled,
@@ -68,23 +84,39 @@ module Foam
68
84
  ignored_outbound_hosts: ignored_outbound_hosts,
69
85
  diagnostics: diagnostics, endpoint: endpoint
70
86
  )
71
- self.active_config = config
72
-
73
87
  # Calling init twice warns and returns the first instance (rule:
74
- # lifecycle). Config above is refreshed so the helpers read the latest
75
- # redaction lists, but providers are never re-registered.
88
+ # lifecycle). The redaction lists (and diagnostics) refresh so the
89
+ # helpers read the latest customer lists — but enabled/endpoint/identity
90
+ # are process-global and are NEVER changed by a second init: overwriting
91
+ # `enabled` here would leave foam HALF-dark (a second init(enabled:false)
92
+ # would no-op the helpers while the already-registered providers keep
93
+ # exporting Rack/Net::HTTP spans — the operator believes foam is off
94
+ # while data still flows), and the reverse would "wake" a service booted
95
+ # disabled without ever building a pipeline. Merge ONLY the redact lists.
76
96
  if @initialized
77
- Diagnostics.warn("init already called — returning the existing instance (providers are process-global)")
97
+ self.active_config = refresh_post_init_config(
98
+ active_config, redact_keys: redact_keys, redact_pii_keys: redact_pii_keys, diagnostics: diagnostics
99
+ )
100
+ Diagnostics.warn("init already called — returning the existing instance (providers are process-global; " \
101
+ "enabled and endpoint cannot change after the first init — only the redaction lists refresh)")
78
102
  return @instance
79
103
  end
104
+ self.active_config = config
80
105
 
81
106
  warn_environment(environment)
107
+ warn_missing_version(version)
82
108
 
83
109
  unless effective_enabled
84
- Diagnostics.warn(
85
- killed ? "OTEL_SDK_DISABLED=true — foam is fully off (no SDK, no export)"
86
- : "enabled: false — foam is inert (no SDK loaded, no providers, no export)"
87
- )
110
+ if killed
111
+ # The kill switch OVERRIDES the code's enabled: true — an operator
112
+ # intervention is worth one loud line (rule 19 env table).
113
+ Diagnostics.warn("OTEL_SDK_DISABLED=true — foam is fully off (no SDK, no export)")
114
+ else
115
+ # enabled: false is the DOCUMENTED tests/CI posture (README
116
+ # scenario 6): an EXPECTED state stays silent (rule 10). The
117
+ # posture line is still visible under diagnostics: true.
118
+ Diagnostics.info("enabled: false — foam is inert (no SDK loaded, no providers, no export)")
119
+ end
88
120
  @initialized = true
89
121
  return @instance = Instance.new(config)
90
122
  end
@@ -98,23 +130,37 @@ module Foam
98
130
  require_sdk!
99
131
  configure_propagation
100
132
 
101
- # Classify ALL THREE slots before ANY registration (rule 18, step 1).
102
- verdicts = Classifier.classify
133
+ # Providers foam registered in the PARENT process — non-empty only on
134
+ # a post-fork re-init (the fork hook cleared @initialized, GOTCHAS
135
+ # R1). A slot still holding one of these is foam's OWN to re-own
136
+ # (retiring the inherited pipelines and minting a fresh resource — a
137
+ # NEW service.instance.id per worker, section 0); a genuinely foreign
138
+ # provider is never touched.
139
+ inherited = @foam_providers.dup
140
+ @inherited_retired = false
103
141
  resource = Resource.build(config)
104
142
  headers = { "Authorization" => "Bearer #{token}" }
105
143
  instance = Instance.new(config)
106
144
 
107
- register_free_signal(:traces, verdicts[:traces], instance) do
145
+ # Each slot is classified IMMEDIATELY before its own setter — never
146
+ # as an up-front classify-all pass. Ruby has no atomic set-once
147
+ # attempt (rule 18 step 4's Ruby row: every setter assigns
148
+ # unconditionally, so attempt-then-read would DISPLACE an incumbent —
149
+ # RESEARCH.md §2 / GOTCHAS F7), so a pre-read is forced; classifying
150
+ # per signal at the last moment keeps the residual pre-read→set race
151
+ # window as narrow as the platform allows (GOTCHAS F8).
152
+ traces_verdict = register_free_signal(:traces, instance, inherited) do
108
153
  setup_traces(endpoint, headers, resource, config, additional_span_processors)
109
154
  end
110
- register_free_signal(:logs, verdicts[:logs], instance) do
155
+ register_free_signal(:logs, instance, inherited) do
111
156
  setup_logs(endpoint, headers, resource, config, additional_log_record_processors)
112
157
  end
113
- register_free_signal(:metrics, verdicts[:metrics], instance) do
158
+ register_free_signal(:metrics, instance, inherited) do
114
159
  setup_metrics(endpoint, headers, resource, config, additional_metric_readers)
115
160
  end
116
161
 
117
- activate_instrumentations(config, additional_instrumentations)
162
+ activate_instrumentations(config, additional_instrumentations, traces_verdict)
163
+ activate_floor_extensions
118
164
  ForkHooks.install!
119
165
 
120
166
  at_exit { instance.shutdown }
@@ -124,6 +170,16 @@ module Foam
124
170
  end
125
171
  # rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
126
172
 
173
+ # Called by the fork hook IN THE CHILD: clears the idempotency guard so
174
+ # the README's per-worker init is honored as a real re-init (the child
175
+ # then reclaims foam's inherited slots and mints a fresh
176
+ # service.instance.id — see classify_for_registration). Everything else
177
+ # is left running for workers that never re-init.
178
+ def mark_forked!
179
+ @initialized = false
180
+ end
181
+ private :mark_forked!
182
+
127
183
  # Test hook: full reset of module state (NOT for production — OTel
128
184
  # globals cannot be cleanly re-registered after a real shutdown).
129
185
  def reset_for_tests!
@@ -132,28 +188,118 @@ module Foam
132
188
  @foam_providers = []
133
189
  @flushables = []
134
190
  @metric_readers = []
191
+ @ingest_taps = []
135
192
  @foam_registered = {}
136
193
  @displacement_warned = {}
194
+ @inherited_retired = false
137
195
  self.active_config = blank_config
138
196
  Metrics.reset_for_tests!
197
+ RuntimeMetrics.reset_for_tests!
139
198
  end
140
199
 
141
200
  private
142
201
 
143
- # Registers into a FREE slot; warns once (honestly) for a CLAIMED one and
144
- # touches nothing (rule 18, steps 2-3).
145
- def register_free_signal(signal, verdict, instance)
202
+ # Classifies the slot IMMEDIATELY before registering into it (the
203
+ # narrowest pre-read→set window Ruby's non-atomic globals allow —
204
+ # GOTCHAS F8), registers into a FREE slot, and warns once (honestly)
205
+ # for a CLAIMED one, touching nothing (rule 18, steps 2-3). Returns the
206
+ # verdict so init can gate instrumentation activation on traces.
207
+ def register_free_signal(signal, instance, inherited)
208
+ verdict = classify_for_registration(signal, inherited)
146
209
  if verdict.free?
147
210
  provider = yield
148
211
  if provider
149
212
  instance.providers << provider
150
213
  @foam_registered[signal] = provider # for the late-arrival re-check
151
214
  end
215
+ elsif verdict.unclassifiable?
216
+ warn_unclassifiable(signal)
152
217
  else
153
218
  warn_claimed(signal, verdict.owner)
154
219
  end
220
+ verdict
155
221
  rescue StandardError => e
156
222
  Diagnostics.warn("#{signal} setup failed: #{e.class}: #{e.message}")
223
+ verdict || Verdict.new(false, Classifier::UNCLASSIFIABLE)
224
+ end
225
+
226
+ # rule 42 degraded path (GOTCHAS G14): the FREE predicate itself is
227
+ # unavailable — say THAT, loudly and accurately, instead of fabricating
228
+ # a foreign-owner verdict. Foam registers nothing (fail-safe).
229
+ def warn_unclassifiable(signal)
230
+ Diagnostics.warn(
231
+ "foam cannot classify the #{signal} slot on this OpenTelemetry API version " \
232
+ "(OpenTelemetry::Internal proxy constant missing — the installed API changed shape). " \
233
+ "Foam registered NOTHING for #{signal} (fail-safe) and is DARK for #{signal}. " \
234
+ "Check the installed opentelemetry-api version against foam's supported range (README), " \
235
+ "or file this with foam support."
236
+ )
237
+ end
238
+
239
+ # The per-signal pre-read, taken as LATE as possible. A CLAIMED verdict
240
+ # whose live provider is one foam itself registered in the parent
241
+ # process (inherited across a fork after the fork hook cleared
242
+ # @initialized) is foam's to re-own: the inherited pipelines are
243
+ # retired once — their export threads died with the fork — and the slot
244
+ # is treated as FREE so the re-init rebuilds it with a freshly minted
245
+ # resource (GOTCHAS R1).
246
+ def classify_for_registration(signal, inherited)
247
+ verdict = Classifier.classify_signal(signal)
248
+ return verdict if verdict.free? || verdict.unclassifiable?
249
+
250
+ live = live_provider(signal)
251
+ return verdict unless inherited.any? { |p| p.equal?(live) }
252
+
253
+ retire_inherited_pipelines!
254
+ Verdict.new(true, nil)
255
+ end
256
+
257
+ def live_provider(signal)
258
+ case signal
259
+ when :traces then OpenTelemetry.tracer_provider
260
+ when :metrics then OpenTelemetry.meter_provider
261
+ when :logs then OpenTelemetry.logger_provider
262
+ end
263
+ end
264
+
265
+ def retire_inherited_pipelines!
266
+ return if @inherited_retired
267
+
268
+ @inherited_retired = true
269
+ retire_foam_pipelines!
270
+ end
271
+
272
+ # Shut down and forget every pipeline foam owned in the parent process:
273
+ # a forked child re-init must not leave inherited readers exporting the
274
+ # PARENT's service.instance.id beside the new pipelines.
275
+ # @ingest_taps is deliberately NOT touched: door-2 tap readers belong to
276
+ # the CUSTOMER'S pipeline and must survive a foam re-init (GOTCHAS R7).
277
+ def retire_foam_pipelines!
278
+ begin
279
+ @instance&.shutdown
280
+ rescue StandardError
281
+ nil
282
+ end
283
+ @foam_providers = []
284
+ @flushables = []
285
+ @metric_readers = []
286
+ @foam_registered = {}
287
+ @displacement_warned = {}
288
+ end
289
+
290
+ # Door-2 fork registry (GOTCHAS R7): tap metric readers registered by
291
+ # the ingest factory, walked by after_fork! beside foam's own readers.
292
+ def register_ingest_tap!(reader)
293
+ @ingest_taps << reader
294
+ nil
295
+ end
296
+
297
+ # Door-2 same-signal double-claim predicate (fleet ruling 2c): true
298
+ # when init() registered (and therefore exports) this signal to foam.
299
+ def foam_exports_signal?(signal)
300
+ @foam_registered[signal] ? true : false
301
+ rescue StandardError
302
+ false
157
303
  end
158
304
 
159
305
  # Late-arrival re-check (rule 11 / 18 step 5). Ruby globals are
@@ -178,15 +324,16 @@ module Foam
178
324
  nil
179
325
  end
180
326
 
181
- # The honest situation-B warning (rule 18 B): name the owner and the
182
- # signal, say where helper telemetry now flows, and that foam ingest for
183
- # the claimed signal is not yet available (door 2 is deferred, rule 22a).
327
+ # The honest situation-B warning (rule 18 B), fleet template W1: name
328
+ # the owner and the signal, say where helper telemetry now flows, and
329
+ # point at the matching door-2 ingest entry — the entries shipped, so
330
+ # the old "not yet available" text would be the banned hollow pointer
331
+ # (rule 18 B). Text pinned by spec/ingest_spec.rb.
184
332
  def warn_claimed(signal, owner)
185
333
  Diagnostics.warn(
186
- "#{signal} is already owned by #{owner} — foam did NOT register its #{signal} provider " \
187
- "and is DARK for #{signal}. #{signal.capitalize} from foam's helpers now flow through " \
188
- "that provider, not to foam. Foam ingest for #{signal} is not yet available; feed foam " \
189
- "server-side from that pipeline, or file the need."
334
+ "#{signal} is owned by #{owner} — foam is dark for #{signal}; helper telemetry for it " \
335
+ "rides the foreign SDK to ITS backend. To also send that pipeline to foam, add one " \
336
+ "line in THEIR setup: #{Ingest::ENTRY_NAMES.fetch(signal)}. See README \"Door 2\"."
190
337
  )
191
338
  end
192
339
 
@@ -205,6 +352,24 @@ module Foam
205
352
  "— exported verbatim, not rewritten")
206
353
  end
207
354
 
355
+ # version is optional by design, but MISSING = warn + continue (section
356
+ # 0 init table; the README documents this warning): without it,
357
+ # service.version is absent and deploy correlation is degraded.
358
+ def warn_missing_version(version)
359
+ return unless version.nil? || version.to_s.strip.empty?
360
+
361
+ Diagnostics.warn("version is missing — continuing without service.version " \
362
+ "(recommended: pass your git SHA for deploy correlation)")
363
+ end
364
+
365
+ # The operator kill switch predicate (rule 19), shared by init() and the
366
+ # door-2 ingest factory. Trimmed + case-insensitive "true" so
367
+ # OTEL_SDK_DISABLED=TRUE (or " true") kills foam the same way it kills the
368
+ # rest of the fleet (JS/Python already trim + downcase).
369
+ def kill_switch_active?
370
+ ENV["OTEL_SDK_DISABLED"].to_s.strip.downcase == "true"
371
+ end
372
+
208
373
  # The pinned fleet endpoint, or the operator's OTEL_EXPORTER_OTLP_ENDPOINT
209
374
  # override honored with a LOUD warning naming the destination (rule 19).
210
375
  def resolve_endpoint
@@ -216,12 +381,53 @@ module Foam
216
381
  override.strip.sub(%r{/+\z}, "")
217
382
  end
218
383
 
384
+ # The per-signal OTLP endpoint variants are deliberately INERT in foam:
385
+ # init resolves ONE endpoint and passes it to every exporter
386
+ # constructor explicitly, and the installed exporters consult the
387
+ # per-signal vars only when `endpoint:` is nil — so a per-signal var
388
+ # can never silently split foam's export across destinations (posture
389
+ # pinned by spec/env_vars_spec.rb; surfaced as a spec-table divergence
390
+ # in GOTCHAS F2). An operator who SET one expected a redirect, and
391
+ # silently not applying it is the rule-15 failure mode — say it loudly.
392
+ def warn_inert_per_signal_endpoints(endpoint)
393
+ set_vars = {
394
+ "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT" => ENV["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"],
395
+ "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT" => ENV["OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"],
396
+ "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT" => ENV["OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"],
397
+ }.reject { |_var, value| value.nil? || value.strip.empty? }
398
+ return if set_vars.empty?
399
+
400
+ Diagnostics.warn(
401
+ "#{set_vars.keys.join(', ')} set but INERT — foam wires one resolved endpoint into every " \
402
+ "exporter explicitly, so per-signal OTLP endpoint vars never redirect foam's export " \
403
+ "(all signals -> #{endpoint}; only OTEL_EXPORTER_OTLP_ENDPOINT is honored — README env table)"
404
+ )
405
+ end
406
+
219
407
  # OTEL_PROPAGATORS handling (rule 17 / env table). Foam bypasses
220
408
  # SDK.configure, which is the only thing that reads this var, so foam
221
409
  # implements it: `none` installs a no-op propagator (injection off,
222
410
  # telemetry keeps flowing); the default is W3C TraceContext + Baggage;
223
411
  # any other value warns and is ignored (foam's propagator set is fixed).
412
+ #
413
+ # The propagation slot is a process-wide last-write-wins global (rule
414
+ # 18 B): "a foreign owner keeps them". Foam installs its set ONLY when
415
+ # the slot still holds the API's built-in default (NoopTextMapPropagator
416
+ # — the exact default the API's reader memoizes); a propagator a
417
+ # foreign SDK installed first is KEPT, with one honest warning —
418
+ # displacing it would sever the customer's B3/Jaeger/X-Ray propagation
419
+ # in THEIR OWN product (GOTCHAS F5).
224
420
  def configure_propagation
421
+ current = OpenTelemetry.propagation
422
+ unless current.instance_of?(OpenTelemetry::Context::Propagation::NoopTextMapPropagator)
423
+ Diagnostics.warn(
424
+ "a propagator is already installed (#{current.class}) — foam KEPT it and did not " \
425
+ "install its W3C tracecontext+baggage set (rule 18: a foreign owner keeps the " \
426
+ "process-wide propagator). Inbound/outbound context rides the incumbent's formats."
427
+ )
428
+ return
429
+ end
430
+
225
431
  value = ENV["OTEL_PROPAGATORS"]&.strip
226
432
  if value == "none"
227
433
  Diagnostics.warn("OTEL_PROPAGATORS=none — trace propagation is OFF (links lost); telemetry still flows")
@@ -248,13 +454,47 @@ module Foam
248
454
  require "opentelemetry-exporter-otlp-metrics"
249
455
  end
250
456
 
457
+ # The central RAW-capture activation config (raised-floor ruling
458
+ # 2026-07-26): the upstream datastore instrumentations default
459
+ # db_statement to :obfuscate — a sanitizer, which the zero-default-
460
+ # redaction ruling explicitly disables. Foam activates them with
461
+ # `db_statement: :include` so raw SQL / redis args / mongo commands
462
+ # land on the wire; masking happens ONLY for customer-listed redact
463
+ # keys at foam's redaction stage. Keys are registry instrumentation
464
+ # names; entries whose target gem is absent are simply never installed
465
+ # (rule 6 presence checks are the gems' own).
466
+ FLOOR_INSTRUMENTATION_CONFIG = {
467
+ "OpenTelemetry::Instrumentation::PG" => { db_statement: :include }.freeze,
468
+ "OpenTelemetry::Instrumentation::Mysql2" => { db_statement: :include }.freeze,
469
+ "OpenTelemetry::Instrumentation::Redis" => { db_statement: :include }.freeze,
470
+ "OpenTelemetry::Instrumentation::Mongo" => { db_statement: :include }.freeze,
471
+ }.freeze
472
+
251
473
  # Tier-1/2 activation (rules 5-7): install every bundled official
252
474
  # opentelemetry-instrumentation-* gem, presence-checked by the gem
253
475
  # itself. The outbound loop guard (rule 24) is wired by pre-installing
254
476
  # the HTTP-client instrumentations with untraced_hosts = the export host
255
477
  # + ignored_outbound_hosts BEFORE install_all (install is idempotent, so
256
478
  # the later install_all skips the ones already configured).
257
- def activate_instrumentations(config, additional)
479
+ #
480
+ # GATED on the traces verdict (rule 18 B): every contrib instrumentation
481
+ # binds its tracer to the CURRENT global — when a FOREIGN SDK owns the
482
+ # tracer slot, install_all would silently add span producers (and Rack
483
+ # middleware) to the incumbent's pipeline, on their vendor's bill. Foam
484
+ # registers NOTHING into a claimed pipeline: automatic activation is
485
+ # skipped with a loud warning. `additional_instrumentations` instances
486
+ # are the FDE's EXPLICIT choice and are still installed.
487
+ def activate_instrumentations(config, additional, traces_verdict)
488
+ unless traces_verdict.free?
489
+ Diagnostics.warn(
490
+ "foam does not own the traces slot — automatic instrumentation activation SKIPPED " \
491
+ "(foam adds no span producers or middleware to a pipeline it does not own; rule 18 B). " \
492
+ "additional_instrumentations passed explicitly are still installed."
493
+ )
494
+ install_additional(additional)
495
+ return
496
+ end
497
+
258
498
  gems = Gem.loaded_specs.keys.grep(/\Aopentelemetry-instrumentation-/)
259
499
  gems.each do |name|
260
500
  require name
@@ -265,12 +505,43 @@ module Foam
265
505
 
266
506
  configure_loop_guard(config)
267
507
  install_additional(additional)
268
- OpenTelemetry::Instrumentation.registry.install_all
508
+ OpenTelemetry::Instrumentation.registry.install_all(FLOOR_INSTRUMENTATION_CONFIG)
269
509
  Diagnostics.info("instrumentations installed: #{gems.length} bundled gem(s)")
270
510
  rescue StandardError => e
271
511
  Diagnostics.warn("instrumentation activation failed: #{e.class}: #{e.message}")
272
512
  end
273
513
 
514
+ # The non-contrib floor pieces (raised-floor ruling 2026-07-26). Each
515
+ # activation is gated on foam OWNING the slot its telemetry flows into
516
+ # (rule 18 B: never a new producer on a foreign pipeline), presence-
517
+ # checked, and fault-isolated — a broken piece warns and the rest of
518
+ # init proceeds (rule 9: never abort boot).
519
+ def activate_floor_extensions
520
+ if foam_exports_signal?(:logs)
521
+ begin
522
+ LoggerBridge.install!
523
+ rescue StandardError => e
524
+ Diagnostics.warn("stdlib Logger bridge activation failed: #{e.class}: #{e.message}")
525
+ end
526
+ end
527
+
528
+ if (meter_provider = @foam_registered[:metrics])
529
+ begin
530
+ RuntimeMetrics.install!(meter_provider)
531
+ rescue StandardError => e
532
+ Diagnostics.warn("runtime metrics activation failed: #{e.class}: #{e.message}")
533
+ end
534
+ end
535
+
536
+ return unless foam_exports_signal?(:traces)
537
+
538
+ begin
539
+ LLM.activate!
540
+ rescue StandardError => e
541
+ Diagnostics.warn("LLM shim activation failed: #{e.class}: #{e.message}")
542
+ end
543
+ end
544
+
274
545
  # Pre-install the HTTP-client instrumentations with untraced_hosts so
275
546
  # foam's exports and FDE-listed hosts never produce outbound spans.
276
547
  # Each entry is the Instrumentation *class* (…::Instrumentation), not the
@@ -278,12 +549,26 @@ module Foam
278
549
  # the trailing ::Instrumentation the NameError/NoMethodError is swallowed
279
550
  # and install_all then installs with default untraced_hosts: [] — the
280
551
  # export-loop guard and ignored_outbound_hosts silently never apply.
552
+ #
553
+ # ONLY the clients whose upstream instrumentation DECLARES the
554
+ # `untraced_hosts` option: Net::HTTP (option :untraced_hosts) and Excon
555
+ # (Concerns::UntracedHosts). Faraday, HTTP (httprb) and HTTPX declare NO
556
+ # such option — Base#config_options silently DROPS unknown keys
557
+ # (instrumentation-base base.rb), so pre-installing them would install
558
+ # UNGUARDED with foam's option discarded; foam instead warns that
559
+ # ignored_outbound_hosts cannot apply to them (GOTCHAS G13).
281
560
  HTTP_CLIENT_INSTRUMENTATIONS = %w[
282
561
  OpenTelemetry::Instrumentation::Net::HTTP::Instrumentation
562
+ OpenTelemetry::Instrumentation::Excon::Instrumentation
563
+ ].freeze
564
+
565
+ # Bundled HTTP-client instrumentations that CANNOT honor untraced_hosts
566
+ # upstream — named so init can warn (never silently not-apply) when the
567
+ # FDE wires ignored_outbound_hosts while one of these is present.
568
+ UNGUARDABLE_HTTP_CLIENT_INSTRUMENTATIONS = %w[
283
569
  OpenTelemetry::Instrumentation::Faraday::Instrumentation
284
570
  OpenTelemetry::Instrumentation::HTTP::Instrumentation
285
571
  OpenTelemetry::Instrumentation::HTTPX::Instrumentation
286
- OpenTelemetry::Instrumentation::Excon::Instrumentation
287
572
  ].freeze
288
573
 
289
574
  def configure_loop_guard(config)
@@ -293,9 +578,41 @@ module Foam
293
578
  HTTP_CLIENT_INSTRUMENTATIONS.each do |const|
294
579
  klass = const.split("::").reduce(Object) { |mod, name| mod.const_get(name) }
295
580
  klass.instance.install(untraced_hosts: hosts)
296
- rescue NameError, StandardError
297
- nil
581
+ rescue NameError
582
+ # The gem is simply not bundled — the expected path; narrate it only
583
+ # under diagnostics (rule 15: a swallowed error still logs).
584
+ Diagnostics.info("loop guard: #{const} not present — skipped")
585
+ rescue StandardError => e
586
+ # A PRESENT client whose pre-install failed: the guard silently not
587
+ # applying was the 1.0.0 bug's hiding mechanism — never again (rule 15).
588
+ Diagnostics.warn("loop guard: could not pre-configure #{const} (#{e.class}: #{e.message}) — " \
589
+ "outbound calls from that client to #{hosts.join(', ')} may still produce spans")
590
+ end
591
+ warn_unguardable_clients(config)
592
+ end
593
+
594
+ # ignored_outbound_hosts silently not applying is a feedback-loop risk
595
+ # (rule 24 / rule 18 C.4): when the FDE listed hosts and a bundled
596
+ # HTTP-client instrumentation has no untraced_hosts support upstream,
597
+ # say so loudly once at init (GOTCHAS G13). Foam's OWN export loop is
598
+ # safe regardless — the OTLP exporters send inside Common::Utilities
599
+ # .untraced (GOTCHAS G4).
600
+ def warn_unguardable_clients(config)
601
+ return if config.ignored_outbound_hosts.empty?
602
+
603
+ present = UNGUARDABLE_HTTP_CLIENT_INSTRUMENTATIONS.select do |const|
604
+ Object.const_defined?(const)
605
+ rescue NameError
606
+ false
298
607
  end
608
+ return if present.empty?
609
+
610
+ names = present.map { |const| const.split("::")[-2] }.join(", ")
611
+ Diagnostics.warn(
612
+ "ignored_outbound_hosts cannot be applied to #{names} — the upstream instrumentation has " \
613
+ "no untraced_hosts option, so calls made through #{names} to the listed hosts WILL still " \
614
+ "produce spans (see GOTCHAS G13; foam's own export loop stays guarded regardless)"
615
+ )
299
616
  end
300
617
 
301
618
  def endpoint_host(endpoint)
@@ -30,7 +30,10 @@ module Foam
30
30
  @providers.each do |provider|
31
31
  begin
32
32
  provider.shutdown
33
- rescue StandardError
33
+ rescue StandardError, SystemStackError
34
+ # SystemStackError is not a StandardError; a poisoned payload deep
35
+ # in a pipeline must never escape through the at_exit hook into
36
+ # the customer's process teardown (rule 9).
34
37
  nil
35
38
  end
36
39
  Foam::Otel.send(:forget_provider, provider)