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 +4 -4
- data/GOTCHAS.md +173 -0
- data/README.md +186 -2
- data/lib/foam/otel/config.rb +55 -5
- data/lib/foam/otel/errors.rb +12 -0
- data/lib/foam/otel/header_capture.rb +416 -0
- data/lib/foam/otel/init.rb +74 -2
- data/lib/foam/otel/payload_capture.rb +655 -0
- data/lib/foam/otel/redaction.rb +59 -1
- data/lib/foam/otel/version.rb +49 -1
- data/lib/foam/otel.rb +10 -3
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48166d05b120dd8bdca15a9b45db5b4c16a6574fcb6890f4caff2c17c2ae9add
|
|
4
|
+
data.tar.gz: 2dcd4b3b146a0221c231ba5efc057085418b74e21b24d8441396d47cfc0a8288
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
37
|
-
#
|
|
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). |
|
data/lib/foam/otel/config.rb
CHANGED
|
@@ -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
|
data/lib/foam/otel/errors.rb
CHANGED
|
@@ -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
|