foam-otel 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55498709d9b6b79c84356755456455766ce4cc3293174efce14e21e4666a19d2
4
- data.tar.gz: f7e5a00c801883644e33995415c538ae0410cd52926515be110c35803dfe7222
3
+ metadata.gz: b4e3f8240b1b181676669710856d6f7a2cd6c95ebf34c7598b1197a2b54c3de7
4
+ data.tar.gz: c81e51f8331c9d60ed0f59e1638a220a1943429c41496af77b27877d35deeaad
5
5
  SHA512:
6
- metadata.gz: fbc098c14d51948a8849f4f1fd8522876fcde089a1e065bded46547d4298148c8465c4d630803ace66aa7d38612f4a07f20ad1c921eea8dfeeb24ea99bb76a69
7
- data.tar.gz: 3d19f5acc8ab0acd77d3ce0f0f851cd84edcb24afa84335d283ace4680e648a50b4720d3117c93bdd5a9c2edce6755a5c3a0ff6d13e0b904cfb30f3e2cd14930
6
+ metadata.gz: 2f2ea8f51b7ccc19bc042a360e4892d5d7a75bf7c19ab06f27ef104782e063333dd2f22eef34a3ee0b0034256005db3398f5ffeb5a943f8bec4354c44bf33fba
7
+ data.tar.gz: 830a7a9c6ac02f4168a6575c14f9d373b9781c50d5e6feda19a23d3da114bd4a440259ff53b573653e6b3321a081ed2cc181f3899959738d80da336378730f9a
data/GOTCHAS.md CHANGED
@@ -36,13 +36,21 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
36
36
  span/log in the child — foam relies on upstream and never reimplements it. The
37
37
  PeriodicMetricReader does not, and foam bypasses `SDK.configure`, so foam
38
38
  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".
39
+ - **Mitigation**: `lib/foam/otel/fork_hooks.rb` prepends `Process._fork` and, in
40
+ the child, calls `Foam::Otel.after_fork!` (restarts tracked metric readers,
41
+ `lib/foam/otel/api.rb`) and clears the init idempotency guard
42
+ (`mark_forked!`), so the README's cluster-mode `init`-per-worker recipe is a
43
+ REAL re-init: it retires the inherited pipelines, re-owns foam's own slots
44
+ (`classify_for_registration`, `lib/foam/otel/init.rb` — a genuinely FOREIGN
45
+ provider is never touched), and mints a fresh `service.instance.id` per worker
46
+ (section 0 resource table). `after_fork!` is also exposed for
47
+ `Puma on_worker_boot` / `Unicorn after_fork` without re-init.
48
+ - **Test**: `spec/fork_spec.rb` — "B3: a worker re-init after fork mints a NEW
49
+ service.instance.id", "the Process._fork hook fires in the CHILD only …
50
+ restarts the PeriodicMetricReader thread in the child", "the child's re-init
51
+ re-owns FOAM's inherited slots but NEVER a foreign provider", "init() itself
52
+ installs the fork hook", plus the child-flush smoke; conformance FORK phase
53
+ (distinct per-worker instance ids on the wire).
46
54
 
47
55
  ## R2: Signal maturity is uneven — logs & metrics SDKs are pre-1.0
48
56
 
@@ -52,12 +60,18 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
52
60
  — https://opentelemetry.io/docs/languages/ruby/ ; the metrics-sdk README states
53
61
  "alpha … things may break and APIs may change". Installed versions:
54
62
  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
63
+ - **Decision & why**: CAP every pre-1.0 range at the next minor above the
64
+ version the suite + conformance gate last proved (rule 40: a loose
65
+ two-segment `~> 0.x` would admit breaking 0.x minors into customer installs
66
+ the lockfile only protects foam's own CI, never a customer resolution),
67
+ and wrap both signals behind stable foam helpers (`log`, the four metric
68
+ helpers) so app code never touches the churning API directly. Widening a cap
69
+ is a deliberate rule-44 re-prove.
70
+ - **Mitigation**: capped gemspec ranges (e.g. `>= 0.6, < 0.7` logs-sdk,
71
+ `>= 0.15, < 0.16` metrics-sdk — reasons at each pin) + the helper wrappers
72
+ (`lib/foam/otel/api.rb`, `lib/foam/otel/metrics.rb`).
73
+ - **Test**: `spec/gemspec_spec.rb` ("rule 40 — capped ranges, never an open
74
+ floor"), `spec/version_spec.rb` — pins the supported SDK lines and proves the
61
75
  helpers work over the pinned pre-1.0 SDKs.
62
76
 
63
77
  ## R3: Contrib instrumentations can themselves raise
@@ -105,6 +119,112 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
105
119
  real Rails app boots with foam and the official rack span carries the inbound
106
120
  telemetry (`/ok`, `/fail`, `/reject` assertions).
107
121
 
122
+ ## R5: Door-2 ingest taps ride the alpha logs/metrics SDKs — every behavioral reliance pinned (rule 22a / fleet audit R8)
123
+
124
+ - **Trap**: the log and metric ingest entries attach to upstream SDKs that are
125
+ pre-1.0 ("development"/alpha). Four load-bearing behaviors could drift under
126
+ a 0.x minor: (1) `MeterProvider#add_metric_reader` BACK-FILLS instruments
127
+ created before attach; (2) `MetricReader#metric_store` is the seam
128
+ `add_metric_reader` registers instruments through (why the tap SUBCLASSES
129
+ `PeriodicMetricReader` instead of composing); (3) collected data points are
130
+ SHALLOW dups whose `attributes` hash is the LIVE aggregation key — in-place
131
+ masking would corrupt the customer's aggregation state; (4)
132
+ `LogRecord#resource` carries the emitting provider's resource (the
133
+ self-ingest detection read).
134
+ - **Sources**: installed source — `meter_provider.rb:110-121` (back-fill walk
135
+ + `register_synchronous_instrument` fan-out), `metric_reader.rb:15-22`
136
+ (`metric_store` seam), `aggregation/sum.rb:44-50` (`ndp.dup` shallow
137
+ copies), `logs/log_record.rb:16-26` (`attr_accessor :resource`);
138
+ maturity statement — https://opentelemetry.io/docs/languages/ruby/
139
+ - **Decision & why**: ship the entries under rule 22a's experimental posture
140
+ (stated in the README Door 2 section), keep every reliance inside the
141
+ already-capped gemspec ranges (R2), and pin each with a test so a 0.x bump
142
+ is a deliberate rule-44 re-prove (any widening re-runs the door-2 e2e),
143
+ never a silent drift.
144
+ - **Mitigation**: capped ranges (`foam-otel.gemspec`); masking rebuilds point
145
+ COPIES with fresh attribute hashes (`Redaction.mask_metric_data`,
146
+ `lib/foam/otel/redaction.rb`); subclassing isolated to
147
+ `lib/foam/otel/ingest_metric_reader.rb`.
148
+ - **Test**: `spec/ingest_spec.rb` — "instruments created BEFORE attach flow to
149
+ foam (back-fill)" (reliances 1-2), "mask_metric_data is FAIL-CLOSED … and
150
+ never mutates the customer's point" (reliance 3), the case-2 log test +
151
+ self-ingest cases (reliance 4).
152
+
153
+ ## R6: PeriodicMetricReader thread death — a raising exporter or collect kills door-2 metrics silently
154
+
155
+ - **Trap**: `PeriodicMetricReader#export` has NO rescue: one exception from
156
+ `collect` or the exporter chain kills the reader's ticker thread, and the
157
+ metrics tap goes dark forever with nothing but a dead thread (rule 15's
158
+ silent-loss shape, on someone else's pipeline).
159
+ - **Sources**: installed source — `periodic_metric_reader.rb:105-131`
160
+ (`export` calls `collect` + `@exporter.export` unrescued inside the ticker
161
+ loop).
162
+ - **Decision & why**: close both halves inside foam. The tap's whole exporter
163
+ chain (stamp → mask → OTLP) rescues `StandardError, SystemStackError` to
164
+ `FAILURE`, and the reader subclass overrides `collect` with a `[]`-on-error
165
+ guard — the thread can never see an exception from foam's side.
166
+ - **Mitigation**: `lib/foam/otel/ingest.rb` (`MetricStampExporter`),
167
+ `lib/foam/otel/redacting_exporter.rb` (`RedactingMetricsExporter`),
168
+ `lib/foam/otel/ingest_metric_reader.rb` (`collect` guard).
169
+ - **Test**: `spec/ingest_spec.rb` — "the reader thread survives a poisoned
170
+ collect ([] on error) and a raising exporter chain (GOTCHAS R6)".
171
+
172
+ ## R7: The door-2 fork registry — tap readers must survive fork AND a foam re-init (@ingest_taps ≠ @metric_readers)
173
+
174
+ - **Trap**: two ways a door-2 metrics tap dies in cluster mode. (1) The
175
+ reader thread is not restarted after fork: upstream wires `after_fork` only
176
+ through `SDK.configure`'s ForkHooks, which door 2 never runs (the tap rides
177
+ the CUSTOMER'S manual setup). (2) If tap readers were tracked in foam's own
178
+ `@metric_readers`, the post-fork re-init path (`retire_foam_pipelines!`)
179
+ would CLEAR them — orphaning readers that belong to the customer's
180
+ pipeline, dark in every worker with no warning.
181
+ - **Sources**: installed source — `periodic_metric_reader.rb:82-87`
182
+ (`after_fork` restarts the thread + resets the exporter),
183
+ `metrics/configurator_patch.rb:57` (upstream's hook is SDK.configure-only);
184
+ foam's own `init.rb` `retire_foam_pipelines!` (clears `@metric_readers`).
185
+ - **Decision & why**: the ingest factory installs foam's `Process._fork` hook
186
+ (idempotent) and registers tap readers in a SEPARATE module registry,
187
+ `@ingest_taps`, walked by `Foam::Otel.after_fork!` beside foam's own
188
+ readers and deliberately untouched by pipeline retirement. The exporter
189
+ wrappers forward `reset` so `after_fork` reaches the OTLP connection.
190
+ - **Mitigation**: `lib/foam/otel/init.rb` (`register_ingest_tap!`,
191
+ `retire_foam_pipelines!` comment), `lib/foam/otel/api.rb` (`after_fork!`
192
+ walks both), `lib/foam/otel/ingest.rb` (registration + `reset` forwarding).
193
+ - **Test**: `spec/ingest_spec.rb` — "retire_foam_pipelines! clears
194
+ @metric_readers but NEVER @ingest_taps", "a REAL fork: foam's Process._fork
195
+ hook restarts the tap's reader thread in the child", "the exporter chain
196
+ forwards reset for after_fork".
197
+
198
+ ## R8: The tap's loop story — untraced coverage, the REQUIRED README step, and the echo filter
199
+
200
+ - **Trap**: the tap's exports to foam are outbound HTTP THEIR instrumentation
201
+ will span, and their pipeline hands those spans back to the tap — a
202
+ feedback loop foam's own rule-24 guard cannot reach (foam does not own
203
+ their instrumentation). The SDK suppression context alone is defeated by
204
+ non-contrib patches, contrib versions predating the `untraced?` check, and
205
+ proprietary agents wrapping Net::HTTP; the documented `untraced_hosts` step
206
+ is fail-open by definition (an FDE who forgets it ships the loop).
207
+ - **Sources**: installed source — exporters send inside
208
+ `Common::Utilities.untraced` (otlp `exporter.rb:146-147,171`, logs
209
+ `logs_exporter.rb:136-137`, metrics `util.rb:29-31`); the contrib bail-out
210
+ (net_http `patches/*/instrumentation.rb` `untraced?` /
211
+ `untraced_hosts`); spec rule 24 ("not optional and not configurable off").
212
+ - **Decision & why**: three layers. (1) The suppression context, automatic —
213
+ foam's exporters all send through Net::HTTP inside `untraced`. (2) The
214
+ README-REQUIRED `untraced_hosts` step in THEIR http instrumentation (door
215
+ 2's mirror of the 18 C ignore entry; the tap cannot wire it itself — door 2
216
+ never mutates their instrumentation). (3) The ECHO FILTER, structural: a
217
+ client/producer span naming the ACTIVE export host is telemetry about
218
+ foam's own export call — the tap refuses to re-export its own echo, warns
219
+ once naming the missing step (template W5), and leaves THEIR copy of the
220
+ span untouched (additive reader intact).
221
+ - **Mitigation**: `lib/foam/otel/ingest.rb` (`SpanProcessor#echo_span?`,
222
+ `Ingest.echo_warning`); README "Door 2 — the required loop step".
223
+ - **Test**: `spec/ingest_spec.rb` case 4 — halves 1/2 (each layer alone yields
224
+ zero export spans, with a live positive control), half 3 (BOTH defeated →
225
+ the feedback span appears — red without the guards — and the echo filter
226
+ bounds it, proven against the wire bytes), and the echo-filter unit case.
227
+
108
228
  ---
109
229
 
110
230
  ## F1: Span attributes freeze at finish — the redaction floor cannot be a processor
@@ -122,18 +242,29 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
122
242
  the mutable `SpanData`/`LogRecordData` Structs with masked attributes/events/
123
243
  body before serialization. This guarantees no raw secret leaves the process in
124
244
  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
245
+ - **Divergence (TODO pcga11, NARROWED)**: foam's HELPERS now mask AT CAPTURE
246
+ (`api.rb` `stringify`/`set_attribute`/`log` run the floor before the value
247
+ reaches the span/log record; `metrics.rb` always did), so a tenant
248
+ `additional_*` instance receives foam-helper data ALREADY-MASKED (rule 18
249
+ C.3) masking is idempotent, so the wire value is unchanged. The residual
250
+ divergence is THIRD-PARTY INSTRUMENTATION attributes only: those freeze at
251
+ finish before any processor runs, so a tenant span processor sees them
252
+ UNMASKED and foam can only mask them at the exporter boundary. Recorded
131
253
  honestly, not papered over; surfaced to pcga11 as spec-gap material (Ruby
132
254
  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).
255
+ mutex, so a processor calling `set_attribute` there would deadlock). Foam's
256
+ own export is always masked, and the exporters FAIL CLOSED: a struct whose
257
+ rebuild raises is DROPPED with a loud warning (`Redaction.mask_*` return nil
258
+ on hard failure), never exported raw.
259
+ - **Mitigation**: capture-time masking in `lib/foam/otel/api.rb` +
260
+ `lib/foam/otel/redacting_exporter.rb` + `lib/foam/otel/redaction.rb`.
261
+ - **Test**: `spec/tenant_seam_spec.rb` ("a generic tenant processor receives
262
+ HELPER-SET secrets ALREADY-MASKED", "a tenant LOG processor receives
263
+ helper-set body + attributes ALREADY-MASKED", the byte-identical
264
+ with/without-tenant proofs for spans, logs, AND metrics),
265
+ `spec/redacting_exporter_spec.rb` (masking on the wire; "a POISONED SpanData
266
+ (dup raises) is DROPPED — never exported raw"), `spec/redaction_spec.rb`
267
+ (the floor, tested exhaustively; capture+floor idempotence).
137
268
 
138
269
  ## F2: OTEL_SDK_DISABLED and OTEL_PROPAGATORS are only honored by SDK.configure
139
270
 
@@ -145,18 +276,88 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
145
276
  read only by `Configurator#configure_propagation`
146
277
  (`sdk/configurator.rb:209-225`, `none` → NoopTextMapPropagator). Spec:
147
278
  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).
279
+ - **Decision & why**: foam implements both itself. `OTEL_SDK_DISABLED` supersedes
280
+ `enabled: true` and goes fully inert when it reads (trimmed, case-insensitive)
281
+ `true` DELIBERATELY looser than the upstream Ruby SDK's exact-string check,
282
+ so the fleet's one emergency switch (`TRUE`, ` true `) behaves identically
283
+ across languages (JS/Python already trim + downcase; the OTel boolean-env
284
+ convention is case-insensitive). `OTEL_PROPAGATORS=none` installs a no-op
285
+ propagator (injection off, telemetry keeps flowing); any other value
286
+ warns-and-ignores (foam's propagator set is fixed).
287
+ - **Mitigation**: `lib/foam/otel/init.rb` (`kill_switch_active?` trimmed,
288
+ case-insensitive `"true"`; shared by init and the door-2 ingest factory —
289
+ and `configure_propagation`).
290
+ - **Test**: `spec/init_spec.rb` (OTEL_SDK_DISABLED precedence + trimmed
291
+ case-insensitive), `spec/env_vars_spec.rb` (OTEL_PROPAGATORS none / default /
292
+ ignored).
293
+ - **Divergence (TODO pcga11)**: the spec's env table reads
294
+ "`OTEL_EXPORTER_OTLP_ENDPOINT` (and per-signal variants) | HONORED where
295
+ present", but foam deliberately pins the per-signal variants
296
+ (`OTEL_EXPORTER_OTLP_{TRACES,METRICS,LOGS}_ENDPOINT`) INERT: init resolves
297
+ ONE endpoint and passes it to every exporter constructor explicitly, and
298
+ the installed exporters consult the per-signal vars only when `endpoint:`
299
+ is nil (otlp `exporter.rb` `prepare_endpoint`) — so a per-signal var can
300
+ never silently split foam's export across destinations (or dodge the
301
+ export-loop guard, which is fed the ONE resolved host). A set-but-inert
302
+ per-signal var warns loudly at init (rule 15: never a silent no-op of
303
+ operator intent). Surfaced here for a fleet ruling; README env table names
304
+ all three. *Test*: `spec/env_vars_spec.rb` ("per-signal OTLP endpoint
305
+ variants — pinned INERT": no-redirect + loud-warning examples).
157
306
 
158
307
  ---
159
308
 
309
+ ## F4: Pre-init proxy loggers poison the logs-API upgrade (L1) — one early log() would kill the whole log pipeline
310
+
311
+ - **Trap**: `opentelemetry-logs-api 0.4.1` `ProxyLoggerProvider#delegate=`
312
+ upgrades every CACHED proxy logger with a POSITIONAL
313
+ `provider.logger(key.name, key.version)` call, but the logs SDK's
314
+ `LoggerProvider#logger` is KEYWORD-ONLY (`logger(name:, version: nil)`) —
315
+ so if ANY logger was created through the pre-init proxy, the global
316
+ assignment `OpenTelemetry.logger_provider = provider` raises `ArgumentError`
317
+ MID-setter and the slot stays the inert proxy: the process exports NO logs,
318
+ ever, and the failure is one swallowed warning at init.
319
+ - **Sources**: installed source — `proxy_logger_provider.rb:42`
320
+ (`logger.delegate = provider.logger(key.name, key.version)`, positional) vs
321
+ logs-sdk `logger_provider.rb:44` (`def logger(name:, version: nil)`);
322
+ the setter shape in `opentelemetry-logs-api.rb:26-34` (delegate installed
323
+ BEFORE the raise, assignment after).
324
+ - **Decision & why**: two layers. (1) foam's `get_logger` NEVER registers into
325
+ the pre-init proxy registry — pre-init it returns the API's plain no-op
326
+ `OpenTelemetry::Logs::Logger` (pre-init logs are a silent no-op by contract
327
+ anyway), so foam's own surface can never poison the upgrade. (2) init's logs
328
+ setup rescues the `ArgumentError` and assigns AGAIN — the delegate was
329
+ installed before the raise, so the second assignment is accepted
330
+ (`delegate=` ignores the reset) and foam's provider lands; loggers a
331
+ CUSTOMER cached pre-init stay no-op, warned loudly.
332
+ - **Mitigation**: `lib/foam/otel/api.rb` (`get_logger` no-op-logger path),
333
+ `lib/foam/otel/pipelines.rb` (`setup_logs` rescue+retry).
334
+ - **Test**: `spec/init_spec.rb` ("ordering — LOG helper used BEFORE init":
335
+ rebind + held-logger cases), `spec/public_api_spec.rb` ("get_meter /
336
+ get_logger no-op in a clean process" — pins that no ProxyLogger is created).
337
+
338
+ ## F5: The propagation slot is last-write-wins — foam installs only over the API default
339
+
340
+ - **Trap**: `OpenTelemetry.propagation` is a plain `attr_writer` global
341
+ (last-write-wins). Unconditionally assigning foam's W3C composite at init
342
+ would silently REPLACE a propagator a foreign SDK installed first — severing
343
+ the customer's B3/Jaeger/X-Ray propagation (inbound extraction AND outbound
344
+ injection) in their own product. "Two SDKs racing the context manager breaks
345
+ propagation for BOTH products — worse than losing a signal" (rule 18 B).
346
+ - **Sources**: `opentelemetry-api opentelemetry.rb:27` (`attr_writer
347
+ :propagation`), `:69-71` (the reader memoizes the default
348
+ `NoopTextMapPropagator`).
349
+ - **Decision & why**: default-detection, the same shape as the provider
350
+ classifier: foam installs its W3C tracecontext+baggage set ONLY when the
351
+ slot holds the API's built-in `NoopTextMapPropagator` default; anything else
352
+ is a foreign owner and is KEPT, with one honest `[foam]` warning. (A
353
+ customer who deliberately installed their own Noop instance is
354
+ indistinguishable from the default — foam then installs W3C; the warning
355
+ path names whatever class it kept.)
356
+ - **Mitigation**: `lib/foam/otel/init.rb` (`configure_propagation` owner check).
357
+ - **Test**: `spec/propagation_spec.rb` ("does not clobber a FOREIGN propagator
358
+ installed before init", "installs foam's W3C set when the slot still holds
359
+ the API DEFAULT").
360
+
160
361
  ## F3: Last-write-wins globals — foam cannot defend a slot claimed AFTER init
161
362
 
162
363
  - **Trap**: Ruby's OTel global setters are last-write-wins — a foreign SDK that
@@ -180,6 +381,91 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
180
381
  - **Test**: `spec/coexistence_spec.rb` — "warns ONCE when a foreign SDK displaces
181
382
  a slot after init".
182
383
 
384
+ ## F6: `ignored_outbound_hosts` cannot reach Faraday / HTTP (httprb) / HTTPX (G13)
385
+
386
+ - **Trap**: Only the Net::HTTP and Excon contrib instrumentations support host
387
+ suppression. Faraday, HTTP (httprb) and HTTPX declare NO `untraced_hosts`
388
+ option, and `Base#config_options` silently DROPS unknown config keys — so
389
+ pre-installing them with foam's host list would install them UNGUARDED with
390
+ the option discarded: a tenant exporter or agent egress using those clients
391
+ still produces spans (the feedback-loop class rule 24's guard exists to
392
+ prevent), silently.
393
+ - **Sources**: installed source — net_http `instrumentation.rb:30`
394
+ (`option :untraced_hosts, default: []`); Excon includes
395
+ `Concerns::UntracedHosts`; contrib source for faraday/http/httpx
396
+ instrumentation declares no such option; `instrumentation-base base.rb:309-310`
397
+ (unknown config keys dropped with only an upstream-logger warning);
398
+ `base.rb:218-219` (install is idempotent — a bad first install can never be
399
+ re-applied by `install_all`).
400
+ - **Decision & why**: never silently not-apply a documented option. The
401
+ pre-install list is LIMITED to the two clients that honor it, and when the
402
+ FDE wires `ignored_outbound_hosts` while an unguardable client gem is
403
+ bundled, init warns loudly naming the client. Foam's OWN export loop is safe
404
+ for every client regardless (G4: the OTLP exporters send inside
405
+ `Common::Utilities.untraced`). Upstream need (untraced_hosts on
406
+ faraday/http/httpx) is flagged for filing.
407
+ - **Mitigation**: `lib/foam/otel/init.rb` (`HTTP_CLIENT_INSTRUMENTATIONS` /
408
+ `UNGUARDABLE_HTTP_CLIENT_INSTRUMENTATIONS`, `warn_unguardable_clients`);
409
+ README option-table + scenario 2/3 note.
410
+ - **Test**: `spec/loop_guard_spec.rb` ("lists the Instrumentation *class* path
411
+ for exactly the clients whose upstream SUPPORTS untraced_hosts",
412
+ "unguardable clients (GOTCHAS G13)…" warning cases).
413
+
414
+ ## F7: The classifier's FREE predicate reads `OpenTelemetry::Internal::Proxy*` (G14, rule 42 fenced reach)
415
+
416
+ - **Trap**: Ruby's provider setters are last-write-wins with no read-back of
417
+ "still default" — the ONLY non-displacing way to classify a slot is to
418
+ pre-read it against the API's internal `Internal::ProxyTracerProvider` /
419
+ `ProxyMeterProvider` / `ProxyLoggerProvider` classes (the exact check the
420
+ API's own setters use). BREAKS IF upstream renames/moves those constants:
421
+ the feature-detect (`defined?`) then makes every slot UNVERIFIABLE.
422
+ - **Sources**: installed source — `opentelemetry-api opentelemetry.rb:53`
423
+ (setter's own `instance_of? Internal::ProxyTracerProvider` check);
424
+ metrics-api / logs-api twins; RESEARCH.md §2 (why attempt-then-read is
425
+ impossible in Ruby).
426
+ - **Decision & why**: keep the pre-read (it mirrors upstream's own predicate,
427
+ pinned by the version-capped `opentelemetry-api ~> 1.1` range in the
428
+ gemspec), and DEGRADE HONESTLY: a missing constant yields a distinct
429
+ UNCLASSIFIABLE verdict — foam registers nothing (fail-safe) and warns "foam
430
+ cannot classify the <signal> slot on this OpenTelemetry API version", never
431
+ fabricating a foreign-owner name. Upstream need (a public "is the global
432
+ still default" predicate) is flagged for filing.
433
+ - **Mitigation**: `lib/foam/otel/classifier.rb` (`Verdict#unclassifiable?`,
434
+ `UNCLASSIFIABLE`), `lib/foam/otel/init.rb` (`warn_unclassifiable`).
435
+ - **Test**: `spec/classifier_spec.rb` — "internal Proxy-class read degradation
436
+ (rule 42 fence)" (both examples: fail-safe verdicts + the cannot-classify
437
+ warning with no fabricated owner).
438
+
439
+ ## F8: The classify→register gap — Ruby has no atomic set-once attempt
440
+
441
+ - **Trap**: all three Ruby setters assign UNCONDITIONALLY (last-write-wins
442
+ under a mutex that guards only the assignment — no compare-and-set), so
443
+ foam's pre-read classify and its own setter cannot be one atomic attempt
444
+ (rule 18 step 4 rests the no-race intent on attempt atomicity, which Ruby
445
+ does not offer). A foreign SDK registering in the gap between foam's
446
+ pre-read of a slot and foam's setter would be silently DISPLACED — and the
447
+ late-arrival check (`warn_if_displaced`) could never fire, because foam's
448
+ write lands last and the live provider IS foam's.
449
+ - **Sources**: installed source — opentelemetry-api `opentelemetry.rb:52-60`
450
+ (tracer setter: mutex + unconditional assignment; metrics-/logs-api twins
451
+ `opentelemetry-metrics-api.rb:26-34` / `opentelemetry-logs-api.rb:26-34`);
452
+ RESEARCH.md §2 ("no Ruby setter refuses global replacement").
453
+ - **Decision & why**: MINIMIZE the window — each signal is classified
454
+ IMMEDIATELY before its own setter (`register_free_signal` →
455
+ `classify_for_registration`), never as an up-front classify-all pass, so
456
+ the residual race is a few instructions wide instead of spanning three
457
+ whole pipeline builds. The platform offers no primitive to close it
458
+ entirely; a foreign SDK racing inside that residual window on another
459
+ thread is displaced with no warning — recorded honestly here (the same
460
+ honesty F3 gives the after-init displacement). Boot-time init on the main
461
+ thread (README: init FIRST, before the app boots) makes the residual
462
+ window practically unreachable.
463
+ - **Mitigation**: `lib/foam/otel/init.rb` (`register_free_signal` /
464
+ `classify_for_registration` — the per-signal classify-then-set pairing).
465
+ - **Test**: `spec/coexistence_spec.rb` — "TOCTOU window (GOTCHAS F8): a
466
+ foreign SDK claiming LOGS while foam registers TRACES is kept and warned,
467
+ never silently displaced".
468
+
183
469
  ## General gotchas (applicable to Ruby)
184
470
 
185
471
  - **G1 — init after target import / pre-init no-op**: the API's proxy providers
@@ -189,29 +475,101 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
189
475
  - **G2 — process ends before flush**: batch processors buffer; `flush` is public
190
476
  and the README wires it at handler end / worker exit.
191
477
  *Test*: `spec/fork_spec.rb` (child flush).
478
+ - **G3 — batch queue silently drops**: a full `BatchSpanProcessor`/
479
+ `BatchLogRecordProcessor` queue drops telemetry with no signal by default
480
+ (installed source: `batch_span_processor.rb` `report_dropped_spans`, reasons
481
+ `buffer-full`/`terminating`, visible only through the SDK's internal metrics
482
+ reporter / `OTEL_LOG_LEVEL=debug` error handler). Foam keeps upstream's
483
+ buffering untouched (rule 4: no client-side shedding decisions of its own)
484
+ and makes loss VISIBLE instead: `flush()` is public for burst/exit paths and
485
+ the failure-modes/diagnostics story is README surface; drops in foam's OWN
486
+ fail-closed path (redaction hard failure) always warn loudly.
487
+ *Test*: `spec/redacting_exporter_spec.rb` (loud drop warnings);
488
+ `spec/public_api_spec.rb` ("actually drains a pending span"); conformance
489
+ FLUSH phase (10-minute batch delays — only flush/shutdown deliver).
192
490
  - **G4 — exporter self-tracing loop**: the OTLP exporter sends inside an
193
491
  `OpenTelemetry::Common::Utilities.untraced` context the official
194
492
  instrumentation respects (opentelemetry-exporter-otlp `exporter.rb:147`), so
195
493
  export never traces itself; `ignored_outbound_hosts` + the endpoint host also
196
- feed the http-client instrumentations' `untraced_hosts`
494
+ feed the http-client instrumentations' `untraced_hosts` where upstream
495
+ supports the option — Net::HTTP and Excon only, see F6/G13
197
496
  (`lib/foam/otel/init.rb` `configure_loop_guard`).
198
- *Test*: `test-apps/ruby-rails` gate (no feedback spans appear).
497
+ *Test*: `spec/loop_guard_spec.rb` (behavioral zero-spans proof);
498
+ `test-apps/ruby-rails` gate (no feedback spans appear).
199
499
  - **G5 — context across async boundaries**: `Context.current` is fiber-local; a
200
500
  raw `Thread.new` loses the span. foam relies on the official
201
501
  `-concurrent_ruby` and `-active_job` instrumentations (bundled via `-rails`) to
202
502
  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.
503
+ to wrap. *Test*: `spec/gotcha_spec.rb` ("the bundled concurrent_ruby instrumentation
504
+ carries the active span across a Promises thread pool"); conformance covers
505
+ the rack-context path.
506
+ - **G6 — customer infrastructure strips propagation headers**: gateways/LBs
507
+ strip or rewrite `traceparent`, and a format mismatch (W3C sender, B3-only
508
+ receiver) fails extraction SILENTLY — every service becomes its own root.
509
+ Foam cannot fix their infra; foam keeps extraction non-fabricating (a fresh
510
+ root, never invented ids), keeps the W3C set the default, and the README
511
+ troubleshooting entry ("a downstream rejects traceparent" /
512
+ `OTEL_PROPAGATORS=none`) carries the operator lever. When the incumbent owns
513
+ the propagator, foam YIELDS (F5) so the formats the customer's infra expects
514
+ keep flowing.
515
+ *Test*: `spec/propagation_spec.rb` (inbound respected, injection exact);
516
+ conformance MAIN phase (two-hop continuation with fixed W3C ids) and
517
+ PROPNONE phase (extraction+injection stop, telemetry flows).
204
518
  - **G7 — metric cardinality explosion**: the metric helpers forward
205
519
  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).
520
+ raw path) explodes the series count. There is deliberately NO code
521
+ mitigation (foam never rewrites or drops metric dimensions — rule 4/26);
522
+ the mitigation is the README metrics warning with the classic bad example —
523
+ *Documented* (README "Metrics" section). *Test*: `spec/metrics_spec.rb`
524
+ ("masks a secret-shaped metric attribute" — pins that non-secret attributes
525
+ pass through VERBATIM, the behavior this warning is about).
526
+ - **G8 — never-ended spans leak memory**: a span started and never ended holds
527
+ memory and context forever (and never exports — the batch processor only
528
+ sees FINISHED spans, `on_finish`). `Foam::Otel.span {}` always ends the span
529
+ by construction (rule 9), proven even when the block raises; `get_tracer`
530
+ passthrough users own their `span.finish` — README note ("the span always
531
+ ends") rather than code.
532
+ *Test*: `spec/public_api_spec.rb` ("re-raises the block's error identically
533
+ AND records it, always ending the span" — the span appears in
534
+ finished_spans, i.e. it was ended).
208
535
  - **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.
536
+ provider per signal and warns by name; foam registers only into free slots
537
+ and installs NO instrumentation into a foreign-owned trace pipeline
538
+ (`spec/activation_spec.rb` claimed-traces cases).
210
539
  *Test*: `spec/coexistence_spec.rb` (8-permutation matrix), `spec/classifier_spec.rb`.
540
+ - **G10 — instrumentor version-range drift**: a contrib gem supports library
541
+ vN; the customer upgrades to vN+1 and the instrumentation goes silent (each
542
+ contrib gem's `compatible?`/`present?` gates install) — or, worse, an
543
+ UPGRADED contrib gem changes what it emits (rule 26: contrib pins set the
544
+ semconv). Foam caps every contrib range at the proven minor in the gemspec
545
+ (`-rack >= 0.26, < 0.32`, `-rails >= 0.31, < 0.43` — reasons at the pins) so
546
+ drift is a deliberate foam release with the fixtures re-proven, never an
547
+ in-place `bundle update` surprise; the supported matrix is README surface.
548
+ *Test*: `spec/gemspec_spec.rb` ("rule 40 — capped ranges"); conformance gate
549
+ pins the emitted rack/action_pack span shape against the locked pair.
550
+ - **G11 — MetricReader temporality and Views**: multiple readers per
551
+ MeterProvider are spec-legal; each reader triggers its OWN collection
552
+ (`MetricReader#collect` pulls the shared `@metric_store`,
553
+ metrics-sdk `export/metric_reader.rb:21`), and the Ruby metrics SDK's
554
+ temporality/Views story is the least-documented corner upstream (alpha —
555
+ see R2). Foam's own reader is the stock `PeriodicMetricReader` with upstream
556
+ defaults (no Views, no temporality override — thin passthrough, rule 26);
557
+ a tenant reader supplied via `additional_metric_readers` runs its collect
558
+ loop on ITS thread (tenant code, not foam-invoked — the never-throw guard
559
+ covers foam-invoked flush/shutdown only, README says so), and foam's export
560
+ is byte-identical with and without it.
561
+ *Test*: `spec/tenant_seam_spec.rb` ("foam's METRICS export is IDENTICAL with
562
+ and without a tenant metric reader", "a tenant metric reader raising in
563
+ force_flush/shutdown never breaks foam-invoked flush or shutdown").
211
564
  - **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
565
+ silent no-op or doubled telemetry. Ruby's Bundler makes duplicate same-name
566
+ gems unresolvable (one activated copy per process — a second copy raises
567
+ `Gem::LoadError` loudly), so the trap is a REBUNDLED/vendored copy; the README
213
568
  troubleshooting entry says to check `bundle list | grep opentelemetry-api` for
214
- one copy first. *Documented* (README).
569
+ one copy first. *Test*: `spec/gotcha_spec.rb` ("exactly ONE opentelemetry-api gem is
570
+ active", "the API entry file was loaded from exactly ONE gem copy"),
571
+ `spec/packaging_spec.rb` ("activating a conflicting opentelemetry-api
572
+ version raises Gem::LoadError at boot").
215
573
 
216
574
  ---
217
575