foam-otel 1.5.0 → 1.7.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: 1e23dd37962a70ea36799a92d914fd2a38aa967a6cd1aface19bbaa322ab8966
4
- data.tar.gz: f0018c27fb2dfff9316118438688a4a8ab2baea18810dbda522880361dfe6681
3
+ metadata.gz: 48166d05b120dd8bdca15a9b45db5b4c16a6574fcb6890f4caff2c17c2ae9add
4
+ data.tar.gz: 2dcd4b3b146a0221c231ba5efc057085418b74e21b24d8441396d47cfc0a8288
5
5
  SHA512:
6
- metadata.gz: 9731cb1fae1b54f236dd583102b2348ab4b45bff38745f446c584151e594ea685f2689d6a7fb4807136372006b7b10c0284b2e7c140c0c9625edb5a820294c8a
7
- data.tar.gz: 91744d9fb0fb671bce62de973eec96152b147742ee5948fedf607626dfd89b8c3c6ed055522f21c4bbfd7d65ce5b558da9e67605578e3573ec15f8b0d4efeecc
6
+ metadata.gz: 4e92c7af8cc5cf7cc177b792f87a8107b918035efdce080e2a974750180ebbb9aac4a5460cb08372f3ded2724d136804bcb8f0574ee4685ebbdd94fde7afb3e8
7
+ data.tar.gz: ddd7b6e431034663ec94dacd8b7b051ee63bfecdbeb2a38d383c58531854ba0580f34c9a04ed56473b654b89155fc9a3b7ce140b073ba346d90a5a801b9c6d4b
data/GOTCHAS.md CHANGED
@@ -633,6 +633,179 @@ exfiltratable — the value-pattern secret layer is the required second control
633
633
  `spec/vuln_suite_spec.rb` (the adversarial exfil/ReDoS/bypass suite +
634
634
  mutation spot-checks).
635
635
 
636
+ ## F14: Default-on header capture — the official rack options are the seam (a NAMED list, not capture-all), operator header config is never overridden, and Faraday middleware ORDER decides which span gets enriched
637
+
638
+ - **Trap**: four ways foam's default-on header capture
639
+ (`header_capture.rb`) could go wrong. (1) The official rack gem's
640
+ `allowed_request_headers:`/`allowed_response_headers:` options are a
641
+ STATIC ALLOWLIST compiled at install time with no capture-all form — so
642
+ Ruby inbound cannot be capture-all like js/python; pretending otherwise
643
+ (or shipping an empty default) silently costs the whole signal. (2) The
644
+ gem applies operator env config
645
+ (`OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS`,
646
+ instrumentation-base `config_overrides_from_env`) per option at install,
647
+ where an env value BEATS any passed config — except the narrow-to-empty
648
+ spelling (`allowed_request_headers=`), which the gem's env parser DROPS
649
+ (`parts[1]` nil): foam blindly passing its defaults would then override
650
+ the operator's explicit opt-out. And `Instrumentation::Base#install` is
651
+ first-wins (`return true if installed?`) — foam pre-installing BEFORE the
652
+ operator's explicit instance would freeze the operator's header config
653
+ out entirely. (3) A middleware writing to
654
+ `OpenTelemetry::Trace.current_span` writes to WHATEVER span is ambient:
655
+ foam's Faraday middleware registered without `use :open_telemetry` FIRST
656
+ (the official faraday instrumentation auto-appends its tracer middleware
657
+ AFTER the customer's block, i.e. INSIDE any middleware the block added)
658
+ would stamp `http.request.header.*` onto the enclosing app span,
659
+ silently corrupting another producer's data. (4) Outbound Net::HTTP has
660
+ NO hook surface in its official instrumentation (a closed
661
+ `connect`/`request` patch, no request/response hook, no header option) —
662
+ the only route would be monkey-patching stdlib or gem internals.
663
+ - **Sources**: installed source —
664
+ opentelemetry-instrumentation-rack-0.31.1 `instrumentation.rb:24-25`
665
+ (allowlist options), `instrumentation.rb:108-127` (compiled at install:
666
+ enumerate-up-front, no capture-all; `build_attribute_name` folds `-` to
667
+ `_`, so the emitted suffixes are underscored);
668
+ opentelemetry-instrumentation-base-0.26.1 `base.rb:218-227` (install is
669
+ first-wins/idempotent), `base.rb:274-313` (env override beats passed
670
+ config per option), `base.rb:343-363` (the `;`-separated `name=value`
671
+ env grammar; a valueless `name=` is dropped);
672
+ opentelemetry-instrumentation-faraday-0.33.0 `instrumentation.rb` (no
673
+ header option; `patches/stable/connection.rb:18-25` — the auto-insert
674
+ appends `use(:open_telemetry)` AFTER the customer's builder block) and
675
+ `middlewares/stable/tracer_middleware.rb:27-45` (the span is active only
676
+ around the handlers BELOW the tracer middleware);
677
+ opentelemetry-instrumentation-net_http-0.29.0 `patches/instrument.rb`
678
+ (no extension point).
679
+ - **Decision & why** (user ruling 2026-07-28, superseding the first-pass
680
+ capture-all Rack middleware): INBOUND capture is the official rack gem's
681
+ OWN header options, which foam PRE-INSTALLS with documented default
682
+ lists (`DEFAULT_REQUEST_HEADERS`/`DEFAULT_RESPONSE_HEADERS` — the
683
+ standard APM set plus the seven floor headers, which arrive `[REDACTED]`
684
+ so presence stays visible) — the same pre-install shape as the loop
685
+ guard, ordered AFTER `install_additional` so an operator's explicit rack
686
+ instance wins entirely, and computed per option AGAINST the operator's
687
+ env var so an operator-named option is never filled by foam (the F-PY3
688
+ posture: a narrower operator list is never widened, and `name=` narrows
689
+ to the gem default `[]`, never to foam's list). The honest consequence
690
+ is documented, not papered over: a header outside the list is NOT
691
+ captured (README shows the env-var extension recipe). OUTBOUND stays
692
+ foam's Faraday middleware (Faraday's first-class public API — the
693
+ official faraday instrumentation has no header option), capture-all,
694
+ triple-gated per write: foam owns the traces slot (rule 18 B), the span
695
+ is recording, AND the span's `instrumentation_scope.name` is
696
+ "OpenTelemetry::Instrumentation::Faraday" — a mis-ordered stack captures
697
+ NOTHING instead of enriching the wrong span. Faraday placement stays a
698
+ per-connection line (`f.use :open_telemetry` then
699
+ `f.use :foam_otel_headers`): the only default-on mechanism is the
700
+ Connection prepend the official gem uses — exactly the
701
+ internals-patching foam never does. Net::HTTP (and Excon / httprb /
702
+ HTTPX) outbound headers are deliberately OUT OF SCOPE — skipped and
703
+ documented rather than hacked. Masking stays central: the credential
704
+ floor covers the emitted `http.{request,response}.header.<name>` forms
705
+ at the exporter boundary (dash/underscore-normalized, so the rack gem's
706
+ underscored suffixes match), never a key list in the capture path.
707
+ - **Mitigation**: `lib/foam/otel/header_capture.rb` (the default lists,
708
+ `preinstall_rack_defaults!` + the operator-precedence parsing, the
709
+ Faraday middleware + public-registry registration, the shared gates);
710
+ `lib/foam/otel/init.rb` (`activate_instrumentations` pre-install
711
+ ordering — after `install_additional`, before `install_all` — gated on
712
+ foam owning traces; `activate_floor_extensions` Faraday registration).
713
+ - **Test**: `spec/header_capture_spec.rb` — default-on capture both
714
+ directions on the official rack span, the honest negative (an unlisted
715
+ header is NOT captured), floor headers arrive `[REDACTED]` on the wire
716
+ with zero config, multi-value arity, operator env respect (named option
717
+ wins, narrow-to-empty honored, explicit instance wins entirely),
718
+ install idempotence (a second sweep never clobbers the config),
719
+ foreign-traces stand-down, hostile values (invalid UTF-8 / huge /
720
+ raising readers), Faraday scope/ownership gates + mis-ordered-stack
721
+ no-op + registration idempotence; `spec/conventions_spec.rb` (rule 21:
722
+ header capture never grows a `rack.input` body tee).
723
+
724
+ ## F15: Opt-in payload (body) capture must TEE, never consume — and the rack span's lifetime decides when body attributes can land
725
+
726
+ - **Trap**: five ways foam's opt-in body capture (`payload_capture.rb`,
727
+ behind `capture_payloads:` — default `:off`) could break the customer or
728
+ silently lose data. (1) Reading `env['rack.input']` ahead of the app —
729
+ or rewinding it — breaks Rack 3 apps: inputs are no longer required to
730
+ be rewindable, and a consumed stream starves the framework's own parser.
731
+ (2) Buffering the response to capture it breaks streaming (SSE, large
732
+ downloads), and Rack 3 `#call`-only streaming bodies must not be wrapped
733
+ at all — defining `#each` on the wrapper would change how the server
734
+ drives the response. (3) The official rack instrumentation ends its span
735
+ at DIFFERENT times per mode: the default `Rack::Events` handler finishes
736
+ it when the response body is CLOSED (`EventedBodyProxy` fires
737
+ `on_finish` after the inner body's close — so a tee flushing at close
738
+ writes in time), but the non-events `TracerMiddleware` ends it when
739
+ `@app.call` RETURNS — attributes flushed from a streamed body's close
740
+ there would hit an ENDED span (upstream logs a warning per write). (4)
741
+ An OUTER middleware (`Rack::MethodOverride`) reads the form body,
742
+ rewinds — or repositions via `seek(0)`/`pos=`, equivalent idioms on the
743
+ rewindable inputs real servers hand out — and the framework re-reads it:
744
+ a naive tee (or one that dedupes `rewind` alone) captures and counts it
745
+ twice. (5) The `:errors` mode needs "an exception was recorded on the
746
+ span" — re-implementing that check would duplicate
747
+ `Errors.record_once`'s dedupe registry and drift from it.
748
+ - **Sources**: installed source — rack `body_proxy.rb:28-35` (`close`
749
+ closes the inner body BEFORE the proxy's block) and `:47-53` (`to_ary`
750
+ auto-closes), rack `events.rb:140-142` (the body proxy that fires
751
+ `on_finish` on close); opentelemetry-instrumentation-rack-0.31.1 stable
752
+ `event_handler.rb:109-117,199-205` (`on_finish`/`detach_context` finish
753
+ the span at body close) vs stable `tracer_middleware.rb:78-90`
754
+ (`in_span` ends the span when the block — `@app.call(env).tap` —
755
+ returns); Rack SPEC (input `gets`/`read`/`each`; `to_ary` bodies;
756
+ streaming `#call` bodies); `errors.rb:17-43` (the span-carried
757
+ `@__foam_otel_recorded` ivar registry).
758
+ - **Decision & why**: delegating tees only, on BOTH directions, behind the
759
+ ONE mode switch. `:off` (the default) ships ZERO middleware (the Railtie
760
+ hook runs after `:load_config_initializers` — when init has resolved the
761
+ mode — and stands down; a manual `use` line is a one-branch
762
+ passthrough). The input tee observes exactly what the APP reads
763
+ (read/gets/each), delegates everything else with truthful `respond_to?`
764
+ (a non-rewindable input stays non-rewindable), and dedupes across EVERY
765
+ repositioning method — `rewind`, `seek`, `pos=` — with a
766
+ position/high-water scheme — bytes are captured and counted once no
767
+ matter how often an outer middleware repositions the stream. The response side
768
+ captures `to_ary`-able (buffered) bodies SYNCHRONOUSLY at middleware
769
+ return — the span is provably alive in both official modes — returning
770
+ the array as the new body (the Rack spec's sanctioned move;
771
+ `BodyProxy#to_ary` closes the original). Genuinely streaming `#each`
772
+ bodies are teed chunk-by-chunk, unbuffered, flushing attributes on
773
+ iteration-complete/close: that lands in the default `Rack::Events` mode
774
+ and is silently skipped (a `recording?` gate, no upstream warn-spam) in
775
+ `TracerMiddleware` mode — a recorded, documented loss shape, never a
776
+ broken stream. `#call`-only bodies are never wrapped (sizes ride the
777
+ declared Content-Length when present). `:errors` reads the SAME ivar
778
+ registry `Errors.record_once` stamps, through the thin
779
+ `Errors.recorded?` seam — one registry, no duplicated dedupe — plus the
780
+ status >= 500 triplet check; an exception raised through the middleware
781
+ attaches the request side before the IDENTICAL re-raise. Capture is
782
+ gated on foam OWNING the traces slot (rule 18 B — a foreign SDK's rack
783
+ span is never enriched) and on a recording span; every path is
784
+ individually rescued (rule 9). Redaction stays central: JSON-shaped body
785
+ strings are deep-masked BY FIELD NAME at the exporter boundary
786
+ (`redaction.rb` `BODY_ATTRIBUTE_NAMES` — floor + customer lists reach
787
+ inside the body JSON; a cap-truncated body no longer parses, so only the
788
+ value-shape scans cover it — documented limit).
789
+ - **Mitigation**: `lib/foam/otel/payload_capture.rb` (the whole module:
790
+ the mode gate + FOAM_CAPTURE_PAYLOADS clamp plumbing, ownership/
791
+ recording gates, tees, `to_ary`/streaming discrimination,
792
+ textual/identity classification, the 8192-char cap + fleet truncation
793
+ marker); `lib/foam/otel/init.rb` (option validation + env clamp,
794
+ mode-gated Railtie-fallback wiring); `lib/foam/otel/errors.rb`
795
+ (`recorded?`); `lib/foam/otel/redaction.rb` (`mask_body_attribute`).
796
+ - **Test**: `spec/payload_capture_spec.rb` — all three modes on the real
797
+ redacting wire path (`:off` ships zero middleware and zero attrs;
798
+ `:errors` attaches on exception AND 5xx and on the record_exception ivar
799
+ seam, NOT on 200/404; `:always` attaches on 200), env-clamp precedence
800
+ both directions + invalid-env fallback, the streaming-body proof (chunks
801
+ intact, close preserved, attributes landed), the rewind/seek/pos=
802
+ re-read dedupe, the
803
+ never-reads case, cap/true-size/truncation, binary/gzip sizes-only,
804
+ concurrent-request isolation, the foreign-provider and non-recording
805
+ no-ops, the hostile-input battery, the error-path capture, and the
806
+ JSON-body deep-redaction wire proof; `spec/conventions_spec.rb` (rule 21
807
+ as amended: `rack.input` touched ONLY by payload_capture.rb).
808
+
636
809
  ---
637
810
 
638
811
  ## General gotchas (applicable to Ruby)
data/README.md CHANGED
@@ -33,8 +33,11 @@ gem "foam-otel"
33
33
  # Faraday), the primary datastores (pg, mysql2, redis, mongo) with RAW
34
34
  # db.statement capture, Sidekiq, the stdlib Logger bridge, hand-written Ruby
35
35
  # runtime + GC metrics, session stitching (browser session.id via baggage),
36
- # and the LLM shims (OpenAI, Anthropic, Gemini, ruby_llm activity + raw
37
- # content). Add only the niche long tail your app needs beyond the floor:
36
+ # default-on HTTP header capture (a documented default header list on the
37
+ # official rack server span; every Faraday client header see "Header
38
+ # capture" below), and the LLM shims
39
+ # (OpenAI, Anthropic, Gemini, ruby_llm — activity + raw content). Add only
40
+ # the niche long tail your app needs beyond the floor:
38
41
  gem "opentelemetry-instrumentation-graphql" # e.g. GraphQL
39
42
  gem "opentelemetry-instrumentation-resque" # e.g. Resque
40
43
  ```
@@ -131,6 +134,184 @@ baggage is absent. **CORS note (the FDE wires this on every frontend-called
131
134
  API):** the allowlist must include BOTH headers —
132
135
  `Access-Control-Allow-Headers: traceparent, baggage`.
133
136
 
137
+ ### Header capture — request + response headers on the official spans
138
+
139
+ **Inbound (default-on, no init option):** foam configures the OFFICIAL
140
+ `opentelemetry-instrumentation-rack` gem's own header options
141
+ (`allowed_request_headers:` / `allowed_response_headers:`) with a
142
+ documented **default list** at init, so the rack SERVER span carries the
143
+ listed request and response headers as
144
+ `http.request.header.<name>` / `http.response.header.<name>` attributes
145
+ (the gem's form: lowercase with `-` folded to `_`, e.g.
146
+ `http.request.header.x_request_id`), on error requests too.
147
+
148
+ > **Honest limit (deliberate divergence from foam's js/python cores):**
149
+ > Ruby inbound captures the **named default list below, NOT all headers** —
150
+ > the official gem's options are an enumerated allowlist with no
151
+ > capture-all form, and that official option is the chosen seam (ruling
152
+ > 2026-07-28). A custom header outside the list is **not captured** unless
153
+ > you extend the list (recipe below).
154
+
155
+ The default lists (`Foam::Otel::HeaderCapture::DEFAULT_REQUEST_HEADERS` /
156
+ `DEFAULT_RESPONSE_HEADERS` — frozen, documented constants):
157
+
158
+ - **request** — `content-type`, `content-length`, `content-encoding`,
159
+ `accept`, `accept-charset`, `accept-encoding`, `accept-language`,
160
+ `user-agent`, `referer`, `origin`, `host`, `cache-control`, `pragma`,
161
+ `if-none-match`, `if-modified-since`, `range`, `via`, `forwarded`,
162
+ `x-forwarded-for`, `x-forwarded-proto`, `x-forwarded-host`,
163
+ `x-forwarded-port`, `x-real-ip`, `x-request-id`, `x-correlation-id`,
164
+ plus the request half of the credential floor — `authorization`,
165
+ `proxy-authorization`, `cookie`, `x-api-key`, `x-auth-token`;
166
+ - **response** — `content-type`, `content-length`, `content-encoding`,
167
+ `content-language`, `content-range`, `cache-control`, `pragma`,
168
+ `expires`, `age`, `etag`, `last-modified`, `vary`, `location`,
169
+ `retry-after`, `x-request-id`, `x-correlation-id`, `x-runtime`,
170
+ `server-timing`, plus the response half of the floor — `set-cookie`,
171
+ `www-authenticate`.
172
+
173
+ Propagation headers (`traceparent`, `tracestate`, `baggage`) are
174
+ deliberately not listed — they are extracted as span context, not captured
175
+ as attributes; hop-by-hop plumbing (`connection`, `keep-alive`, …) is
176
+ skipped too.
177
+
178
+ **Extending (or narrowing) the list — the gem's own standard env var,
179
+ always respected, never overridden by foam:**
180
+
181
+ ```bash
182
+ # The value REPLACES the list for that option — name every header you want:
183
+ export OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS='allowed_request_headers=content-type,accept,x-request-id,x-tenant-id'
184
+ ```
185
+
186
+ Precedence: (1) an explicit rack instrumentation instance you pass via
187
+ `additional_instrumentations:` installs first and its header config wins
188
+ entirely; (2) any header option you set in
189
+ `OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS` governs that option (foam's
190
+ default fills only the option you did not touch — a narrower operator list
191
+ is never widened, even the narrow-to-empty `allowed_request_headers=`
192
+ spelling); (3) otherwise foam's defaults apply. Foam never installs the
193
+ rack instrumentation (or its header config) when a foreign SDK owns the
194
+ traces slot.
195
+
196
+ **Floor masking, zero config:** the always-on credential floor masks the
197
+ seven credential headers (`authorization`, `proxy-authorization`, `cookie`,
198
+ `set-cookie`, `x-api-key`, `x-auth-token`, `www-authenticate`) to
199
+ `[REDACTED]` in the emitted attribute forms (dash/underscore-normalized
200
+ match) — they ride the default lists deliberately so their PRESENCE is
201
+ visible, value never (see "The default credential denylist";
202
+ `x-forwarded-for` / `x-real-ip` are on the 52-entry key floor and arrive
203
+ `[REDACTED]` too). **Masking further captured headers is redaction's job,
204
+ not a capture switch:** list the emitted name form in `redact_keys` /
205
+ `redact: { secrets: [...] }` (tail mask) or `redact_pii_keys` /
206
+ `redact: { pii: [...] }` (full `[REDACTED]`) — the substring key match
207
+ reaches the attribute names (inbound names are underscored by the rack gem,
208
+ so list e.g. `x_runtime`; Faraday's outbound names keep dashes).
209
+
210
+ **Outbound (Faraday):** the official faraday instrumentation has no header
211
+ option at all, so outbound stays foam's own Faraday middleware (its
212
+ first-class public extension API), enriching the official CLIENT span with
213
+ **every** request header actually sent (the injected `traceparent`
214
+ included) and every response header received — string-array attributes,
215
+ dash-form names. It never creates a span and stands down when foam is
216
+ disabled/killed, when a foreign SDK owns traces, or when the current span
217
+ is not the official faraday instrumentation's own (a mis-ordered stack
218
+ captures nothing rather than writing onto the wrong span). One line per
219
+ connection (Faraday has no public add-to-every-connection hook — the
220
+ middleware name is registered by init, placement is yours; ORDER MATTERS,
221
+ the official middleware first):
222
+
223
+ ```ruby
224
+ conn = Faraday.new(url: "https://partner-api.example") do |f|
225
+ f.use :open_telemetry # the official client span (explicit, so it sits OUTSIDE)
226
+ f.use :foam_otel_headers # foam's header capture, directly inside it
227
+ end
228
+ ```
229
+
230
+ **Deliberately out of scope** (no standard extension point exists —
231
+ skipped, not monkey-patched): outbound **Net::HTTP / Excon / HTTP (httprb)
232
+ / HTTPX** headers (their official instrumentations expose no
233
+ request/response hook or header option) and gRPC metadata. Outbound header
234
+ coverage today is Faraday. **Bodies/payloads are not captured by header
235
+ capture** — bodies are the separate, opt-in `capture_payloads:` capability
236
+ below.
237
+
238
+ ### Payload (body) capture — opt-in, `capture_payloads:`
239
+
240
+ **Off by default.** One init option turns on inbound HTTP **body** capture
241
+ on the official rack SERVER span (headers stay the header-capture seam
242
+ above — this never duplicates them):
243
+
244
+ ```ruby
245
+ Foam::Otel.init(
246
+ name: "checkout-api", environment: ENV.fetch("APP_ENV"),
247
+ enabled: ENV.fetch("APP_ENV") == "production", token: ENV.fetch("FOAM_OTEL_TOKEN"),
248
+ capture_payloads: :errors # :off (default) | :errors | :always — equivalent strings accepted
249
+ )
250
+ ```
251
+
252
+ - **`:off` (default)** — zero body teeing, zero per-request allocation, and
253
+ **zero middleware shipped**: with the default, foam's Rails wiring
254
+ installs nothing at all.
255
+ - **`:errors`** — bodies are teed per request but attributes attach to the
256
+ span **only when the request errored**: `Foam::Otel.record_exception`
257
+ called while the rack server span is current, an exception raised through
258
+ the middleware, or a response status **>= 500**. A clean 2xx/4xx
259
+ attaches **nothing**.
260
+ - **`:always`** — attach on every request.
261
+
262
+ An invalid option value raises `ArgumentError` at boot. The operator env
263
+ clamp **`FOAM_CAPTURE_PAYLOADS=off|errors|always` overrides the option in
264
+ both directions** (force payloads off on a misbehaving deploy, or force
265
+ them on without a code change — one loud `[foam]` line when it changes the
266
+ mode); an invalid env value warns and falls back to the option, never a
267
+ crashed boot.
268
+
269
+ **What lands on the span** (in `:always`, or in `:errors` on an errored
270
+ request): `http.request.body` / `http.response.body` for **textual**
271
+ payloads (`text/*`, JSON/`+json`, urlencoded forms, XML/`+xml`, GraphQL;
272
+ skipped when `Content-Encoding` is not identity), capped at **8192 chars**
273
+ with an `…[truncated]` marker, plus `http.request.body.size` /
274
+ `http.response.body.size` — **true byte sizes** when known (declared
275
+ `Content-Length`, else the bytes actually observed). Non-textual payloads
276
+ contribute sizes only.
277
+
278
+ **Stream safety (GOTCHAS F15):** bodies are TEED, never consumed — foam
279
+ records exactly what your app reads from `rack.input` (an app that never
280
+ reads its body captures nothing; a re-read form body — rewound, or
281
+ repositioned via `seek`/`pos=` — is captured and counted once), and
282
+ streaming/SSE responses pass through
283
+ unbuffered chunk-by-chunk with `close` preserved. Rack 3 `#call`-only
284
+ streaming bodies are never wrapped. The request and response are never
285
+ altered, and an app exception re-raises identically.
286
+
287
+ **Redaction stays central, on export:** a JSON-shaped body is
288
+ **deep-redacted by field name** at the exporter boundary — the credential
289
+ floor (`password`, `authorization`, …) and your `redact_keys` /
290
+ `redact: {secrets:/pii:}` lists reach *inside* the body JSON; urlencoded
291
+ bodies get their `k=v` pair names masked by the same tokenizer that covers
292
+ query strings; and the value-pattern secret layer scans all captured body
293
+ text. (A body truncated at the cap no longer parses as JSON, so field-name
294
+ deep-redaction cannot apply to it — the value-shape scans still run.)
295
+
296
+ **Wiring:** in **Rails**, automatic when the mode is not `:off` (foam's
297
+ railtie inserts `Foam::Otel::PayloadCapture` directly inside the official
298
+ rack middleware after your initializers run; when foam-otel loads before
299
+ rails, `init` wires it instead, as long as init runs during boot — the
300
+ standard `config/initializers/foam.rb` recipe). **Plain Rack / Sinatra**
301
+ apps add one line under the official middleware:
302
+
303
+ ```ruby
304
+ # config.ru
305
+ use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args)
306
+ use Foam::Otel::PayloadCapture # opt-in body capture (inert while capture_payloads is :off)
307
+ run MyApp
308
+ ```
309
+
310
+ The middleware enriches only: it never creates a span, and it stands down
311
+ per request when foam is disabled/killed, when the span is not recording,
312
+ or when a foreign SDK owns the traces slot. Outbound (client) bodies are
313
+ not captured — inbound only.
314
+
134
315
  ---
135
316
 
136
317
  ## The `init` options
@@ -150,6 +331,7 @@ API):** the allowlist must include BOTH headers —
150
331
  | `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
151
332
  | `additional_instrumentations:` | Array | no | `[]` | Constructed tier-2 instrumentation instances to register (fault-isolated: one that throws is skipped with a `[foam]` warning). |
152
333
  | `ignored_outbound_hosts:` | Array<String> | no | `[]` | Hosts whose outbound calls produce no spans — EXTENDS the built-in export-loop guard. For a co-resident agent's intake or a tenant exporter's endpoint. Applies to the clients whose official instrumentation supports host suppression: **Net::HTTP and Excon**. Faraday/HTTP (httprb)/HTTPX have no upstream `untraced_hosts` option — foam warns loudly at init when one of those is bundled (GOTCHAS G13); foam's own export loop is guarded for every client regardless. |
334
+ | `capture_payloads:` | Symbol/String | no | `:off` | Inbound HTTP **body** capture on the rack server span (see "Payload (body) capture"). `:off` = zero teeing, zero middleware (the default); `:errors` = tee per request, attach only on exception/5xx; `:always` = attach on every request. Equivalent strings accepted; anything else raises at boot. Overridden in both directions by the `FOAM_CAPTURE_PAYLOADS` env clamp (env table below). Textual payloads only, 8192-char cap + `…[truncated]`, true `.size` attributes; sizes-only for binary/compressed. |
153
335
  | `diagnostics:` | Boolean | no | false | Verbose `[foam]` self-reporting of init/health. Warnings and errors are always loud regardless. |
154
336
  | `secret_heuristics:` | Boolean | no | true | The value-pattern secret layer's HEURISTIC tier (generic keyword+entropy detection, see "The value-pattern secret layer"). `false` disables ONLY the heuristics — the named provider patterns, the credential floor and the redaction-coverage contract have no off switch. Disabling logs one loud `[foam]` line at init (an explicit, audited decision for telemetry whose legitimate values collide with the heuristics). |
155
337
 
@@ -823,6 +1005,8 @@ end
823
1005
  | `OTEL_EXPORTER_OTLP_ENDPOINT` | HONORED — the one operator-level override of the pinned fleet endpoint (for foam's own conformance rig / enterprise egress). Active → loud `[foam]` warning naming the destination. Applies to door-2 taps identically (and moves the host the required loop step must name). |
824
1006
  | `OTEL_PROPAGATORS=none` | HONORED — turns trace propagation OFF (links lost) while telemetry keeps flowing; warns. Any other value warns and is ignored (foam's propagator set is fixed: W3C tracecontext + baggage). |
825
1007
  | `OTEL_BSP_SCHEDULE_DELAY` / `OTEL_BLRP_SCHEDULE_DELAY` / `OTEL_METRIC_EXPORT_INTERVAL` | HONORED — batch cadence, read natively by the upstream SDK. |
1008
+ | `FOAM_CAPTURE_PAYLOADS` | HONORED — the operator clamp over the `capture_payloads:` init option (`off`/`errors`/`always`, case-insensitive), overriding it in BOTH directions with one loud `[foam]` line when it changes the mode. An invalid value warns and falls back to the init option (never crashes a boot). This is the ONE foam-named env var the gem reads — an override valve over an init-declared option, never an on/off switch, token, or config fallback (those still arrive only through `init`'s explicit arguments). Read once at init; changing it implies a restart. |
1009
+ | `OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS` | HONORED (by the contrib rack gem itself) and RESPECTED by foam — a header option you set here (`allowed_request_headers=…` / `allowed_response_headers=…`) governs that option; foam's default header list fills only the option you did not touch (never widened, never overridden — see "Header capture"). The other `OTEL_RUBY_INSTRUMENTATION_<NAME>_CONFIG_OPTS` vars are likewise the contrib gems' own standard levers (e.g. the Sidekiq `propagation_style` note above). |
826
1010
  | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | INERT — foam wires ONE resolved endpoint into all three exporters explicitly, so per-signal endpoint vars never redirect (or split) foam's export. Set → loud `[foam]` warning that it is inert. |
827
1011
  | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | INERT — as above (warns when set). |
828
1012
  | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | INERT — as above (warns when set). |
@@ -12,7 +12,7 @@ module Foam
12
12
  Config = Struct.new(
13
13
  :name, :environment, :version, :enabled,
14
14
  :redact_keys, :redact_pii_keys, :redact_detect, :ignored_outbound_hosts,
15
- :diagnostics, :endpoint, :secret_heuristics,
15
+ :diagnostics, :endpoint, :secret_heuristics, :capture_payloads,
16
16
  keyword_init: true
17
17
  )
18
18
 
@@ -22,6 +22,14 @@ module Foam
22
22
  # opts into the PII detection tier. Anything else raises at boot.
23
23
  REDACT_OPTION_FIELDS = %w[secrets pii detect].freeze
24
24
 
25
+ # The exact modes the `capture_payloads:` init option accepts
26
+ # (payload-capture mandate 2026-07-28; equivalent strings — trimmed,
27
+ # case-insensitive — are accepted and canonicalized to these symbols).
28
+ # :off is the default: zero body teeing, zero per-request allocation, no
29
+ # middleware shipped. The FOAM_CAPTURE_PAYLOADS operator env clamp
30
+ # (init.rb) accepts the same spellings and overrides the option.
31
+ CAPTURE_PAYLOAD_MODES = %i[off errors always].freeze
32
+
25
33
  class << self
26
34
  # The inert config the helpers read before init() runs (everything a
27
35
  # no-op needs: empty redaction lists, export disabled).
@@ -32,7 +40,7 @@ module Foam
32
40
  redact_detect: [].freeze,
33
41
  ignored_outbound_hosts: [].freeze,
34
42
  diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT,
35
- secret_heuristics: true
43
+ secret_heuristics: true, capture_payloads: :off
36
44
  ).freeze
37
45
  end
38
46
 
@@ -44,8 +52,13 @@ module Foam
44
52
 
45
53
  def resolve_config(name:, environment:, version:, enabled:,
46
54
  redact_keys:, redact_pii_keys:, ignored_outbound_hosts:,
47
- diagnostics:, endpoint:, secret_heuristics: true, redact: nil)
55
+ diagnostics:, endpoint:, secret_heuristics: true, redact: nil,
56
+ capture_payloads: :off)
48
57
  keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
58
+ # capture_payloads is validated LOUDLY at boot (rule 10, exactly like
59
+ # the redact: object): a value outside CAPTURE_PAYLOAD_MODES (or its
60
+ # string spellings) raises here — never a silent :off.
61
+ capture_mode = validate_capture_payloads!(capture_payloads)
49
62
  Config.new(
50
63
  name: name,
51
64
  environment: environment,
@@ -71,12 +84,44 @@ module Foam
71
84
  # module-constant machinery no config shape can narrow. Anything
72
85
  # but literal false means ON (default-on preserves the fleet's
73
86
  # no-leakage bar; disabling is an explicit, audited decision).
74
- secret_heuristics: secret_heuristics == false ? false : true
87
+ secret_heuristics: secret_heuristics == false ? false : true,
88
+ # The payload-capture mode (2026-07-28 mandate): :off (the default
89
+ # — zero teeing, zero middleware) | :errors | :always, canonical
90
+ # symbol form. init.rb applies the FOAM_CAPTURE_PAYLOADS operator
91
+ # env clamp BEFORE this resolves.
92
+ capture_payloads: capture_mode
75
93
  ).freeze
76
94
  end
77
95
 
78
96
  private
79
97
 
98
+ # ---- the capture_payloads option (payload-capture mandate 2026-07-28)
99
+ # Canonicalize a mode value: the three symbols, or their string
100
+ # spellings trimmed + case-insensitive, map to the canonical symbol;
101
+ # anything else is nil (the callers decide raise-vs-fallback).
102
+ def normalize_capture_payloads(value)
103
+ return value if CAPTURE_PAYLOAD_MODES.include?(value)
104
+ return nil unless value.is_a?(String) || value.is_a?(Symbol)
105
+
106
+ mode = value.to_s.strip.downcase.to_sym
107
+ CAPTURE_PAYLOAD_MODES.include?(mode) ? mode : nil
108
+ rescue StandardError
109
+ nil
110
+ end
111
+
112
+ # The loud-at-boot door (rule 10): an invalid INIT value is a
113
+ # programming error the engineer must catch on their machine.
114
+ # (The env clamp's invalid values warn + fall back instead — init.rb.)
115
+ def validate_capture_payloads!(value, context: "Foam::Otel.init")
116
+ mode = normalize_capture_payloads(value)
117
+ if mode.nil?
118
+ raise ArgumentError, "#{context} capture_payloads: must be one of " \
119
+ "#{CAPTURE_PAYLOAD_MODES.map(&:inspect).join(', ')} " \
120
+ "(equivalent strings accepted), got #{value.inspect}"
121
+ end
122
+ mode
123
+ end
124
+
80
125
  def downcase_list(list)
81
126
  Array(list).map { |k| k.to_s.downcase }.reject(&:empty?).uniq.freeze
82
127
  end
@@ -162,7 +207,12 @@ module Foam
162
207
  ignored_outbound_hosts: existing.ignored_outbound_hosts,
163
208
  diagnostics: diagnostics ? true : false,
164
209
  endpoint: existing.endpoint,
165
- secret_heuristics: secret_heuristics == false ? false : true
210
+ secret_heuristics: secret_heuristics == false ? false : true,
211
+ # Like enabled/endpoint, capture_payloads is process-global after
212
+ # the first init: middleware insertion already happened (or
213
+ # deliberately did not — an :off boot shipped none), so a second
214
+ # init cannot meaningfully flip it. Carried forward unchanged.
215
+ capture_payloads: existing.capture_payloads
166
216
  ).freeze
167
217
  end
168
218
  end
@@ -41,6 +41,18 @@ module Foam
41
41
  # exception" into a host-thread crash (rule 9).
42
42
  false
43
43
  end
44
+
45
+ # Read-only view of the registry above (the payload-capture :errors
46
+ # mode's thin seam, mandate 2026-07-28): true when at least one
47
+ # exception was recorded on this span via record_once. Reads the SAME
48
+ # span-carried ivar — never a second registry, never duplicated dedupe
49
+ # logic. Never raises (rule 9).
50
+ def recorded?(span)
51
+ seen = span.instance_variable_get(IVAR)
52
+ seen.is_a?(Array) && !seen.empty?
53
+ rescue StandardError
54
+ false
55
+ end
44
56
  end
45
57
  end
46
58
  end