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
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!`
|
|
41
|
-
`lib/foam/otel/api.rb`)
|
|
42
|
-
`
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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**:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
233
|
+
## F1: Span attributes freeze at finish — the redaction key pass cannot be a processor
|
|
111
234
|
|
|
112
|
-
- **Trap**: The obvious place to
|
|
113
|
-
SDK freezes a span's attributes at `finish` BEFORE any `on_finish`
|
|
114
|
-
runs — so a processor cannot mutate them, and neither can a tenant
|
|
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
|
-
- **
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
`
|
|
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
|
|
149
|
-
|
|
150
|
-
`
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
-
|
|
156
|
-
|
|
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*: `
|
|
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. *
|
|
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.
|
|
207
|
-
|
|
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.
|
|
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. *
|
|
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
|
|