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.
data/GOTCHAS.md CHANGED
@@ -8,7 +8,10 @@ those tests.
8
8
 
9
9
  Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
10
10
  1.13.0, -logs-sdk 0.6.1, -metrics-sdk 0.15.0, -api 1.11.0, -exporter-otlp 0.34.1,
11
- -instrumentation-rack 0.31.1, -instrumentation-rails 0.42.0).
11
+ -instrumentation-rack 0.31.1, -instrumentation-rails 0.42.0, and the floor
12
+ contrib pins: -net_http 0.29.0, -faraday 0.33.0, -pg 0.36.0, -mysql2 0.34.0,
13
+ -redis 0.29.0, -mongo 0.26.0, -sidekiq 0.29.0; LLM shim refs at openai 0.72.0,
14
+ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
12
15
 
13
16
  ---
14
17
 
@@ -36,13 +39,21 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
36
39
  span/log in the child — foam relies on upstream and never reimplements it. The
37
40
  PeriodicMetricReader does not, and foam bypasses `SDK.configure`, so foam
38
41
  installs its OWN `Process._fork` hook to restart foam's readers in the child.
39
- - **Mitigation**: `lib/foam/otel/fork_hooks.rb` prepends `Process._fork` and
40
- calls `Foam::Otel.after_fork!` in the child (restarts tracked metric readers,
41
- `lib/foam/otel/api.rb`). Also exposed for `Puma on_worker_boot` /
42
- `Unicorn after_fork`. The README's cluster-mode recipe calls `init` per worker
43
- so each worker also mints a fresh `service.instance.id`.
44
- - **Test**: `spec/fork_spec.rb` — "a real fork does not crash and the child can
45
- flush", "restarts every tracked metric reader and never raises".
42
+ - **Mitigation**: `lib/foam/otel/fork_hooks.rb` prepends `Process._fork` and, in
43
+ the child, calls `Foam::Otel.after_fork!` (restarts tracked metric readers,
44
+ `lib/foam/otel/api.rb`) and clears the init idempotency guard
45
+ (`mark_forked!`), so the README's cluster-mode `init`-per-worker recipe is a
46
+ REAL re-init: it retires the inherited pipelines, re-owns foam's own slots
47
+ (`classify_for_registration`, `lib/foam/otel/init.rb` — a genuinely FOREIGN
48
+ provider is never touched), and mints a fresh `service.instance.id` per worker
49
+ (section 0 resource table). `after_fork!` is also exposed for
50
+ `Puma on_worker_boot` / `Unicorn after_fork` without re-init.
51
+ - **Test**: `spec/fork_spec.rb` — "B3: a worker re-init after fork mints a NEW
52
+ service.instance.id", "the Process._fork hook fires in the CHILD only …
53
+ restarts the PeriodicMetricReader thread in the child", "the child's re-init
54
+ re-owns FOAM's inherited slots but NEVER a foreign provider", "init() itself
55
+ installs the fork hook", plus the child-flush smoke; conformance FORK phase
56
+ (distinct per-worker instance ids on the wire).
46
57
 
47
58
  ## R2: Signal maturity is uneven — logs & metrics SDKs are pre-1.0
48
59
 
@@ -52,12 +63,18 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
52
63
  — https://opentelemetry.io/docs/languages/ruby/ ; the metrics-sdk README states
53
64
  "alpha … things may break and APIs may change". Installed versions:
54
65
  logs-sdk 0.6.1, metrics-sdk 0.15.0.
55
- - **Decision & why**: Pin with pessimistic constraints (`~> 0.6`, `~> 0.15`) and
56
- wrap both signals behind stable foam helpers (`log`, the four metric helpers)
57
- so app code never touches the churning API directly.
58
- - **Mitigation**: gemspec pins + the helper wrappers (`lib/foam/otel/api.rb`,
59
- `lib/foam/otel/metrics.rb`); the lockfile freezes exact versions.
60
- - **Test**: `spec/version_spec.rb` — pins the supported SDK lines and proves the
66
+ - **Decision & why**: CAP every pre-1.0 range at the next minor above the
67
+ version the suite + conformance gate last proved (rule 40: a loose
68
+ two-segment `~> 0.x` would admit breaking 0.x minors into customer installs
69
+ — the lockfile only protects foam's own CI, never a customer resolution),
70
+ and wrap both signals behind stable foam helpers (`log`, the four metric
71
+ helpers) so app code never touches the churning API directly. Widening a cap
72
+ is a deliberate rule-44 re-prove.
73
+ - **Mitigation**: capped gemspec ranges (e.g. `>= 0.6, < 0.7` logs-sdk,
74
+ `>= 0.15, < 0.16` metrics-sdk — reasons at each pin) + the helper wrappers
75
+ (`lib/foam/otel/api.rb`, `lib/foam/otel/metrics.rb`).
76
+ - **Test**: `spec/gemspec_spec.rb` ("rule 40 — capped ranges, never an open
77
+ floor"), `spec/version_spec.rb` — pins the supported SDK lines and proves the
61
78
  helpers work over the pinned pre-1.0 SDKs.
62
79
 
63
80
  ## R3: Contrib instrumentations can themselves raise
@@ -105,35 +122,152 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
105
122
  real Rails app boots with foam and the official rack span carries the inbound
106
123
  telemetry (`/ok`, `/fail`, `/reject` assertions).
107
124
 
125
+ ## R5: Door-2 ingest taps ride the alpha logs/metrics SDKs — SHIPPED, every behavioral reliance pinned (fleet audit R8)
126
+
127
+ - **Trap**: the log and metric ingest entries attach to upstream SDKs that are
128
+ pre-1.0 ("development"/alpha). Four load-bearing behaviors could drift under
129
+ a 0.x minor: (1) `MeterProvider#add_metric_reader` BACK-FILLS instruments
130
+ created before attach; (2) `MetricReader#metric_store` is the seam
131
+ `add_metric_reader` registers instruments through (why the tap SUBCLASSES
132
+ `PeriodicMetricReader` instead of composing); (3) collected data points are
133
+ SHALLOW dups whose `attributes` hash is the LIVE aggregation key — in-place
134
+ masking would corrupt the customer's aggregation state; (4)
135
+ `LogRecord#resource` carries the emitting provider's resource (the
136
+ self-ingest detection read).
137
+ - **Sources**: installed source — `meter_provider.rb:110-121` (back-fill walk
138
+ + `register_synchronous_instrument` fan-out), `metric_reader.rb:15-22`
139
+ (`metric_store` seam), `aggregation/sum.rb:44-50` (`ndp.dup` shallow
140
+ copies), `logs/log_record.rb:16-26` (`attr_accessor :resource`);
141
+ maturity statement — https://opentelemetry.io/docs/languages/ruby/
142
+ - **Decision & why**: the entries are SHIPPED (README Door 2 section); the
143
+ pre-1.0 logs/metrics SDKs are a version-pin caveat, not a deferral. Keep every
144
+ reliance inside the already-capped gemspec ranges (R2), and pin each with a
145
+ test so a 0.x bump is a deliberate rule-44 re-prove (any widening re-runs the
146
+ door-2 e2e), never a silent drift.
147
+ - **Mitigation**: capped ranges (`foam-otel.gemspec`); masking rebuilds point
148
+ COPIES with fresh attribute hashes (`Redaction.mask_metric_data`,
149
+ `lib/foam/otel/redaction.rb`); subclassing isolated to
150
+ `lib/foam/otel/ingest_metric_reader.rb`.
151
+ - **Test**: `spec/ingest_spec.rb` — "instruments created BEFORE attach flow to
152
+ foam (back-fill)" (reliances 1-2), "mask_metric_data is FAIL-CLOSED … and
153
+ never mutates the customer's point" (reliance 3), the case-2 log test +
154
+ self-ingest cases (reliance 4).
155
+
156
+ ## R6: PeriodicMetricReader thread death — a raising exporter or collect kills door-2 metrics silently
157
+
158
+ - **Trap**: `PeriodicMetricReader#export` has NO rescue: one exception from
159
+ `collect` or the exporter chain kills the reader's ticker thread, and the
160
+ metrics tap goes dark forever with nothing but a dead thread (rule 15's
161
+ silent-loss shape, on someone else's pipeline).
162
+ - **Sources**: installed source — `periodic_metric_reader.rb:105-131`
163
+ (`export` calls `collect` + `@exporter.export` unrescued inside the ticker
164
+ loop).
165
+ - **Decision & why**: close both halves inside foam. The tap's whole exporter
166
+ chain (stamp → mask → OTLP) rescues `StandardError, SystemStackError` to
167
+ `FAILURE`, and the reader subclass overrides `collect` with a `[]`-on-error
168
+ guard — the thread can never see an exception from foam's side.
169
+ - **Mitigation**: `lib/foam/otel/ingest.rb` (`MetricStampExporter`),
170
+ `lib/foam/otel/redacting_exporter.rb` (`RedactingMetricsExporter`),
171
+ `lib/foam/otel/ingest_metric_reader.rb` (`collect` guard).
172
+ - **Test**: `spec/ingest_spec.rb` — "the reader thread survives a poisoned
173
+ collect ([] on error) and a raising exporter chain (GOTCHAS R6)".
174
+
175
+ ## R7: The door-2 fork registry — tap readers must survive fork AND a foam re-init (@ingest_taps ≠ @metric_readers)
176
+
177
+ - **Trap**: two ways a door-2 metrics tap dies in cluster mode. (1) The
178
+ reader thread is not restarted after fork: upstream wires `after_fork` only
179
+ through `SDK.configure`'s ForkHooks, which door 2 never runs (the tap rides
180
+ the CUSTOMER'S manual setup). (2) If tap readers were tracked in foam's own
181
+ `@metric_readers`, the post-fork re-init path (`retire_foam_pipelines!`)
182
+ would CLEAR them — orphaning readers that belong to the customer's
183
+ pipeline, dark in every worker with no warning.
184
+ - **Sources**: installed source — `periodic_metric_reader.rb:82-87`
185
+ (`after_fork` restarts the thread + resets the exporter),
186
+ `metrics/configurator_patch.rb:57` (upstream's hook is SDK.configure-only);
187
+ foam's own `init.rb` `retire_foam_pipelines!` (clears `@metric_readers`).
188
+ - **Decision & why**: the ingest factory installs foam's `Process._fork` hook
189
+ (idempotent) and registers tap readers in a SEPARATE module registry,
190
+ `@ingest_taps`, walked by `Foam::Otel.after_fork!` beside foam's own
191
+ readers and deliberately untouched by pipeline retirement. The exporter
192
+ wrappers forward `reset` so `after_fork` reaches the OTLP connection.
193
+ - **Mitigation**: `lib/foam/otel/init.rb` (`register_ingest_tap!`,
194
+ `retire_foam_pipelines!` comment), `lib/foam/otel/api.rb` (`after_fork!`
195
+ walks both), `lib/foam/otel/ingest.rb` (registration + `reset` forwarding).
196
+ - **Test**: `spec/ingest_spec.rb` — "retire_foam_pipelines! clears
197
+ @metric_readers but NEVER @ingest_taps", "a REAL fork: foam's Process._fork
198
+ hook restarts the tap's reader thread in the child", "the exporter chain
199
+ forwards reset for after_fork".
200
+
201
+ ## R8: The tap's loop story — untraced coverage, the REQUIRED README step, and the echo filter
202
+
203
+ - **Trap**: the tap's exports to foam are outbound HTTP THEIR instrumentation
204
+ will span, and their pipeline hands those spans back to the tap — a
205
+ feedback loop foam's own rule-24 guard cannot reach (foam does not own
206
+ their instrumentation). The SDK suppression context alone is defeated by
207
+ non-contrib patches, contrib versions predating the `untraced?` check, and
208
+ proprietary agents wrapping Net::HTTP; the documented `untraced_hosts` step
209
+ is fail-open by definition (an FDE who forgets it ships the loop).
210
+ - **Sources**: installed source — exporters send inside
211
+ `Common::Utilities.untraced` (otlp `exporter.rb:146-147,171`, logs
212
+ `logs_exporter.rb:136-137`, metrics `util.rb:29-31`); the contrib bail-out
213
+ (net_http `patches/*/instrumentation.rb` `untraced?` /
214
+ `untraced_hosts`); spec rule 24 ("not optional and not configurable off").
215
+ - **Decision & why**: three layers. (1) The suppression context, automatic —
216
+ foam's exporters all send through Net::HTTP inside `untraced`. (2) The
217
+ README-REQUIRED `untraced_hosts` step in THEIR http instrumentation (door
218
+ 2's mirror of the 18 C ignore entry; the tap cannot wire it itself — door 2
219
+ never mutates their instrumentation). (3) The ECHO FILTER, structural: a
220
+ client/producer span naming the ACTIVE export host is telemetry about
221
+ foam's own export call — the tap refuses to re-export its own echo, warns
222
+ once naming the missing step (template W5), and leaves THEIR copy of the
223
+ span untouched (additive reader intact).
224
+ - **Mitigation**: `lib/foam/otel/ingest.rb` (`SpanProcessor#echo_span?`,
225
+ `Ingest.echo_warning`); README "Door 2 — the required loop step".
226
+ - **Test**: `spec/ingest_spec.rb` case 4 — halves 1/2 (each layer alone yields
227
+ zero export spans, with a live positive control), half 3 (BOTH defeated →
228
+ the feedback span appears — red without the guards — and the echo filter
229
+ bounds it, proven against the wire bytes), and the echo-filter unit case.
230
+
108
231
  ---
109
232
 
110
- ## F1: Span attributes freeze at finish — the redaction floor cannot be a processor
233
+ ## F1: Span attributes freeze at finish — the redaction key pass cannot be a processor
111
234
 
112
- - **Trap**: The obvious place to mask attributes is a SpanProcessor, but the Ruby
113
- SDK freezes a span's attributes at `finish` BEFORE any `on_finish` processor
114
- runs — so a processor cannot mutate them, and neither can a tenant processor
115
- see a masked view.
235
+ - **Trap**: The obvious place to apply the redaction key pass is a SpanProcessor,
236
+ but the Ruby SDK freezes a span's attributes at `finish` BEFORE any `on_finish`
237
+ processor runs — so a processor cannot mutate them, and neither can a tenant
238
+ processor see a masked view.
116
239
  - **Sources**: installed source — `Span#finish` does
117
240
  `@attributes = validated_attributes(@attributes).freeze` then sets `@ended`
118
241
  (opentelemetry-sdk `trace/span.rb:277,280`); `to_span_data` returns the frozen
119
242
  `@attributes` by reference (`trace/span.rb:296-307`).
120
- - **Decision & why**: foam runs the always-on floor (rule 14) at the EXPORTER
121
- boundary — a `RedactingSpanExporter`/`RedactingLogRecordExporter` that rebuilds
122
- the mutable `SpanData`/`LogRecordData` Structs with masked attributes/events/
123
- body before serialization. This guarantees no raw secret leaves the process in
124
- foam's own export.
125
- - **Divergence (TODO pcga11)**: because attributes freeze at finish, a tenant
126
- `additional_span_processor` receives span attributes UNMASKED. Masking is
127
- EXPORT-only in Ruby, so the rule-18 C "tenant sees already-masked" guarantee
128
- holds ONLY for values the caller pre-masks with `redact()` — NOT for
129
- third-party instrumentation attributes AND NOT for values passed to
130
- `set_attribute`/`set_attributes`. Foam's own export is always masked. Recorded
131
- honestly, not papered over; surfaced to pcga11 as spec-gap material (Ruby
132
- offers no safe pre-freeze hook — the `on_finishing` hook runs inside the span
133
- mutex, so a processor calling `set_attribute` there would deadlock).
134
- - **Mitigation**: `lib/foam/otel/redacting_exporter.rb` + `lib/foam/otel/redaction.rb`.
135
- - **Test**: `spec/redacting_exporter_spec.rb` (masking on the wire),
136
- `spec/redaction_spec.rb` (the floor, tested exhaustively).
243
+ - **Opt-in redaction (holistic redesign, 2026-07-26)**: foam does NO redaction by
244
+ default — with no `redact_keys`/`redact_pii_keys` every value is captured RAW
245
+ (no secret floor, no value-pattern masking). When keys ARE configured, foam
246
+ applies the key pass at the EXPORTER boundary — a `RedactingSpanExporter`/
247
+ `RedactingLogRecordExporter` that rebuilds the mutable `SpanData`/
248
+ `LogRecordData` Structs with masked attributes/events/body before
249
+ serialization. With no keys configured the exporter is a true pass-through.
250
+ - **Divergence (NARROWED)**: when keys are configured foam's HELPERS apply the
251
+ key pass AT CAPTURE (`api.rb` `stringify`/`set_attribute`/`log`; `metrics.rb`
252
+ always did), so a tenant `additional_*` instance receives foam-helper data
253
+ ALREADY-MASKED (rule 18 C.3) — the pass is idempotent, so the wire value is
254
+ unchanged. The residual divergence is THIRD-PARTY INSTRUMENTATION attributes
255
+ only: those freeze at finish before any processor runs, so a tenant span
256
+ processor sees them as foam captured them and foam can only apply the key pass
257
+ at the exporter boundary. Ruby offers no safe pre-freeze hook — the
258
+ `on_finishing` hook runs inside the span mutex, so a processor calling
259
+ `set_attribute` there would deadlock. Foam's own export applies the configured
260
+ key pass, and the exporters FAIL CLOSED once a pass has begun: a struct whose
261
+ rebuild raises is DROPPED with a loud warning (`Redaction.mask_*` return nil on
262
+ hard failure), never exported half-masked.
263
+ - **Mitigation**: capture-time key pass in `lib/foam/otel/api.rb` +
264
+ `lib/foam/otel/redacting_exporter.rb` + `lib/foam/otel/redaction.rb`.
265
+ - **Test**: `spec/tenant_seam_spec.rb` (tenant processors receive helper-set
266
+ LISTED keys ALREADY-MASKED, free-text bodies RAW, the byte-identical
267
+ with/without-tenant proofs for spans, logs, AND metrics),
268
+ `spec/redacting_exporter_spec.rb` (opt-in masking on the wire; "a POISONED
269
+ SpanData (dup raises) is DROPPED — never exported raw"), `spec/redaction_spec.rb`
270
+ (default zero-redaction + the two opt-in key lists; capture+exporter idempotence).
137
271
 
138
272
  ## F2: OTEL_SDK_DISABLED and OTEL_PROPAGATORS are only honored by SDK.configure
139
273
 
@@ -145,18 +279,88 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
145
279
  read only by `Configurator#configure_propagation`
146
280
  (`sdk/configurator.rb:209-225`, `none` → NoopTextMapPropagator). Spec:
147
281
  https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/
148
- - **Decision & why**: foam implements both itself. `OTEL_SDK_DISABLED=true`
149
- (exact string) supersedes `enabled: true` and goes fully inert.
150
- `OTEL_PROPAGATORS=none` installs a no-op propagator (injection off, telemetry
151
- keeps flowing); any other value warns-and-ignores (foam's propagator set is
152
- fixed).
153
- - **Mitigation**: `lib/foam/otel/init.rb` (`killed = ENV["OTEL_SDK_DISABLED"] == "true"`,
154
- `configure_propagation`).
155
- - **Test**: `spec/init_spec.rb` (OTEL_SDK_DISABLED precedence + exact-string),
156
- `spec/env_vars_spec.rb` (OTEL_PROPAGATORS none / default / ignored).
282
+ - **Decision & why**: foam implements both itself. `OTEL_SDK_DISABLED` supersedes
283
+ `enabled: true` and goes fully inert when it reads (trimmed, case-insensitive)
284
+ `true` — DELIBERATELY looser than the upstream Ruby SDK's exact-string check,
285
+ so the fleet's one emergency switch (`TRUE`, ` true `) behaves identically
286
+ across languages (JS/Python already trim + downcase; the OTel boolean-env
287
+ convention is case-insensitive). `OTEL_PROPAGATORS=none` installs a no-op
288
+ propagator (injection off, telemetry keeps flowing); any other value
289
+ warns-and-ignores (foam's propagator set is fixed).
290
+ - **Mitigation**: `lib/foam/otel/init.rb` (`kill_switch_active?` — trimmed,
291
+ case-insensitive `"true"`; shared by init and the door-2 ingest factory —
292
+ and `configure_propagation`).
293
+ - **Test**: `spec/init_spec.rb` (OTEL_SDK_DISABLED precedence + trimmed
294
+ case-insensitive), `spec/env_vars_spec.rb` (OTEL_PROPAGATORS none / default /
295
+ ignored).
296
+ - **Divergence (TODO pcga11)**: the spec's env table reads
297
+ "`OTEL_EXPORTER_OTLP_ENDPOINT` (and per-signal variants) | HONORED where
298
+ present", but foam deliberately pins the per-signal variants
299
+ (`OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT`) INERT: init resolves
300
+ ONE endpoint and passes it to every exporter constructor explicitly, and
301
+ the installed exporters consult the per-signal vars only when `endpoint:`
302
+ is nil (otlp `exporter.rb` `prepare_endpoint`) — so a per-signal var can
303
+ never silently split foam's export across destinations (or dodge the
304
+ export-loop guard, which is fed the ONE resolved host). A set-but-inert
305
+ per-signal var warns loudly at init (rule 15: never a silent no-op of
306
+ operator intent). Surfaced here for a fleet ruling; README env table names
307
+ all three. *Test*: `spec/env_vars_spec.rb` ("per-signal OTLP endpoint
308
+ variants — pinned INERT": no-redirect + loud-warning examples).
157
309
 
158
310
  ---
159
311
 
312
+ ## F4: Pre-init proxy loggers poison the logs-API upgrade (L1) — one early log() would kill the whole log pipeline
313
+
314
+ - **Trap**: `opentelemetry-logs-api 0.4.1` `ProxyLoggerProvider#delegate=`
315
+ upgrades every CACHED proxy logger with a POSITIONAL
316
+ `provider.logger(key.name, key.version)` call, but the logs SDK's
317
+ `LoggerProvider#logger` is KEYWORD-ONLY (`logger(name:, version: nil)`) —
318
+ so if ANY logger was created through the pre-init proxy, the global
319
+ assignment `OpenTelemetry.logger_provider = provider` raises `ArgumentError`
320
+ MID-setter and the slot stays the inert proxy: the process exports NO logs,
321
+ ever, and the failure is one swallowed warning at init.
322
+ - **Sources**: installed source — `proxy_logger_provider.rb:42`
323
+ (`logger.delegate = provider.logger(key.name, key.version)`, positional) vs
324
+ logs-sdk `logger_provider.rb:44` (`def logger(name:, version: nil)`);
325
+ the setter shape in `opentelemetry-logs-api.rb:26-34` (delegate installed
326
+ BEFORE the raise, assignment after).
327
+ - **Decision & why**: two layers. (1) foam's `get_logger` NEVER registers into
328
+ the pre-init proxy registry — pre-init it returns the API's plain no-op
329
+ `OpenTelemetry::Logs::Logger` (pre-init logs are a silent no-op by contract
330
+ anyway), so foam's own surface can never poison the upgrade. (2) init's logs
331
+ setup rescues the `ArgumentError` and assigns AGAIN — the delegate was
332
+ installed before the raise, so the second assignment is accepted
333
+ (`delegate=` ignores the reset) and foam's provider lands; loggers a
334
+ CUSTOMER cached pre-init stay no-op, warned loudly.
335
+ - **Mitigation**: `lib/foam/otel/api.rb` (`get_logger` no-op-logger path),
336
+ `lib/foam/otel/pipelines.rb` (`setup_logs` rescue+retry).
337
+ - **Test**: `spec/init_spec.rb` ("ordering — LOG helper used BEFORE init":
338
+ rebind + held-logger cases), `spec/public_api_spec.rb` ("get_meter /
339
+ get_logger no-op in a clean process" — pins that no ProxyLogger is created).
340
+
341
+ ## F5: The propagation slot is last-write-wins — foam installs only over the API default
342
+
343
+ - **Trap**: `OpenTelemetry.propagation` is a plain `attr_writer` global
344
+ (last-write-wins). Unconditionally assigning foam's W3C composite at init
345
+ would silently REPLACE a propagator a foreign SDK installed first — severing
346
+ the customer's B3/Jaeger/X-Ray propagation (inbound extraction AND outbound
347
+ injection) in their own product. "Two SDKs racing the context manager breaks
348
+ propagation for BOTH products — worse than losing a signal" (rule 18 B).
349
+ - **Sources**: `opentelemetry-api opentelemetry.rb:27` (`attr_writer
350
+ :propagation`), `:69-71` (the reader memoizes the default
351
+ `NoopTextMapPropagator`).
352
+ - **Decision & why**: default-detection, the same shape as the provider
353
+ classifier: foam installs its W3C tracecontext+baggage set ONLY when the
354
+ slot holds the API's built-in `NoopTextMapPropagator` default; anything else
355
+ is a foreign owner and is KEPT, with one honest `[foam]` warning. (A
356
+ customer who deliberately installed their own Noop instance is
357
+ indistinguishable from the default — foam then installs W3C; the warning
358
+ path names whatever class it kept.)
359
+ - **Mitigation**: `lib/foam/otel/init.rb` (`configure_propagation` owner check).
360
+ - **Test**: `spec/propagation_spec.rb` ("does not clobber a FOREIGN propagator
361
+ installed before init", "installs foam's W3C set when the slot still holds
362
+ the API DEFAULT").
363
+
160
364
  ## F3: Last-write-wins globals — foam cannot defend a slot claimed AFTER init
161
365
 
162
366
  - **Trap**: Ruby's OTel global setters are last-write-wins — a foreign SDK that
@@ -180,6 +384,190 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
180
384
  - **Test**: `spec/coexistence_spec.rb` — "warns ONCE when a foreign SDK displaces
181
385
  a slot after init".
182
386
 
387
+ ## F6: `ignored_outbound_hosts` cannot reach Faraday / HTTP (httprb) / HTTPX (G13)
388
+
389
+ - **Trap**: Only the Net::HTTP and Excon contrib instrumentations support host
390
+ suppression. Faraday, HTTP (httprb) and HTTPX declare NO `untraced_hosts`
391
+ option, and `Base#config_options` silently DROPS unknown config keys — so
392
+ pre-installing them with foam's host list would install them UNGUARDED with
393
+ the option discarded: a tenant exporter or agent egress using those clients
394
+ still produces spans (the feedback-loop class rule 24's guard exists to
395
+ prevent), silently.
396
+ - **Sources**: installed source — net_http `instrumentation.rb:30`
397
+ (`option :untraced_hosts, default: []`); Excon includes
398
+ `Concerns::UntracedHosts`; contrib source for faraday/http/httpx
399
+ instrumentation declares no such option; `instrumentation-base base.rb:309-310`
400
+ (unknown config keys dropped with only an upstream-logger warning);
401
+ `base.rb:218-219` (install is idempotent — a bad first install can never be
402
+ re-applied by `install_all`).
403
+ - **Decision & why**: never silently not-apply a documented option. The
404
+ pre-install list is LIMITED to the two clients that honor it, and when the
405
+ FDE wires `ignored_outbound_hosts` while an unguardable client gem is
406
+ bundled, init warns loudly naming the client. Foam's OWN export loop is safe
407
+ for every client regardless (G4: the OTLP exporters send inside
408
+ `Common::Utilities.untraced`). Upstream need (untraced_hosts on
409
+ faraday/http/httpx) is flagged for filing.
410
+ - **Mitigation**: `lib/foam/otel/init.rb` (`HTTP_CLIENT_INSTRUMENTATIONS` /
411
+ `UNGUARDABLE_HTTP_CLIENT_INSTRUMENTATIONS`, `warn_unguardable_clients`);
412
+ README option-table + scenario 2/3 note.
413
+ - **Test**: `spec/loop_guard_spec.rb` ("lists the Instrumentation *class* path
414
+ for exactly the clients whose upstream SUPPORTS untraced_hosts",
415
+ "unguardable clients (GOTCHAS G13)…" warning cases).
416
+
417
+ ## F7: The classifier's FREE predicate reads `OpenTelemetry::Internal::Proxy*` (G14, rule 42 fenced reach)
418
+
419
+ - **Trap**: Ruby's provider setters are last-write-wins with no read-back of
420
+ "still default" — the ONLY non-displacing way to classify a slot is to
421
+ pre-read it against the API's internal `Internal::ProxyTracerProvider` /
422
+ `ProxyMeterProvider` / `ProxyLoggerProvider` classes (the exact check the
423
+ API's own setters use). BREAKS IF upstream renames/moves those constants:
424
+ the feature-detect (`defined?`) then makes every slot UNVERIFIABLE.
425
+ - **Sources**: installed source — `opentelemetry-api opentelemetry.rb:53`
426
+ (setter's own `instance_of? Internal::ProxyTracerProvider` check);
427
+ metrics-api / logs-api twins; RESEARCH.md §2 (why attempt-then-read is
428
+ impossible in Ruby).
429
+ - **Decision & why**: keep the pre-read (it mirrors upstream's own predicate,
430
+ pinned by the version-capped `opentelemetry-api ~> 1.1` range in the
431
+ gemspec), and DEGRADE HONESTLY: a missing constant yields a distinct
432
+ UNCLASSIFIABLE verdict — foam registers nothing (fail-safe) and warns "foam
433
+ cannot classify the <signal> slot on this OpenTelemetry API version", never
434
+ fabricating a foreign-owner name. Upstream need (a public "is the global
435
+ still default" predicate) is flagged for filing.
436
+ - **Mitigation**: `lib/foam/otel/classifier.rb` (`Verdict#unclassifiable?`,
437
+ `UNCLASSIFIABLE`), `lib/foam/otel/init.rb` (`warn_unclassifiable`).
438
+ - **Test**: `spec/classifier_spec.rb` — "internal Proxy-class read degradation
439
+ (rule 42 fence)" (both examples: fail-safe verdicts + the cannot-classify
440
+ warning with no fabricated owner).
441
+
442
+ ## F8: The classify→register gap — Ruby has no atomic set-once attempt
443
+
444
+ - **Trap**: all three Ruby setters assign UNCONDITIONALLY (last-write-wins
445
+ under a mutex that guards only the assignment — no compare-and-set), so
446
+ foam's pre-read classify and its own setter cannot be one atomic attempt
447
+ (rule 18 step 4 rests the no-race intent on attempt atomicity, which Ruby
448
+ does not offer). A foreign SDK registering in the gap between foam's
449
+ pre-read of a slot and foam's setter would be silently DISPLACED — and the
450
+ late-arrival check (`warn_if_displaced`) could never fire, because foam's
451
+ write lands last and the live provider IS foam's.
452
+ - **Sources**: installed source — opentelemetry-api `opentelemetry.rb:52-60`
453
+ (tracer setter: mutex + unconditional assignment; metrics-/logs-api twins
454
+ `opentelemetry-metrics-api.rb:26-34` / `opentelemetry-logs-api.rb:26-34`);
455
+ RESEARCH.md §2 ("no Ruby setter refuses global replacement").
456
+ - **Decision & why**: MINIMIZE the window — each signal is classified
457
+ IMMEDIATELY before its own setter (`register_free_signal` →
458
+ `classify_for_registration`), never as an up-front classify-all pass, so
459
+ the residual race is a few instructions wide instead of spanning three
460
+ whole pipeline builds. The platform offers no primitive to close it
461
+ entirely; a foreign SDK racing inside that residual window on another
462
+ thread is displaced with no warning — recorded honestly here (the same
463
+ honesty F3 gives the after-init displacement). Boot-time init on the main
464
+ thread (README: init FIRST, before the app boots) makes the residual
465
+ window practically unreachable.
466
+ - **Mitigation**: `lib/foam/otel/init.rb` (`register_free_signal` /
467
+ `classify_for_registration` — the per-signal classify-then-set pairing).
468
+ - **Test**: `spec/coexistence_spec.rb` — "TOCTOU window (GOTCHAS F8): a
469
+ foreign SDK claiming LOGS while foam registers TRACES is kept and warned,
470
+ never silently displaced".
471
+
472
+ ## F9: The upstream datastore instrumentations SANITIZE by default — db_statement :obfuscate would stand silently
473
+
474
+ - **Trap**: pg/mysql2/redis/mongo contrib all declare
475
+ `option :db_statement, default: :obfuscate` — an `install_all` with no
476
+ config ships OBFUSCATED statements, violating the zero-default-redaction
477
+ ruling with no error anywhere. Worse, `Base#config_options` silently
478
+ REPLACES an invalid option value with the default — so a contrib bump that
479
+ renamed/dropped `:include` would silently fall BACK to `:obfuscate`.
480
+ - **Sources**: installed source — pg `instrumentation.rb:29`, mysql2
481
+ `instrumentation.rb:24`, redis `instrumentation.rb:24`, mongo
482
+ `instrumentation.rb:36` (the `option :db_statement, default: :obfuscate`
483
+ lines); `instrumentation-base base.rb:309-310` (invalid values replaced
484
+ with the default, warning goes to the upstream logger only). Ruling:
485
+ docs/decisions/holistic-redesign-decision-log.md (raised floor, raw
486
+ capture 2026-07-26).
487
+ - **Decision & why**: activation passes a CENTRAL config map
488
+ (`FLOOR_INSTRUMENTATION_CONFIG`, `lib/foam/otel/init.rb`) setting
489
+ `db_statement: :include` for all four datastore instrumentations — raw
490
+ statements are the floor's point; masking is opt-in via redact keys at
491
+ foam's redaction stage. The option's continued upstream validity is pinned
492
+ by a test so a contrib rename is a CI failure, never a silent sanitize.
493
+ - **Test**: `spec/floor_spec.rb` — "install_all receives the CENTRAL
494
+ raw-capture config", ":include is a declared db_statement option on every
495
+ configured floor gem"; presence checks stay proven there too.
496
+
497
+ ## F10: The stdlib Logger bridge — two feedback loops and the lazy-block contract
498
+
499
+ - **Trap**: bridging `::Logger` naively (1) turns the OTel SDK's OWN internal
500
+ logger into a producer — every export failure logs, which emits a record,
501
+ which must be exported: unbounded growth the per-stack re-entrancy flag
502
+ cannot see because the batch thread is a different stack; (2) evaluates a
503
+ caller's lazy `logger.debug { expensive }` block even when the severity is
504
+ filtered (stdlib's block form exists precisely to avoid that cost), or
505
+ evaluates an accepted block TWICE (once for the bridge, once in `super`).
506
+ - **Sources**: installed source — opentelemetry-api `opentelemetry.rb:30-31`
507
+ (`OpenTelemetry.logger` memoized `Logger.new($stdout)`) and `:37` (the
508
+ error handler logs through it — the export-failure feedback edge); stdlib
509
+ logger gem `logger.rb:675` (`Logger#add`, the single funnel; lazy block
510
+ resolution order); foam's emit path `lib/foam/otel/logger_bridge.rb`.
511
+ - **Decision & why**: prepend ONE patch on `Logger#add`; skip
512
+ `OpenTelemetry.logger` by live identity; hold a thread-local re-entrancy
513
+ flag around the emit; resolve an accepted block exactly once and hand the
514
+ RESULT down to `super`; return `super` untouched for filtered severities
515
+ (block unevaluated). Emission is gated on foam OWNING the logs slot (rule
516
+ 18 B) and every bridge failure degrades to the stdlib write alone (rule 9).
517
+ - **Test**: `spec/logger_bridge_spec.rb` — the two loop-guard examples, the
518
+ lazy-block pair, the foreign-slot darkness, and "NEVER breaks the host's
519
+ log call".
520
+
521
+ ## F11: LLM shims patch third-party SDK call sites — the model call is sacred
522
+
523
+ - **Trap**: no official Ruby contrib instrumentation exists for ANY LLM SDK,
524
+ so foam patches the SDKs' public methods directly. A raising extractor, a
525
+ broken tracer, or a semantic drift in the SDK's params/response shapes
526
+ must never fail the customer's model call (a broken instrumentation that
527
+ drops a production LLM request is the worst possible trade), and the SDK's
528
+ own HTTP send must not produce a SECOND span under the gen_ai span.
529
+ - **Sources**: installed source — openai `resources/chat/completions.rb:119`
530
+ / `resources/responses.rb:96`, anthropic `resources/messages.rb:68`,
531
+ gemini-ai `controllers/client.rb:151`, ruby_llm `provider.rb:44` (the
532
+ patched call sites); opentelemetry-common `utilities.rb:104-117`
533
+ (`untraced` — the suppression context the http contrib honors). Ruling:
534
+ LLM provider parity + testing-mandatory (decision log 2026-07-26).
535
+ - **Decision & why**: every patch routes through ONE guarded core
536
+ (`Foam::Otel::LLM.observe`, `lib/foam/otel/llm.rb`): request extraction
537
+ inside `safe_request` (degrades to bare operation/provider), span start
538
+ fail-to-dark (call runs unspanned), the SDK call's error re-raised
539
+ IDENTICALLY with `error.type` (+ the OBSERVED http status only), the SDK's
540
+ transport run inside `Common::Utilities.untraced` (one call = one span).
541
+ Presence-checked constants + explicit version windows per SDK; emission
542
+ gated on foam owning traces. Wire names are the GenAI semconv strings the
543
+ fleet's js/python gap-fillers emit — content attributes included, RAW.
544
+ - **Test**: `spec/llm_openai_spec.rb`, `spec/llm_anthropic_spec.rb`,
545
+ `spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb` — real SDKs against
546
+ a local fake provider (only the 3rd party mocked), per-provider activity +
547
+ RAW-content assertions, error-path re-raise, and the fail-to-dark set.
548
+
549
+ ## F12: Session stamping must beat the freeze (spans) and the buffer (logs)
550
+
551
+ - **Trap**: the two signals offer different safe write points. Span
552
+ attributes FREEZE at finish (F1) — an on_finish stamp is impossible; log
553
+ records are buffered LIVE by the batch processor and serialized later on
554
+ another thread — a stamp must land on the emitting thread before export
555
+ can convert the record.
556
+ - **Sources**: installed source — opentelemetry-sdk `trace/span.rb:277-280`
557
+ (freeze at finish), logs-sdk `batch_log_record_processor.rb:70-83`
558
+ (`on_emit` buffers the live record) + `export` (LogRecordData conversion
559
+ at export time). Ruling: session stitching (decision log 2026-07-26).
560
+ - **Decision & why**: spans stamp in `on_start` (order-independent — the
561
+ attribute exists long before finish); logs stamp in `on_emit` on the
562
+ emitting thread, registered right after foam's batch processor (which
563
+ stays FIRST — pinned invariant). Both read `session.id` /
564
+ `session.previous_id` from the CONTEXT's baggage (foam's W3C propagator
565
+ set already extracts it), stamp verbatim, no-op when absent, never raise.
566
+ Door-2 taps are untouched — foam never mutates the customer's records.
567
+ - **Test**: `spec/session_stitching_spec.rb` — span + log stamping, the
568
+ rack end-to-end with real `traceparent`+`baggage` headers, the no-op and
569
+ never-raise cases.
570
+
183
571
  ## General gotchas (applicable to Ruby)
184
572
 
185
573
  - **G1 — init after target import / pre-init no-op**: the API's proxy providers
@@ -189,29 +577,110 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
189
577
  - **G2 — process ends before flush**: batch processors buffer; `flush` is public
190
578
  and the README wires it at handler end / worker exit.
191
579
  *Test*: `spec/fork_spec.rb` (child flush).
580
+ - **G3 — batch queue silently drops**: a full `BatchSpanProcessor`/
581
+ `BatchLogRecordProcessor` queue drops telemetry with no signal by default
582
+ (installed source: `batch_span_processor.rb` `report_dropped_spans`, reasons
583
+ `buffer-full`/`terminating`, visible only through the SDK's internal metrics
584
+ reporter / `OTEL_LOG_LEVEL=debug` error handler). Foam keeps upstream's
585
+ buffering untouched (rule 4: no client-side shedding decisions of its own)
586
+ and makes loss VISIBLE instead: `flush()` is public for burst/exit paths and
587
+ the failure-modes/diagnostics story is README surface; drops in foam's OWN
588
+ fail-closed path (redaction hard failure) always warn loudly.
589
+ *Test*: `spec/redacting_exporter_spec.rb` (loud drop warnings);
590
+ `spec/public_api_spec.rb` ("actually drains a pending span"); conformance
591
+ FLUSH phase (10-minute batch delays — only flush/shutdown deliver).
192
592
  - **G4 — exporter self-tracing loop**: the OTLP exporter sends inside an
193
593
  `OpenTelemetry::Common::Utilities.untraced` context the official
194
594
  instrumentation respects (opentelemetry-exporter-otlp `exporter.rb:147`), so
195
595
  export never traces itself; `ignored_outbound_hosts` + the endpoint host also
196
- feed the http-client instrumentations' `untraced_hosts`
596
+ feed the http-client instrumentations' `untraced_hosts` where upstream
597
+ supports the option — Net::HTTP and Excon only, see F6/G13
197
598
  (`lib/foam/otel/init.rb` `configure_loop_guard`).
198
- *Test*: `test-apps/ruby-rails` gate (no feedback spans appear).
599
+ *Test*: `spec/loop_guard_spec.rb` (behavioral zero-spans proof);
600
+ `test-apps/ruby-rails` gate (no feedback spans appear).
199
601
  - **G5 — context across async boundaries**: `Context.current` is fiber-local; a
200
602
  raw `Thread.new` loses the span. foam relies on the official
201
603
  `-concurrent_ruby` and `-active_job` instrumentations (bundled via `-rails`) to
202
604
  carry context across Rails' pools and jobs; a hand-rolled thread is the FDE's
203
- to wrap. *Documented*; conformance covers the rack-context path.
605
+ to wrap. *Test*: `spec/gotcha_spec.rb` ("the bundled concurrent_ruby instrumentation
606
+ carries the active span across a Promises thread pool"); conformance covers
607
+ the rack-context path.
608
+ - **G6 — customer infrastructure strips propagation headers**: gateways/LBs
609
+ strip or rewrite `traceparent`, and a format mismatch (W3C sender, B3-only
610
+ receiver) fails extraction SILENTLY — every service becomes its own root.
611
+ Foam cannot fix their infra; foam keeps extraction non-fabricating (a fresh
612
+ root, never invented ids), keeps the W3C set the default, and the README
613
+ troubleshooting entry ("a downstream rejects traceparent" /
614
+ `OTEL_PROPAGATORS=none`) carries the operator lever. When the incumbent owns
615
+ the propagator, foam YIELDS (F5) so the formats the customer's infra expects
616
+ keep flowing.
617
+ *Test*: `spec/propagation_spec.rb` (inbound respected, injection exact);
618
+ conformance MAIN phase (two-hop continuation with fixed W3C ids) and
619
+ PROPNONE phase (extraction+injection stop, telemetry flows).
204
620
  - **G7 — metric cardinality explosion**: the metric helpers forward
205
621
  caller-supplied `attributes` verbatim; an unbounded attribute value (user id,
206
- raw path) explodes the series count. The README carries the warning with the
207
- classic bad example. *Test*: `spec/metrics_spec.rb` (attribute passthrough).
622
+ raw path) explodes the series count. There is deliberately NO code
623
+ mitigation (foam never rewrites or drops metric dimensions — rule 4/26);
624
+ the mitigation is the README metrics warning with the classic bad example —
625
+ *Documented* (README "Metrics" section). *Test*: `spec/metrics_spec.rb`
626
+ ("captures a metric attribute RAW by default; masks it only when the key is in
627
+ redact_keys" — pins that unlisted attributes pass through VERBATIM).
628
+ - **G8 — never-ended spans leak memory**: a span started and never ended holds
629
+ memory and context forever (and never exports — the batch processor only
630
+ sees FINISHED spans, `on_finish`). `Foam::Otel.span {}` always ends the span
631
+ by construction (rule 9), proven even when the block raises; `get_tracer`
632
+ passthrough users own their `span.finish` — README note ("the span always
633
+ ends") rather than code.
634
+ *Test*: `spec/public_api_spec.rb` ("re-raises the block's error identically
635
+ AND records it, always ending the span" — the span appears in
636
+ finished_spans, i.e. it was ended).
208
637
  - **G9 — a second SDK / injected agent**: the classifier detects a foreign
209
- provider per signal and warns by name; foam registers only into free slots.
638
+ provider per signal and warns by name; foam registers only into free slots —
639
+ and installs NO instrumentation into a foreign-owned trace pipeline
640
+ (`spec/activation_spec.rb` claimed-traces cases).
210
641
  *Test*: `spec/coexistence_spec.rb` (8-permutation matrix), `spec/classifier_spec.rb`.
642
+ - **G10 — instrumentor version-range drift**: a contrib gem supports library
643
+ vN; the customer upgrades to vN+1 and the instrumentation goes silent (each
644
+ contrib gem's `compatible?`/`present?` gates install) — or, worse, an
645
+ UPGRADED contrib gem changes what it emits (rule 26: contrib pins set the
646
+ semconv). Foam caps every contrib range at the proven minor in the gemspec
647
+ (`-rack >= 0.26, < 0.32`, `-rails >= 0.31, < 0.43` — reasons at the pins) so
648
+ drift is a deliberate foam release with the fixtures re-proven, never an
649
+ in-place `bundle update` surprise; the supported matrix is README surface.
650
+ *Test*: `spec/gemspec_spec.rb` ("rule 40 — capped ranges"); conformance gate
651
+ pins the emitted rack/action_pack span shape against the locked pair.
652
+ - **G11 — MetricReader temporality and Views**: multiple readers per
653
+ MeterProvider are spec-legal; each reader triggers its OWN collection
654
+ (`MetricReader#collect` pulls the shared `@metric_store`,
655
+ metrics-sdk `export/metric_reader.rb:21`), and the Ruby metrics SDK's
656
+ temporality/Views story is the least-documented corner upstream (alpha —
657
+ see R2). Foam's own reader is the stock `PeriodicMetricReader` with upstream
658
+ defaults (no Views, no temporality override — thin passthrough, rule 26);
659
+ a tenant reader supplied via `additional_metric_readers` runs its collect
660
+ loop on ITS thread (tenant code, not foam-invoked — the never-throw guard
661
+ covers foam-invoked flush/shutdown only, README says so), and foam's export
662
+ is byte-identical with and without it.
663
+ *Test*: `spec/tenant_seam_spec.rb` ("foam's METRICS export is IDENTICAL with
664
+ and without a tenant metric reader", "a tenant metric reader raising in
665
+ force_flush/shutdown never breaks foam-invoked flush or shutdown").
211
666
  - **G12 — two copies of the OTel API**: two gem copies → two global registries →
212
- silent no-op or doubled telemetry. Cannot be seen at build time; the README
667
+ silent no-op or doubled telemetry. Ruby's Bundler makes duplicate same-name
668
+ gems unresolvable (one activated copy per process — a second copy raises
669
+ `Gem::LoadError` loudly), so the trap is a REBUNDLED/vendored copy; the README
213
670
  troubleshooting entry says to check `bundle list | grep opentelemetry-api` for
214
- one copy first. *Documented* (README).
671
+ one copy first. *Test*: `spec/gotcha_spec.rb` ("exactly ONE opentelemetry-api gem is
672
+ active", "the API entry file was loaded from exactly ONE gem copy"),
673
+ `spec/packaging_spec.rb` ("activating a conflicting opentelemetry-api
674
+ version raises Gem::LoadError at boot").
675
+ - **G15 — runtime metrics are asynchronous-instrument callbacks**: the
676
+ hand-written GC/thread/RSS readings run on the PeriodicMetricReader's
677
+ thread at COLLECT time (no extra timer thread; fork recovery rides R1's
678
+ reader restart). A raising reading (missing GC.stat key, unreadable /proc)
679
+ must degrade to nil — the SDK ignores non-numeric callback results — so one
680
+ bad reading can never kill the collect loop or the healthy instruments
681
+ beside it (`lib/foam/otel/runtime_metrics.rb` `guarded_callback`).
682
+ *Test*: `spec/runtime_metrics_spec.rb` ("a poisoned reading is
683
+ fail-to-dark", the foreign-slot and idempotence cases).
215
684
 
216
685
  ---
217
686