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 +4 -4
- data/GOTCHAS.md +398 -40
- data/README.md +267 -15
- data/RESEARCH.md +43 -3
- data/THIRD-PARTY-NOTICES +3 -0
- data/lib/foam/otel/api.rb +117 -16
- data/lib/foam/otel/classifier.rb +44 -6
- data/lib/foam/otel/config.rb +19 -0
- 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 +298 -33
- data/lib/foam/otel/instance.rb +4 -1
- data/lib/foam/otel/metrics.rb +29 -7
- data/lib/foam/otel/pipelines.rb +56 -3
- data/lib/foam/otel/redacting_exporter.rb +83 -12
- data/lib/foam/otel/redaction.rb +91 -8
- data/lib/foam/otel/version.rb +1 -1
- metadata +87 -17
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b4e3f8240b1b181676669710856d6f7a2cd6c95ebf34c7598b1197a2b54c3de7
|
|
4
|
+
data.tar.gz: c81e51f8331c9d60ed0f59e1638a220a1943429c41496af77b27877d35deeaad
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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!`
|
|
41
|
-
`lib/foam/otel/api.rb`)
|
|
42
|
-
`
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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**:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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)**:
|
|
126
|
-
`
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
`
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
-
|
|
156
|
-
|
|
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*: `
|
|
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. *
|
|
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.
|
|
207
|
-
|
|
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.
|
|
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. *
|
|
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
|
|