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.
- checksums.yaml +4 -4
- data/GOTCHAS.md +522 -53
- data/README.md +341 -31
- data/RESEARCH.md +170 -70
- data/THIRD-PARTY-NOTICES +21 -10
- data/lib/foam/otel/api.rb +124 -16
- data/lib/foam/otel/classifier.rb +44 -6
- data/lib/foam/otel/config.rb +22 -2
- data/lib/foam/otel/constants.rb +15 -89
- data/lib/foam/otel/errors.rb +4 -1
- data/lib/foam/otel/fork_hooks.rb +7 -0
- data/lib/foam/otel/ingest.rb +617 -0
- data/lib/foam/otel/ingest_metric_reader.rb +87 -0
- data/lib/foam/otel/init.rb +351 -34
- data/lib/foam/otel/instance.rb +4 -1
- data/lib/foam/otel/llm/anthropic_shim.rb +84 -0
- data/lib/foam/otel/llm/gemini_shim.rb +113 -0
- data/lib/foam/otel/llm/openai_shim.rb +123 -0
- data/lib/foam/otel/llm/ruby_llm_shim.rb +131 -0
- data/lib/foam/otel/llm.rb +272 -0
- data/lib/foam/otel/logger_bridge.rb +187 -0
- data/lib/foam/otel/metrics.rb +32 -7
- data/lib/foam/otel/pipelines.rb +72 -3
- data/lib/foam/otel/redacting_exporter.rb +88 -17
- data/lib/foam/otel/redaction.rb +164 -87
- data/lib/foam/otel/runtime_metrics.rb +120 -0
- data/lib/foam/otel/session_stitching.rb +112 -0
- data/lib/foam/otel/version.rb +1 -1
- metadata +237 -19
|
@@ -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
|
data/lib/foam/otel/init.rb
CHANGED
|
@@ -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
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
|
|
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).
|
|
75
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
#
|
|
102
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
144
|
-
#
|
|
145
|
-
|
|
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)
|
|
182
|
-
# signal, say where helper telemetry now flows, and
|
|
183
|
-
#
|
|
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
|
|
187
|
-
"
|
|
188
|
-
"
|
|
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
|
-
|
|
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
|
|
297
|
-
|
|
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)
|
data/lib/foam/otel/instance.rb
CHANGED
|
@@ -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)
|