foam-otel 1.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b4e3f8240b1b181676669710856d6f7a2cd6c95ebf34c7598b1197a2b54c3de7
4
- data.tar.gz: c81e51f8331c9d60ed0f59e1638a220a1943429c41496af77b27877d35deeaad
3
+ metadata.gz: bb41e8c47c944ac8f252f47bededb07f8c73e152411f3a7069a8fcab3d1185a2
4
+ data.tar.gz: ab93229a7820e4b60439d8c30f903fd3d1be93a8d2dd723b032862813ac4278f
5
5
  SHA512:
6
- metadata.gz: 2f2ea8f51b7ccc19bc042a360e4892d5d7a75bf7c19ab06f27ef104782e063333dd2f22eef34a3ee0b0034256005db3398f5ffeb5a943f8bec4354c44bf33fba
7
- data.tar.gz: 830a7a9c6ac02f4168a6575c14f9d373b9781c50d5e6feda19a23d3da114bd4a440259ff53b573653e6b3321a081ed2cc181f3899959738d80da336378730f9a
6
+ metadata.gz: 291f81494a360ee3ce94a09b6b98dafde0574e4078d61ec01bb3e3e3b7803a4f7034bc36ef17a33db79cbb886ad5c0d337c2ffa75762c5a9d04f52e89cdf6669
7
+ data.tar.gz: e81c1c075606402562ee74a8dc6752dd4d96f4b73235303a43cd0d20569ca7238a031b3c6c6d6726f5cf3f3c32858c2452cc5cbfac8ffd61f834f549738c6c1b
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
 
@@ -119,7 +122,7 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
119
122
  real Rails app boots with foam and the official rack span carries the inbound
120
123
  telemetry (`/ok`, `/fail`, `/reject` assertions).
121
124
 
122
- ## R5: Door-2 ingest taps ride the alpha logs/metrics SDKs — every behavioral reliance pinned (rule 22a / fleet audit R8)
125
+ ## R5: Door-2 ingest taps ride the alpha logs/metrics SDKs — SHIPPED, every behavioral reliance pinned (fleet audit R8)
123
126
 
124
127
  - **Trap**: the log and metric ingest entries attach to upstream SDKs that are
125
128
  pre-1.0 ("development"/alpha). Four load-bearing behaviors could drift under
@@ -136,11 +139,11 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
136
139
  (`metric_store` seam), `aggregation/sum.rb:44-50` (`ndp.dup` shallow
137
140
  copies), `logs/log_record.rb:16-26` (`attr_accessor :resource`);
138
141
  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.
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.
144
147
  - **Mitigation**: capped ranges (`foam-otel.gemspec`); masking rebuilds point
145
148
  COPIES with fresh attribute hashes (`Redaction.mask_metric_data`,
146
149
  `lib/foam/otel/redaction.rb`); subclassing isolated to
@@ -227,44 +230,44 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
227
230
 
228
231
  ---
229
232
 
230
- ## F1: Span attributes freeze at finish — the redaction floor cannot be a processor
233
+ ## F1: Span attributes freeze at finish — the redaction key pass cannot be a processor
231
234
 
232
- - **Trap**: The obvious place to mask attributes is a SpanProcessor, but the Ruby
233
- SDK freezes a span's attributes at `finish` BEFORE any `on_finish` processor
234
- runs — so a processor cannot mutate them, and neither can a tenant processor
235
- 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.
236
239
  - **Sources**: installed source — `Span#finish` does
237
240
  `@attributes = validated_attributes(@attributes).freeze` then sets `@ended`
238
241
  (opentelemetry-sdk `trace/span.rb:277,280`); `to_span_data` returns the frozen
239
242
  `@attributes` by reference (`trace/span.rb:296-307`).
240
- - **Decision & why**: foam runs the always-on floor (rule 14) at the EXPORTER
241
- boundarya `RedactingSpanExporter`/`RedactingLogRecordExporter` that rebuilds
242
- the mutable `SpanData`/`LogRecordData` Structs with masked attributes/events/
243
- body before serialization. This guarantees no raw secret leaves the process in
244
- foam's own export.
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
253
- honestly, not papered over; surfaced to pcga11 as spec-gap material (Ruby
254
- offers no safe pre-freeze hook — the `on_finishing` hook runs inside the span
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` +
243
+ - **Opt-in redaction (holistic redesign, 2026-07-26)**: foam does NO redaction by
244
+ defaultwith 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` +
260
264
  `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
265
+ - **Test**: `spec/tenant_seam_spec.rb` (tenant processors receive helper-set
266
+ LISTED keys ALREADY-MASKED, free-text bodies RAW, the byte-identical
264
267
  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).
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).
268
271
 
269
272
  ## F2: OTEL_SDK_DISABLED and OTEL_PROPAGATORS are only honored by SDK.configure
270
273
 
@@ -466,6 +469,105 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
466
469
  foreign SDK claiming LOGS while foam registers TRACES is kept and warned,
467
470
  never silently displaced".
468
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
+
469
571
  ## General gotchas (applicable to Ruby)
470
572
 
471
573
  - **G1 — init after target import / pre-init no-op**: the API's proxy providers
@@ -521,8 +623,8 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
521
623
  mitigation (foam never rewrites or drops metric dimensions — rule 4/26);
522
624
  the mitigation is the README metrics warning with the classic bad example —
523
625
  *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).
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).
526
628
  - **G8 — never-ended spans leak memory**: a span started and never ended holds
527
629
  memory and context forever (and never exports — the batch processor only
528
630
  sees FINISHED spans, `on_finish`). `Foam::Otel.span {}` always ends the span
@@ -570,6 +672,15 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
570
672
  active", "the API entry file was loaded from exactly ONE gem copy"),
571
673
  `spec/packaging_spec.rb` ("activating a conflicting opentelemetry-api
572
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).
573
684
 
574
685
  ---
575
686
 
data/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  Foam's OpenTelemetry core for Ruby services. A thin, safe wrapper over the
4
4
  official OpenTelemetry libraries: foam owns the pipeline (providers, batch
5
- processors, OTLP export to the foam fleet endpoint, an always-on redaction
6
- floor), turns on automatic tier-1/2 instrumentation, and hands you a small set
7
- of never-throw helpers and nothing else.
5
+ processors, OTLP export to the foam fleet endpoint), turns on automatic
6
+ tier-1/2 instrumentation, and hands you a small set of never-throw helpers —
7
+ and nothing else. **Redaction is fully opt-in: by default foam captures every
8
+ value RAW** (no secret floor, no value-pattern masking); you enumerate the
9
+ fields to mask via `redact_keys`/`redact_pii_keys`.
8
10
 
9
11
  Built to `docs/BASE_PACKAGE_SPEC.md`. This README is the manual you integrate
10
12
  from.
@@ -19,12 +21,16 @@ Foam builds on the OpenTelemetry authors' work (https://opentelemetry.io) — se
19
21
  ```ruby
20
22
  # Gemfile
21
23
  gem "foam-otel"
22
- # Bundle the official instrumentation for whatever your app uses; foam
23
- # auto-activates each when present (presence-checked). Rails + Rack come with
24
- # foam-otel; add others as needed:
25
- gem "opentelemetry-instrumentation-pg" # e.g. Postgres
26
- gem "opentelemetry-instrumentation-redis" # e.g. Redis
27
- gem "opentelemetry-instrumentation-sidekiq" # e.g. Sidekiq
24
+ # foam-otel bundles the UNIVERSAL FLOOR and auto-activates each piece when its
25
+ # target library is present (presence-checked, and gated on foam owning the
26
+ # slot it produces into): Rack + the Rails family, the HTTP clients (Net::HTTP,
27
+ # Faraday), the primary datastores (pg, mysql2, redis, mongo) with RAW
28
+ # db.statement capture, Sidekiq, the stdlib Logger bridge, hand-written Ruby
29
+ # runtime + GC metrics, session stitching (browser session.id via baggage),
30
+ # and the LLM shims (OpenAI, Anthropic, Gemini, ruby_llm — activity + raw
31
+ # content). Add only the niche long tail your app needs beyond the floor:
32
+ gem "opentelemetry-instrumentation-graphql" # e.g. GraphQL
33
+ gem "opentelemetry-instrumentation-resque" # e.g. Resque
28
34
  ```
29
35
 
30
36
  **Supported versions** (tested in CI, `spec/version_spec.rb`): Ruby **>= 3.1**;
@@ -54,10 +60,53 @@ Foam::Otel.init(
54
60
  ```
55
61
 
56
62
  **Outcome**: foam registers the tracer/meter/logger providers, exports OTLP to
57
- the foam fleet endpoint, and auto-activates the official rack/rails
58
- instrumentation (and any other bundled `opentelemetry-instrumentation-*`). Every
59
- inbound request, DB call, HTTP call, and job the official instrumentations cover
60
- now flows to foam, with the redaction floor applied on the wire.
63
+ the foam fleet endpoint, and auto-activates the **universal floor** — the
64
+ official rack/rails instrumentation plus the bundled HTTP (Net::HTTP/Faraday),
65
+ datastore (pg/mysql2/redis/mongo), and Sidekiq instrumentations each
66
+ presence-gated, and gated on foam owning the traces slot (a foreign-owned
67
+ tracer stands the activation down). The datastore set is activated with
68
+ **`db_statement: :include`** — foam explicitly overrides the upstream
69
+ `:obfuscate` default so RAW SQL / redis args / mongo commands land on the wire.
70
+ On top of the contrib set the floor adds foam's own pieces: the **stdlib
71
+ Logger bridge** (every host `Logger` line also lands as a trace-correlated
72
+ OTel log record, body raw), hand-written Ruby **runtime + GC metrics**
73
+ (GC counts + timing, heap slots, allocation, thread count, RSS — Ruby has no
74
+ drop-in upstream metrics gem, so foam ships its own collector),
75
+ **session stitching** (inbound `baggage: session.id` from the foam browser
76
+ package is stamped onto every span and log record), and the **LLM shims**
77
+ (below). Every inbound request, DB call, HTTP call, job, log line, and model
78
+ call now flows to foam. **Redaction is opt-in: by default every value —
79
+ attributes, URLs/query strings, DB statement text, log bodies, LLM content —
80
+ is captured RAW.** Pass `redact_keys`/`redact_pii_keys` to mask or erase
81
+ specific fields (see below).
82
+
83
+ ### The LLM surface — OpenAI, Anthropic, Gemini (and ruby_llm)
84
+
85
+ The Ruby contrib registry ships no LLM instrumentation, so foam does: thin,
86
+ presence-checked shims over the SDKs' public call sites, emitting the standard
87
+ `gen_ai.*` semantic conventions — activity (model, response id, finish
88
+ reasons, token usage, latency) AND prompt/response **content, RAW**
89
+ (`gen_ai.input.messages` / `gen_ai.output.messages` /
90
+ `gen_ai.system_instructions`). Covered: the official `openai` SDK
91
+ (chat.completions.create + responses.create), the official `anthropic` SDK
92
+ (messages.create), Gemini via the `gemini-ai` gem (Google ships no official
93
+ Ruby SDK), and `ruby_llm` (one seam covering all its providers). Absent SDK =
94
+ inert; version-guarded; fail-to-dark — a shim failure can NEVER break the
95
+ model call, and the SDK's own error re-raises identically. One model call =
96
+ one `gen_ai` client span (the SDK's HTTP send rides the same suppression
97
+ context foam's exporters use). Streaming call sites (`#stream`/`#stream_raw`
98
+ on the official SDKs) are a recorded follow-up.
99
+
100
+ ### Session stitching — browser `session.id` on backend telemetry
101
+
102
+ The foam browser package stamps `session.id` on every browser signal and
103
+ sends it to your API as W3C Baggage beside `traceparent`. This gem's floor
104
+ copies `session.id` (and `session.previous_id` across a rotation) from the
105
+ inbound baggage onto every span and log record foam emits — browser session →
106
+ backend traces/logs, stitched with no ingest-side join. No-op when the
107
+ baggage is absent. **CORS note (the FDE wires this on every frontend-called
108
+ API):** the allowlist must include BOTH headers —
109
+ `Access-Control-Allow-Headers: traceparent, baggage`.
61
110
 
62
111
  ---
63
112
 
@@ -70,8 +119,8 @@ now flows to foam, with the redaction floor applied on the wire.
70
119
  | `enabled:` | Boolean | yes | — | The config switch. `false` = fully inert (no SDK, no providers, no network, helpers no-op). `true` = export, in EVERY environment. No default — you write the logic. |
71
120
  | `token:` | String | when enabled | — | `Authorization: Bearer` for export. Required (and validated) only when `enabled: true`; wired explicitly from your secret source — never an env fallback read by the gem. **Fleet convention: read it from the `FOAM_OTEL_TOKEN` env var.** Blank while enabled → raises at boot. |
72
121
  | `version:` | String | no | nil | `service.version`, verbatim (git SHA recommended). Missing → warns and continues. Never detected at runtime. |
73
- | `redact_keys:` | Array<String> | no | `[]` | Extra field names treated as SECRETS (tail-masked). EXTENDS the always-on floor. |
74
- | `redact_pii_keys:` | Array<String> | no | `[]` | Field names treated as PII (full `[REDACTED]`, no tail). Foam ships no PII preset this is your enumeration. |
122
+ | `redact_keys:` | Array<String> | no | `[]` | The ONLY field names tail-masked (shape-preserving, e.g. `********f456`). Opt-in — empty (the default) means NO masking. Matches by case-insensitive substring, including keys nested in structured values and query-string keys. |
123
+ | `redact_pii_keys:` | Array<String> | no | `[]` | Field names fully erased to `[REDACTED]` (no tail). Opt-in — empty (the default) means NO erasure. Foam ships no preset; this is your enumeration. |
75
124
  | `additional_span_processors:` | Array | no | `[]` | Tenant seam: constructed SpanProcessor instances added to foam's pipeline (additive; never replace foam's export). See coexistence. |
76
125
  | `additional_log_record_processors:` | Array | no | `[]` | Tenant seam, logs. |
77
126
  | `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
@@ -82,10 +131,10 @@ now flows to foam, with the redaction floor applied on the wire.
82
131
  Examples for the remaining options (each is runnable as-is):
83
132
 
84
133
  ```ruby
85
- # Customer redaction lists EXTEND the always-on floor (rule 14):
134
+ # Redaction is OPT-IN (default: nothing is masked). List the fields to redact:
86
135
  Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
87
- redact_keys: %w[internal_ref voucher_code], # tail-masked like secrets
88
- redact_pii_keys: %w[customer_email full_name]) # fully [REDACTED]
136
+ redact_keys: %w[password api_key internal_ref voucher_code], # tail-masked
137
+ redact_pii_keys: %w[customer_email full_name]) # fully [REDACTED]
89
138
  ```
90
139
 
91
140
  ```ruby
@@ -221,12 +270,13 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: FO
221
270
  ignored_outbound_hosts: ["ingest.eval-tool.example"])
222
271
  ```
223
272
 
224
- > Ruby note: foam's HELPERS mask at capture, so a tenant processor receives
225
- > foam-helper data (span/log helper attributes, log bodies, metric labels)
226
- > ALREADY masked. Because the Ruby SDK freezes span attributes at finish, a
227
- > tenant processor sees third-party instrumentation attributes UNMASKED (for
228
- > those, foam's floor runs at the exporter boundary — GOTCHAS F1). Foam's own
229
- > export is always masked.
273
+ > Ruby note: when you configure `redact_keys`/`redact_pii_keys`, foam's HELPERS
274
+ > apply the key pass at capture, so a tenant processor receives foam-helper data
275
+ > (span/log helper attributes, log bodies, metric labels) ALREADY masked.
276
+ > Because the Ruby SDK freezes span attributes at finish, a tenant
277
+ > processor sees third-party instrumentation attributes UNMASKED (for those,
278
+ > foam's key pass runs at the exporter boundary — GOTCHAS F1). With no keys
279
+ > configured (the default) nothing is masked on either path.
230
280
  >
231
281
  > Loop-guard note: `ignored_outbound_hosts` suppresses spans for tenant/agent
232
282
  > egress made through **Net::HTTP or Excon**. A tenant exporter using
@@ -265,7 +315,8 @@ end
265
315
  **Outcome**: their pipeline keeps working exactly as before — their data,
266
316
  their resource, their export, byte-identical — and foam ALSO receives the
267
317
  signal, carrying THEIR resource identity plus foam's export-time stamp and
268
- the full redaction floor (details in the Door 2 section).
318
+ any per-tap `redact_keys`/`redact_pii_keys` you set (opt-in none by default;
319
+ details in the Door 2 section).
269
320
 
270
321
  **Unhappy paths**:
271
322
 
@@ -351,16 +402,23 @@ how that pipeline's data ALSO reaches foam: constructed instances you add —
351
402
  one line per claimed signal — to the CUSTOMER'S OWN OTel setup. Additive
352
403
  readers only: they never mutate their data, their resource, or their export.
353
404
 
354
- > **Experimental posture (spec rule 22a)**: the log and metric entries ride
355
- > upstream's pre-1.0 SDKs (logs "development", metrics alpha) — exactly the
356
- > capped-range posture this gem already holds (GOTCHAS R2/R5). Availability
357
- > tracks upstream stability; the traces entry rides the stable 1.x SDK.
405
+ > **All three entries are SHIPPED.** The traces entry rides the stable 1.x SDK;
406
+ > the log and metric entries ride upstream's pre-1.0 SDKs (logs "development",
407
+ > metrics alpha), pinned to this gem's capped ranges (GOTCHAS R2/R5) so a 0.x
408
+ > drift is a deliberate re-prove, never a silent change. The pre-1.0 SDK
409
+ > maturity is a version-pin caveat, not a deferral — the entries are available
410
+ > and supported today.
411
+ >
412
+ > **Version-pin posture:** because the log and metric taps ride those pre-1.0
413
+ > upstream SDKs, they are version-pinned and re-proven on every 0.x bump
414
+ > rather than assumed stable — shipped and supported either way.
358
415
 
359
416
  ### The three entries
360
417
 
361
418
  All three share one signature (`token:` and `environment:` required; the
362
- redaction kwargs EXTEND the always-on floor for that tap only; `diagnostics:`
363
- is tap-scoped narration). Construction validates loudly at boot and NEVER
419
+ redaction kwargs are the ONLY keys that tap redacts — opt-in, none by default,
420
+ scoped to that tap; `diagnostics:` is tap-scoped narration). Construction
421
+ validates loudly at boot and NEVER
364
422
  throws afterwards — a failure inside a tap can never break their pipeline.
365
423
  Fleet canon mapping (pinned by `spec/export_surface_spec.rb`):
366
424
  `create_ingest_span_processor` = `createFoamIngestSpanProcessor`,
@@ -380,9 +438,9 @@ their_tracer_provider.add_span_processor(processor)
380
438
  # raises ArgumentError at construction for a blank token/environment
381
439
  ```
382
440
 
383
- **Logs** — a LogRecordProcessor for THEIR LoggerProvider. The floor's
384
- key-match runs over record attributes AND the value-pattern pass runs over
385
- the free-text BODY (bodies are where raw tokens actually live):
441
+ **Logs** — a LogRecordProcessor for THEIR LoggerProvider. The tap's
442
+ `redact_keys`/`redact_pii_keys` (if any) match over record attributes and keys
443
+ nested in structured bodies; free-text bodies ride RAW (no value-pattern pass):
386
444
 
387
445
  ```ruby
388
446
  provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: their_resource)
@@ -426,7 +484,7 @@ in THEIR pipeline. Three layers keep it closed:
426
484
  | | their pipeline | foam's copy |
427
485
  | --- | --- | --- |
428
486
  | `service.name` / `service.version` / `service.instance.id` | theirs | theirs, untouched (the data carries THEIR identity) |
429
- | all attributes / events / bodies | raw, byte-identical | redaction floor + your per-tap lists applied |
487
+ | all attributes / events / bodies | raw, byte-identical | your per-tap `redact_keys`/`redact_pii_keys` applied (default: none → identical to theirs) |
430
488
  | `deployment.environment.name` | theirs (if any) | the tap's `environment:` argument (stamped at export time) |
431
489
  | `foam.ingest.tier` | absent | `"external"` (the wire-contract marker) |
432
490
  | `telemetry.distro.name` / `telemetry.distro.version` | absent | `"foam"` / the gem version (support reads the tap's presence and version from telemetry alone) |