foam-otel 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/RESEARCH.md CHANGED
@@ -58,8 +58,13 @@ record_histogram/add_up_down_counter/set_metric`, `log`, `redact`,
58
58
 
59
59
  **Context / Baggage / Propagation** — excluded from the helper surface (low-level
60
60
  plumbing): `Context.*` (`context.rb`), `Baggage.*` (`baggage.rb`), and
61
- `OpenTelemetry.propagation` which foam SETS at init (W3C TraceContext + Baggage,
62
- `lib/foam/otel/init.rb`). Reachable through the passthroughs where needed.
61
+ `OpenTelemetry.propagation`, which foam sets at init (W3C TraceContext + Baggage,
62
+ `lib/foam/otel/init.rb`) ONLY when the slot still holds the API's built-in
63
+ default (`NoopTextMapPropagator`, memoized by the reader —
64
+ `opentelemetry-api opentelemetry.rb:69-71`); the slot is a last-write-wins
65
+ global (`attr_writer :propagation`, `opentelemetry.rb:27`), so a propagator a
66
+ foreign SDK installed first is KEPT with one warning (rule 18 B: "a foreign
67
+ owner keeps them" — GOTCHAS F5). Reachable through the passthroughs where needed.
63
68
 
64
69
  **Lifecycle** (`init/flush/shutdown/after_fork!`) and `redact` are foam-composed
65
70
  (no single API primitive): providers' `shutdown`/`force_flush`, the OTLP
@@ -105,29 +110,58 @@ consumer) or tier-2 (patch/prepend). foam activates by presence: `init` greps
105
110
  `Gem.loaded_specs` for `opentelemetry-instrumentation-*`, requires each, and
106
111
  calls `registry.install_all` (each gem presence-checks its own target). foam
107
112
  maintains **no per-gem allow/deny list** — activation is bundle-driven
108
- (`lib/foam/otel/init.rb`).
109
-
110
- **Always-activated (foam bundles them):** `-rack` (direct) + the `-rails 0.42.0`
111
- meta-gem, which pulls `-action_pack`, `-action_view`, `-action_mailer`,
112
- `-active_record`, `-active_job`, `-active_storage`, `-active_support`,
113
- `-concurrent_ruby`. Non-Rails apps get inert presence-check skips.
114
-
115
- **Activated-when-present (FDE bundles, foam auto-installs):** HTTP clients
116
- (`-net_http`, `-faraday`, `-http`, `-http_client`, `-httpx`, `-excon`, `-ethon`,
117
- `-restclient`), datastores (`-pg`, `-mysql2`, `-trilogy`, `-redis`, `-dalli`,
118
- `-mongo`, `-lmdb`), messaging (`-bunny`, `-rdkafka`, `-racecar`,
119
- `-ruby_kafka`), jobs (`-sidekiq`, `-resque`, `-delayed_job`, `-que`), web/rpc
120
- (`-sinatra`, `-grape`, `-graphql`, `-grpc`, `-gruf`), and `-aws_sdk`,
121
- `-aws_lambda`, `-koala`, `-active_model_serializers`, `-rake`, `-anthropic`
122
- (context-propagation only — see §5), plus the stdlib `-logger` bridge (§4).
123
-
124
- `install_all` runs each instrumentation with its DEFAULT config — foam sets no
125
- per-gem options. For the PHI-sensitive datastores (`-pg` full SQL text,
126
- `-redis` keys/values), the integration MUST set `db_statement: :omit` (or a
127
- verified `:obfuscate`) via the standard `OTEL_RUBY_INSTRUMENTATION_<NAME>_CONFIG_OPTS`
128
- env var so raw statements/keys never ship. This is integration configuration,
129
- not a foam default (there is no `db_statement` anywhere in `lib/`); graduating
130
- it into the package would need real-backend e2e per the NOT-TESTED bar.
113
+ (`lib/foam/otel/init.rb`). Activation is GATED on the traces verdict (rule
114
+ 18 B): when a foreign SDK owns the tracer slot, `init` installs NOTHING
115
+ automatically (every contrib gem binds its tracer to the live global — the
116
+ foreign provider) and warns once; explicit `additional_instrumentations` still
117
+ install (`spec/activation_spec.rb` claimed-traces cases).
118
+
119
+ **Always-activated (foam bundles them — the UNIVERSAL FLOOR, landed):**
120
+ `-rack` (direct) + the `-rails 0.42.0` meta-gem (pulls `-action_pack`,
121
+ `-action_view`, `-action_mailer`, `-active_record`, `-active_job`,
122
+ `-active_storage`, `-active_support`, `-concurrent_ruby`); the HTTP clients
123
+ `-net_http 0.29.0` and `-faraday 0.33.0`; the primary datastores `-pg 0.36.0`,
124
+ `-mysql2 0.34.0`, `-redis 0.29.0`, `-mongo 0.26.0`; and `-sidekiq 0.29.0` —
125
+ verified against the gemspec, the ground truth. Every entry is
126
+ presence-checked, so a gem whose target library is absent is an inert skip
127
+ (a non-Rails app skips the Rails family; an app without Postgres skips `-pg`;
128
+ and so on). The landed floor also includes foam's own non-contrib pieces: the
129
+ stdlib **Logger bridge** (§4), hand-written **runtime + GC metrics** (below),
130
+ **session stitching** (baggage→attributes), and the **LLM shims** (§5).
131
+
132
+ **Activated-when-present (FDE bundles the long tail, foam auto-installs):** the
133
+ remaining HTTP clients (`-http`, `-http_client`, `-httpx`, `-excon`, `-ethon`,
134
+ `-restclient`), datastores (`-trilogy`, `-dalli`, `-lmdb`),
135
+ messaging (`-bunny`, `-rdkafka`, `-racecar`, `-ruby_kafka`), jobs (`-resque`,
136
+ `-delayed_job`, `-que`), web/rpc (`-sinatra`, `-grape`, `-graphql`, `-grpc`,
137
+ `-gruf`), and `-aws_sdk`, `-aws_lambda`, `-koala`,
138
+ `-active_model_serializers`, `-rake`, `-anthropic` (context-propagation only —
139
+ see §5).
140
+
141
+ `install_all` receives foam's CENTRAL raw-capture config
142
+ (`FLOOR_INSTRUMENTATION_CONFIG`, `lib/foam/otel/init.rb`): the datastore
143
+ statement posture is **captured RAW, never suppressed** (2026-07-26 ruling —
144
+ coverage is the mission), and the upstream `-pg`/`-mysql2`/`-redis`/`-mongo`
145
+ gems all DEFAULT to `db_statement: :obfuscate` — a sanitizer — so foam
146
+ explicitly activates them with `db_statement: :include` (GOTCHAS F9; pinned
147
+ by `spec/floor_spec.rb`). Redaction stays fully opt-in, applied only for keys
148
+ the customer lists in `redact_keys`/`redact_pii_keys`. An FDE can still clamp
149
+ `db_statement: :omit`/`:obfuscate` per customer via the standard
150
+ `OTEL_RUBY_INSTRUMENTATION_<NAME>_CONFIG_OPTS` env var when a compliance
151
+ context calls for it — per-customer tightening (rule 18 / Area 4), not the
152
+ default.
153
+
154
+ **Runtime + GC metrics (hand-written — landed,
155
+ `lib/foam/otel/runtime_metrics.rb`).** Ruby has no drop-in upstream metrics
156
+ instrumentation gem (unlike Python's `-system-metrics` or the Node
157
+ host-metrics package), so foam ships its OWN collector: asynchronous
158
+ observable instruments over `GC.stat` / `Thread.list` / `/proc`, emitting GC
159
+ counts + timing, heap slots, allocation/free totals, thread count, and RSS
160
+ (`process.runtime.ruby.*` + `process.memory.usage`). Registered by `init`
161
+ when foam owns the metrics slot; collection runs on the
162
+ `PeriodicMetricReader`'s thread, which foam's `Process._fork` hook restarts
163
+ in cluster workers (GOTCHAS R1/R7/G15). No labels at all — safe by
164
+ construction (rule 26). Tests: `spec/runtime_metrics_spec.rb`.
131
165
 
132
166
  **Excluded (with reason):** `-factory_bot`, `-rspec` (test-only, no production
133
167
  value); `-net_ldap` (niche, PII/credential-adjacent — explicit FDE opt-in);
@@ -148,36 +182,61 @@ Registry: https://github.com/open-telemetry/opentelemetry-ruby-contrib/tree/main
148
182
 
149
183
  Ruby's Logs signal is "development" (https://opentelemetry.io/docs/languages/ruby/).
150
184
  foam ships the logs pipeline + `log()` helper (trace-correlated) — the mechanism
151
- rule 20 requires. **Named logger bridges are correctly deferred.** The only
152
- official contrib bridge is `opentelemetry-instrumentation-logger 0.4.0` (stdlib
153
- `Logger`, not bundled today). `Rails.logger`/`TaggedLogging`/`BroadcastLogger`,
154
- Semantic Logger, Lograge, and Ougai have **no** official OTel appender (the Rails
155
- logs discussion https://github.com/open-telemetry/opentelemetry-ruby/discussions/1789
156
- is open/unresolved). Resolves rule 20's TODO(pcga11): the OTel Logs Bridge API
157
- exists (`opentelemetry-logs-api`) and the stdlib `-logger` bridge covers the
158
- base, but the framework loggers are not covered — foam's job is activation of
159
- `-logger` on first demand (tested at add time), plus a documented gap.
185
+ rule 20 requires. The stdlib **Logger bridge is LANDED as foam's own code**
186
+ (`lib/foam/otel/logger_bridge.rb`, console-logs ruling 2026-07-26): one
187
+ prepend on `Logger#add` turns every message a host logger accepts into a
188
+ trace-correlated OTel log record — body RAW (opt-in key-masking only),
189
+ severity mapped, loop-guarded (the OTel SDK's internal logger is skipped by
190
+ identity + a thread-local re-entrancy flag), gated on foam owning the logs
191
+ slot, and never able to break the host's log call (GOTCHAS F10; tests
192
+ `spec/logger_bridge_spec.rb`). Because `Rails.logger`, Sidekiq's logger, and
193
+ Semantic Logger's appenders all bottom out in stdlib `Logger` instances, the
194
+ one prepend covers most framework logging in practice;
195
+ `BroadcastLogger`/Lograge/Ougai-specific appenders remain uncovered upstream
196
+ (the Rails logs discussion
197
+ https://github.com/open-telemetry/opentelemetry-ruby/discussions/1789 is
198
+ open/unresolved) — a documented gap, narrowed to the non-Logger-backed exotics.
160
199
 
161
200
  ---
162
201
 
163
202
  ## 5. LLM ecosystem (rule 31)
164
203
 
165
- **No official OpenTelemetry Ruby gen_ai attribute instrumentation exists.** The
166
- package ships NO LLM surface in v1 (rule 22); this is the classification
167
- deliverable.
204
+ **No OFFICIAL OpenTelemetry Ruby gen_ai instrumentation exists for ANY LLM
205
+ SDK — foam ships its own thin gap-filler shims (LANDED,
206
+ `lib/foam/otel/llm.rb` + `llm/*_shim.rb`; provider-parity ruling
207
+ 2026-07-26: OpenAI + Anthropic + Gemini in every server language).** The
208
+ shims patch the SDKs' public call sites — official `openai`
209
+ (chat.completions.create + responses.create), official `anthropic`
210
+ (messages.create), `gemini-ai` (generate_content / stream_generate_content;
211
+ Google ships no official Ruby SDK), and `ruby_llm` (Provider#complete, one
212
+ seam covering all its providers) — emitting the same GenAI semconv attribute
213
+ set the fleet's js/python gap-fillers emit. **LLM traces are captured in
214
+ full — activity (model, response id, finish reasons, tokens, latency) AND
215
+ prompt/completion content, raw by default** (2026-07-26 ruling: LLM content
216
+ is never gated or dropped; the FDE reactive clamp is the only per-customer
217
+ restriction). Presence-checked, version-windowed, fail-to-dark, one call =
218
+ one span (the SDK transport rides the untraced suppression context), and
219
+ the model call is never breakable by telemetry (GOTCHAS F11). LLM testing is
220
+ MANDATORY and landed: per-provider specs against the REAL SDKs with only the
221
+ 3rd party mocked (`spec/llm_openai_spec.rb`, `spec/llm_anthropic_spec.rb`,
222
+ `spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb` — the last is the
223
+ wire-level Gemini proof over a local fake provider).
168
224
 
169
225
  - `-anthropic` (in the registry / `all` meta-gem) is **context-propagation ONLY**
170
226
  — its sole patch stores/restores OTel context across the SDK's connection pool;
171
227
  it sets no `gen_ai.*` attributes, captures no tokens/content (CHANGELOG v0.1–0.5
172
- are context/semconv/version bumps).
173
- - `ruby_llm`: **COVERED-tier2** *when the app uses it*, via thoughtbot's
174
- **third-party** `opentelemetry-instrumentation-ruby_llm 0.7.0` (not official
175
- registry, not bundled — if foam ever ships it, the NOT-TESTED bar + supply-chain
176
- review apply).
177
- - `ruby-openai`, official `openai`, `langchainrb`, Gemini, AWS Bedrock (via
178
- `-aws_sdk`, which emits no gen_ai attributes), `llm.rb`: **TIER3-recipe** —
228
+ are context/semconv/version bumps). Not the LLM-activity vehicle — foam's
229
+ shim is.
230
+ - `opentelemetry-instrumentation-ruby_llm` (thoughtbot, third-party) exists
231
+ but is NOT bundled: foam's own `ruby_llm` shim emits the fleet-identical
232
+ attribute set instead, avoiding a non-registry supply-chain dependency and
233
+ a cross-language semconv divergence.
234
+ - `ruby-openai` (community gem), `langchainrb`, AWS Bedrock (via `-aws_sdk`,
235
+ which emits no gen_ai attributes), `llm.rb`: **TIER3-recipe** —
179
236
  hand-written spans via helpers + passthroughs with `gen_ai.*` semconv + token
180
- counts + trace/log correlation.
237
+ counts + trace/log correlation. An uncovered library the FDE hits becomes a
238
+ filed package need (the FDE activates, never authors); until then the tier-3
239
+ recipe covers it.
181
240
  - No mainstream library is CANNOT-COVER — no finding against the helper set.
182
241
 
183
242
  Sources: contrib `-anthropic` CHANGELOG; https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm ;
@@ -186,31 +245,40 @@ https://opentelemetry.io/docs/specs/semconv/gen-ai/aws-bedrock/ ; traceloop Ruby
186
245
 
187
246
  ---
188
247
 
189
- ## 6. Redaction floor provenance (rule 14)
190
-
191
- The always-on secrets floor (`lib/foam/otel/constants.rb`) is COPIED KNOWLEDGE,
192
- never a runtime dependency (rule 38). Matching is SUBSTRING, case-insensitive,
193
- over a normalized key; the `key`/`sid` roots are REJECTED as bare (they eat
194
- `idempotency_key`/`cache_key`/`public_key` and `consider`/`president`), and
195
- `auth`/`card` are token-anchored (miss `author`/`wildcard`/`dashboard`). Two mask
196
- shapes: secret-key matches → tail mask (`********` + last 4 when ≥ 12 chars; the
197
- 8-char body matches raven-ruby's `STRING_MASK` and PCI's last-4 convention);
198
- `redact_pii_keys` → full `[REDACTED]`, no tail. A value-pattern pass masks
199
- prefix-anchored vendor token shapes under unlisted keys. Fail-closed (mask on any
200
- error, cycle + depth guarded).
201
-
202
- Sources consulted (permissive — knowledge copied, never installed): raven-ruby
203
- (MIT), sentry-python/js (MIT), Elastic APM `sanitize_field_names` (Apache-2.0),
204
- is-secret (MIT), @zapier/secret-scrubber (ISC — read from pinned pre-2025 CDN,
205
- never installed), HyperDX (MIT), Instana (Apache-2.0), Rails filter_parameters
206
- (MIT), OTel Collector redactionprocessor (Apache-2.0), detect-secrets
207
- (Apache-2.0), secretlint (MIT), gitleaks (MIT). Facts-only (never copy regex
208
- text): trufflehog (AGPL-3.0). Model of record: the shipped `@foam-ai/otel` Node
209
- core (`js/otel/src/core/{constants,redaction}.ts`, `js/otel/RESEARCH.md`).
210
-
211
- Proven by `spec/redaction_spec.rb` (curated hits/non-hits, both masks, value
212
- pass, fail-closed) and `spec/redacting_exporter_spec.rb` + the conformance app
213
- (on the wire).
248
+ ## 6. Redaction design record (rule 14, as amended 2026-07-26)
249
+
250
+ **Redaction is FULLY OPT-IN — the default is ZERO redaction** (owner ruling
251
+ 2026-07-26, holistic-redesign decision log; `lib/foam/otel/redaction.rb`).
252
+ With neither `redact_keys` nor `redact_pii_keys` set (or both empty) every
253
+ value is captured RAW — attributes, headers, bodies, URL query params,
254
+ `db.statement`, log bodies — including secrets and PII. There is NO always-on
255
+ secrets floor and NO value-pattern auto-masking; the package auto-masks
256
+ nothing and ships no preset. The customer owns the privacy/legal posture; the
257
+ FDE reactive clamp is the per-customer valve.
258
+
259
+ **The retired floor**: earlier revisions shipped an always-on researched
260
+ secrets floor in `constants.rb` (roots + value-pattern pass, sourced from the
261
+ OSS-scrubber consensus — raven-ruby, sentry, Elastic APM, Rails
262
+ filter_parameters, gitleaks et al. — modeled on the Node core). It was
263
+ REMOVED by the 2026-07-26 ruling; the provenance record lives in this file's
264
+ git history and the decision log. Do not reintroduce it without a new ruling.
265
+
266
+ **The current engine** (`redaction.rb`): redaction runs ONLY over the keys
267
+ the customer enumerates. `redact_keys` matches → tail mask, shape-preserving
268
+ (`********` + last 4 when ≥ 12 chars; the fixed 8-char body never reveals
269
+ length; non-scalars redact in full); `redact_pii_keys` matches → full
270
+ `[REDACTED]`, no tail. Matching is SUBSTRING, case-insensitive, over the
271
+ lowercased key — the customer's lists, nothing inferred. The pass runs at the
272
+ exporter boundary (`redacting_exporter.rb` — the Ruby SDK freezes span
273
+ attributes at finish before any `on_finish` processor runs, so the exporter
274
+ boundary is the seam; see GOTCHAS) and is a true pass-through when no keys
275
+ are configured. The explicit `redact(value)` helper always masks (calling it
276
+ is the opt-in). Fail-closed for listed keys (mask on any error, cycle + depth
277
+ guarded).
278
+
279
+ Proven by `spec/redaction_spec.rb` (opt-in defaults — nothing masked with no
280
+ keys; both mask shapes; fail-closed) and `spec/redacting_exporter_spec.rb` +
281
+ the conformance app (default-path RAW capture on the wire).
214
282
 
215
283
  ---
216
284
 
@@ -226,6 +294,38 @@ upstream SDK components foam builds on.
226
294
 
227
295
  ---
228
296
 
297
+ ## 8. Door-2 attach points (ingest entries) — verified at the pinned versions
298
+
299
+ Ruby is the rare core where every door-2 attach point exists post-construction
300
+ on a foreign provider (the JS >=2.0/Java "only inside their setup" limitation
301
+ does not bite):
302
+
303
+ - **Traces**: `TracerProvider#add_span_processor`
304
+ (`V/opentelemetry-sdk-1.13.0/lib/opentelemetry/sdk/trace/tracer_provider.rb:133`);
305
+ also reachable inside their setup block via
306
+ `Configurator#add_span_processor` (`configurator.rb:125`).
307
+ - **Logs**: `LoggerProvider#add_log_record_processor`
308
+ (`V/opentelemetry-logs-sdk-0.6.1/lib/opentelemetry/sdk/logs/logger_provider.rb:62`).
309
+ - **Metrics**: `MeterProvider#add_metric_reader`
310
+ (`V/opentelemetry-metrics-sdk-0.15.0/.../meter_provider.rb:110-121`) — and
311
+ it BACK-FILLS: the registry walk registers every already-created instrument
312
+ with the new reader's `metric_store`, so attach order is forgiving.
313
+
314
+ Load-bearing internals the taps rely on (each pinned by a test — GOTCHAS
315
+ R5-R8): the `metric_store` seam forcing the `PeriodicMetricReader` subclass;
316
+ shallow-dup'd data points sharing the LIVE aggregation-key attributes hash
317
+ (`aggregation/sum.rb:44-50` — masking must rebuild copies);
318
+ `LogRecord#resource` for self-ingest detection; batch processors converting
319
+ to fresh `SpanData`/`LogRecordData` structs at export (non-mutation is
320
+ structural); all three OTLP exporters sending inside
321
+ `Common::Utilities.untraced` via `Net::HTTP` (the suppression layer of the
322
+ loop story), with the contrib `untraced_hosts` option declared by Net::HTTP
323
+ and Excon only. The logs/metrics SDKs are pre-1.0 — the entries are SHIPPED,
324
+ pinned inside the capped ranges (GOTCHAS R2/R5) so a 0.x drift is a deliberate
325
+ re-prove; the pre-1.0 maturity is a version-pin caveat, not a deferral.
326
+
327
+ ---
328
+
229
329
  ## Signatures
230
330
 
231
331
  | Reviewer | Date | Package version | Notes |
data/THIRD-PARTY-NOTICES CHANGED
@@ -10,17 +10,21 @@ redistributes).
10
10
  Foam thanks the OpenTelemetry authors and community
11
11
  (https://opentelemetry.io) — this package is built on their work.
12
12
 
13
- The always-on redaction floor (lib/foam/otel/constants.rb) is COPIED KNOWLEDGE,
14
- not vendored code (rule 38): its key roots and value-pattern shapes were
15
- researched from the published defaults of permissively-licensed open-source
16
- scrubbers (Sentry / raven-ruby MIT, Elastic APM Apache-2.0, is-secret MIT,
17
- @zapier/secret-scrubber ISC, HyperDX MIT, detect-secrets Apache-2.0, secretlint
18
- MIT, gitleaks MIT) and Rails' filter_parameters (MIT). Token FORMATS are facts,
19
- not copyrightable; no regex text was copied from any AGPL source. None of these
20
- libraries is a runtime dependency of foam-otel. Provenance: RESEARCH.md.
13
+ The OPT-IN redaction mask shapes (lib/foam/otel/constants.rb — the 8-char
14
+ MASK_BODY and tail-mask convention applied only to customer-listed
15
+ redact_keys/redact_pii_keys; foam redacts NOTHING by default) are COPIED
16
+ KNOWLEDGE, not vendored code (rule 38): the shapes were researched from the
17
+ published conventions of permissively-licensed open-source scrubbers
18
+ (raven-ruby MIT STRING_MASK precedent among them) and Rails'
19
+ filter_parameters (MIT). Mask FORMATS are facts, not copyrightable; no code
20
+ was copied. None of these libraries is a runtime dependency of foam-otel.
21
+ Provenance: RESEARCH.md.
21
22
 
22
23
  Direct runtime dependencies (name version — license — repository):
23
24
 
25
+ opentelemetry-api 1.11.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby
26
+ opentelemetry-metrics-api 0.6.1 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby
27
+ opentelemetry-logs-api 0.4.1 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby
24
28
  opentelemetry-sdk 1.13.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby
25
29
  opentelemetry-exporter-otlp 0.34.1 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby
26
30
  opentelemetry-logs-sdk 0.6.1 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby
@@ -29,10 +33,17 @@ Direct runtime dependencies (name version — license — repository):
29
33
  opentelemetry-exporter-otlp-metrics 0.10.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby
30
34
  opentelemetry-instrumentation-rack 0.31.1 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby-contrib
31
35
  opentelemetry-instrumentation-rails 0.42.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby-contrib
36
+ opentelemetry-instrumentation-net_http 0.29.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby-contrib
37
+ opentelemetry-instrumentation-faraday 0.33.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby-contrib
38
+ opentelemetry-instrumentation-pg 0.36.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby-contrib
39
+ opentelemetry-instrumentation-mysql2 0.34.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby-contrib
40
+ opentelemetry-instrumentation-redis 0.29.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby-contrib
41
+ opentelemetry-instrumentation-mongo 0.26.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby-contrib
42
+ opentelemetry-instrumentation-sidekiq 0.29.0 — Apache-2.0 — https://github.com/open-telemetry/opentelemetry-ruby-contrib
32
43
 
33
44
  The opentelemetry-instrumentation-rails meta-gem transitively pulls the Rails
34
45
  instrumentation family (action_pack, action_view, action_mailer, active_record,
35
46
  active_job, active_storage, active_support, concurrent_ruby), each Apache-2.0,
36
47
  from https://github.com/open-telemetry/opentelemetry-ruby-contrib. Additional
37
- opentelemetry-instrumentation-* gems the FDE bundles are the customer's declared
38
- dependencies, not foam's.
48
+ opentelemetry-instrumentation-* gems the FDE bundles beyond the floor above are
49
+ the customer's declared dependencies, not foam's.
data/lib/foam/otel/api.rb CHANGED
@@ -43,53 +43,104 @@ module Foam
43
43
  def get_logger(name = "foam")
44
44
  provider = OpenTelemetry.logger_provider
45
45
  warn_if_displaced(:logs, provider)
46
- provider.logger(name: name)
46
+ # Never register into the PRE-INIT proxy registry (GOTCHAS L1): the
47
+ # logs-api 0.4.1 ProxyLoggerProvider#delegate= upgrades cached proxy
48
+ # loggers with a POSITIONAL provider.logger(name, version) call, but
49
+ # the logs SDK's #logger is keyword-only — one cached proxy logger
50
+ # makes init's logger_provider assignment raise and would leave the
51
+ # logs pipeline dark for the process. Pre-init logs are a silent
52
+ # no-op by contract, so hand back the API's no-op Logger directly.
53
+ # (Same feature-detected Internal read as the classifier — rule 42,
54
+ # GOTCHAS G14; when the constant is missing this check degrades to
55
+ # the plain provider call below.)
56
+ if defined?(OpenTelemetry::Internal::ProxyLoggerProvider) &&
57
+ provider.instance_of?(OpenTelemetry::Internal::ProxyLoggerProvider)
58
+ return noop_logger
59
+ end
60
+
61
+ begin
62
+ provider.logger(name: name) # the logs SDK's keyword-only signature
63
+ rescue ArgumentError
64
+ provider.logger(name) # a positional-signature (older/foreign) provider
65
+ end
47
66
  end
48
67
 
49
68
  # ---- traces ----------------------------------------------------------
50
69
 
51
70
  # A custom span around a block. Re-raises the block's error identically,
52
71
  # records the exception + ERROR status, and ALWAYS ends the span (rule 9
53
- # / never-ended-span leak). Safe pre-init (no-op span).
72
+ # / never-ended-span leak). Safe pre-init (no-op span). Argument
73
+ # preparation NEVER throws (rule 9): garbage attributes degrade to nil,
74
+ # a poisoned name to "unnamed" — the block always runs and only ITS
75
+ # error re-raises. When foam is initialized disabled, the block runs on
76
+ # a non-recording no-op span: disabled beats everything, including
77
+ # inert-mode emission through a foreign provider (section 0).
54
78
  def span(name, attributes: nil, &block)
55
- get_tracer.in_span(name.to_s, attributes: stringify(attributes), &block)
79
+ span_name = begin
80
+ name.to_s
81
+ rescue StandardError, SystemStackError
82
+ # SystemStackError is not a StandardError: a name object whose #to_s
83
+ # recurses unboundedly must degrade to "unnamed", never escape into
84
+ # the customer's thread (rule 9).
85
+ "unnamed"
86
+ end
87
+ return noop_tracer.in_span(span_name, &block) if helpers_disabled?
88
+
89
+ get_tracer.in_span(span_name, attributes: stringify(attributes), &block)
56
90
  end
57
91
 
58
92
  # Records an exception on the active span (status ERROR) when one is
59
93
  # recording. Accepts customer error classes; NEVER notifies a vendor
60
94
  # tracker. Deduped per span. Never raises.
61
95
  def record_exception(error)
96
+ return nil if helpers_disabled?
97
+
62
98
  Errors.record_once(OpenTelemetry::Trace.current_span, error)
63
99
  nil
64
100
  end
65
101
 
66
102
  def set_attribute(key, value)
103
+ return false if helpers_disabled?
104
+
67
105
  span = OpenTelemetry::Trace.current_span
68
106
  return false unless span.respond_to?(:recording?) && span.recording?
69
107
 
70
- span.set_attribute(key.to_s, value)
108
+ # Capture-time key pass (rule 18 C.3): when the customer configured
109
+ # redact_keys/redact_pii_keys the value lands ALREADY-MASKED, so tenant
110
+ # processors and inert-mode providers only ever see the redacted view of
111
+ # helper-set data. The exporter pass re-applies (idempotent). With no
112
+ # keys configured this is a pass-through — the value is captured RAW.
113
+ # scrub_utf8 is encoding hygiene, not masking: an invalid-UTF-8 value
114
+ # would make the upstream OTLP encoder drop the WHOLE batch (rule 15).
115
+ span.set_attribute(key.to_s, Redaction.mask_one(key.to_s, Redaction.scrub_utf8(value), active_config))
71
116
  true
72
- rescue StandardError
117
+ rescue StandardError, SystemStackError
118
+ # SystemStackError is not a StandardError: a poisoned key/value (e.g. a
119
+ # recursive #to_s/#hash) must never crash the caller's thread (rule 9).
73
120
  false
74
121
  end
75
122
 
76
123
  def set_attributes(attributes)
124
+ return false if helpers_disabled?
125
+
77
126
  span = OpenTelemetry::Trace.current_span
78
127
  return false unless span.respond_to?(:recording?) && span.recording?
79
128
 
80
129
  span.add_attributes(stringify(attributes))
81
130
  true
82
- rescue StandardError
131
+ rescue StandardError, SystemStackError
83
132
  false
84
133
  end
85
134
 
86
135
  def add_event(name, attributes: nil)
136
+ return false if helpers_disabled?
137
+
87
138
  span = OpenTelemetry::Trace.current_span
88
139
  return false unless span.respond_to?(:recording?) && span.recording?
89
140
 
90
141
  span.add_event(name.to_s, attributes: stringify(attributes))
91
142
  true
92
- rescue StandardError
143
+ rescue StandardError, SystemStackError
93
144
  false
94
145
  end
95
146
 
@@ -101,7 +152,7 @@ module Foam
101
152
  return nil if ctx.nil? || !ctx.valid?
102
153
 
103
154
  { trace_id: ctx.hex_trace_id, span_id: ctx.hex_span_id }
104
- rescue StandardError
155
+ rescue StandardError, SystemStackError
105
156
  nil
106
157
  end
107
158
 
@@ -128,15 +179,17 @@ module Foam
128
179
  # is an OTel severity NUMBER (1..24) or a level symbol (:info, :warn,
129
180
  # :error, :debug, :fatal, :trace). Safe pre-init (no-op). Never raises.
130
181
  def log(severity, body, attributes: nil)
182
+ return true if helpers_disabled? # disabled means fully dark — accepted, silently dropped
183
+
131
184
  get_logger.on_emit(
132
185
  severity_number: severity_number(severity),
133
186
  severity_text: severity_text(severity),
134
- body: body,
187
+ body: mask_log_body(body),
135
188
  attributes: stringify(attributes),
136
189
  context: OpenTelemetry::Context.current
137
190
  )
138
191
  true
139
- rescue StandardError
192
+ rescue StandardError, SystemStackError
140
193
  false
141
194
  end
142
195
 
@@ -153,7 +206,10 @@ module Foam
153
206
  def flush
154
207
  (@flushables || []).dup.each do |flushable|
155
208
  flushable.force_flush
156
- rescue StandardError
209
+ rescue StandardError, SystemStackError
210
+ # SystemStackError is not a StandardError: a poisoned (e.g. cyclic)
211
+ # payload deep in a pipeline must still never escape into the
212
+ # customer's thread (rule 9).
157
213
  nil
158
214
  end
159
215
  nil
@@ -165,16 +221,19 @@ module Foam
165
221
  def shutdown
166
222
  @instance&.shutdown
167
223
  nil
168
- rescue StandardError
224
+ rescue StandardError, SystemStackError
169
225
  nil
170
226
  end
171
227
 
172
228
  # Restart foam's PeriodicMetricReader export threads after a fork (the
173
229
  # trace/log batch processors self-heal on their own — GOTCHAS R1).
174
- # Called automatically by foam's Process._fork hook; also safe to call
175
- # from Puma on_worker_boot / Unicorn after_fork.
230
+ # Walks BOTH registries: foam's own door-1 readers AND the door-2
231
+ # @ingest_taps registry (kept separate so a post-fork re-init that
232
+ # retires foam's pipelines never orphans a tap riding the CUSTOMER'S
233
+ # pipeline — GOTCHAS R7). Called automatically by foam's Process._fork
234
+ # hook; also safe to call from Puma on_worker_boot / Unicorn after_fork.
176
235
  def after_fork!
177
- (@metric_readers || []).each do |reader|
236
+ ((@metric_readers || []) + (@ingest_taps || [])).each do |reader|
178
237
  reader.after_fork if reader.respond_to?(:after_fork)
179
238
  rescue StandardError
180
239
  nil
@@ -201,10 +260,59 @@ module Foam
201
260
  LEVEL_TEXT[severity.to_s.downcase.to_sym]
202
261
  end
203
262
 
263
+ # Stringify keys, then apply the customer's key lists AT CAPTURE (rule 18
264
+ # C.3): when redact_keys/redact_pii_keys are configured, helper-set
265
+ # attributes are ALREADY-MASKED when they reach the span/log record, so a
266
+ # tenant processor (or a foreign provider in inert mode) only ever sees the
267
+ # redacted view of foam-helper data. The exporter-boundary pass re-applies
268
+ # — masking is idempotent. With no keys configured this is a pass-through
269
+ # (raw capture). NEVER throws (rule 9): non-enumerable garbage degrades to
270
+ # nil (attributes dropped, telemetry still flows).
204
271
  def stringify(attributes)
205
272
  return nil if attributes.nil?
206
273
 
207
- attributes.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
274
+ # scrub_utf8 per value: encoding hygiene at capture (see set_attribute
275
+ # — one invalid-UTF-8 value kills its whole OTLP batch upstream).
276
+ stringified = attributes.each_with_object({}) { |(k, v), out| out[k.to_s] = Redaction.scrub_utf8(v) }
277
+ Redaction.mask_attributes(stringified, active_config)
278
+ rescue StandardError, SystemStackError
279
+ nil
280
+ end
281
+
282
+ # The customer key pass over a log BODY at capture (same rationale as
283
+ # stringify). When keys are configured mask_body is fail-closed and
284
+ # depth-guarded, so a cyclic structure is bounded before it can reach any
285
+ # encoder; with no keys configured the body is captured RAW.
286
+ def mask_log_body(body)
287
+ return body if body.nil?
288
+
289
+ Redaction.mask_body(Redaction.scrub_utf8(body), active_config)
290
+ rescue StandardError, SystemStackError
291
+ # Fail CLOSED on a masking failure: in inert mode (a foreign SDK owns
292
+ # logs) the capture-time pass is the ONLY pass on foam-helper log
293
+ # bodies, so returning the raw String after a mask error would ship a
294
+ # half-processed body — REDACTED for every body type instead.
295
+ REDACTED
296
+ end
297
+
298
+ # section 0: "enabled: false beats everything — including inert-mode
299
+ # emission. Disabled means fully dark." Helpers consult the resolved
300
+ # config once foam is initialized; before init the proxy providers
301
+ # already make them silent no-ops (and a foreign pre-init provider is
302
+ # the pre-init passthrough contract, unchanged).
303
+ def helpers_disabled?
304
+ @initialized && !active_config.enabled
305
+ end
306
+
307
+ # The API's no-op tracer: in_span still yields a (non-recording) span
308
+ # and re-raises only the block's own error — nothing is emitted.
309
+ def noop_tracer
310
+ @noop_tracer ||= OpenTelemetry::Trace::Tracer.new
311
+ end
312
+
313
+ # The API's no-op logger (see get_logger / GOTCHAS L1).
314
+ def noop_logger
315
+ @noop_logger ||= OpenTelemetry::Logs::Logger.new
208
316
  end
209
317
  end
210
318
  end