foam-otel 1.5.0 → 1.6.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: cb5e4f91fea3170bbb206582adade6ca516824edad064a4be911ebe912ce7abf
4
+ data.tar.gz: 67ee9186826b4bc31c7f51deeaf8fd91bec6aaec393099f23b000384821230b9
5
5
  SHA512:
6
- metadata.gz: 9731cb1fae1b54f236dd583102b2348ab4b45bff38745f446c584151e594ea685f2689d6a7fb4807136372006b7b10c0284b2e7c140c0c9625edb5a820294c8a
7
- data.tar.gz: 91744d9fb0fb671bce62de973eec96152b147742ee5948fedf607626dfd89b8c3c6ed055522f21c4bbfd7d65ce5b558da9e67605578e3573ec15f8b0d4efeecc
6
+ metadata.gz: 4c4c7288e2b049a9f686d0eae3637a74261820bc779d6e8ec4daf14347b6ed3b423648c4c0d226982663465907465fc350a44cd52b998ad97b4766b69ecb727b
7
+ data.tar.gz: 7eb2f27f17fc9fcd9649b72c68b59493b366ff66950c86ff42f4baa414b7ea222b9e251f30264adbfe32400fe7851b2cf64505bf666c5385546b44f4a5714768
data/GOTCHAS.md CHANGED
@@ -633,6 +633,94 @@ 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
+
636
724
  ---
637
725
 
638
726
  ## 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,106 @@ 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** — a
235
+ separate, future capability, not part of header capture.
236
+
134
237
  ---
135
238
 
136
239
  ## The `init` options
@@ -823,6 +926,7 @@ end
823
926
  | `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
927
  | `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
928
  | `OTEL_BSP_SCHEDULE_DELAY` / `OTEL_BLRP_SCHEDULE_DELAY` / `OTEL_METRIC_EXPORT_INTERVAL` | HONORED — batch cadence, read natively by the upstream SDK. |
929
+ | `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
930
  | `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
931
  | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | INERT — as above (warns when set). |
828
932
  | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | INERT — as above (warns when set). |
@@ -0,0 +1,416 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry"
4
+
5
+ require_relative "diagnostics"
6
+ require_relative "redaction"
7
+
8
+ module Foam
9
+ module Otel
10
+ # Default-on HTTP HEADER capture with NO init knob (the init signature is
11
+ # FROZEN; a capture option is forbidden surface), on both wire directions:
12
+ #
13
+ # * INBOUND — the OFFICIAL opentelemetry-instrumentation-rack gem's own
14
+ # header options, `allowed_request_headers:` /
15
+ # `allowed_response_headers:`
16
+ # (opentelemetry-instrumentation-rack-0.31.1 instrumentation.rb:24-25),
17
+ # which foam PRE-INSTALLS with the default lists below
18
+ # (preinstall_rack_defaults!, wired from init.rb's
19
+ # activate_instrumentations — the same pre-install shape as
20
+ # configure_loop_guard's untraced_hosts). The official middleware then
21
+ # records each listed header onto its own SERVER span as
22
+ # `http.request.header.<name>` / `http.response.header.<name>`
23
+ # (the gem's build_attribute_name lowercases and folds `-` to `_`,
24
+ # so `x-request-id` lands as http.request.header.x_request_id;
25
+ # values are the raw header strings).
26
+ # * OUTBOUND (Faraday) — foam's own FaradayMiddleware below (Faraday's
27
+ # first-class public middleware API), enriching the official faraday
28
+ # CLIENT span with EVERY request/response header. Faraday's official
29
+ # instrumentation has no header option at all, so the middleware seam
30
+ # stays (explicitly accepted); placement is one line per connection
31
+ # (README recipe: `f.use :foam_otel_headers`).
32
+ #
33
+ # THE SEAM DECISION (superseded + re-ruled): the first pass shipped a foam
34
+ # Rack middleware capturing ALL headers, because the official gem's
35
+ # options are a static allowlist with no capture-all form. USER RULING
36
+ # 2026-07-28: inbound headers come from the official rack gem's own
37
+ # options, configured by foam with defaults — NOT from a custom Rack
38
+ # middleware. That capture-all middleware (and its Railtie/init wiring) is
39
+ # REMOVED; the official option is the chosen seam, and the honest
40
+ # consequence is documented everywhere it matters: Ruby inbound captures
41
+ # the NAMED DEFAULT LIST below, not all headers (the js/python cores
42
+ # capture all — the official Ruby gem cannot express capture-all). A
43
+ # header outside the list is NOT captured unless the operator extends the
44
+ # list via the gem's own standard env var (below).
45
+ #
46
+ # OPERATOR PRECEDENCE (never override an operator's own header config):
47
+ # 1. An explicit rack instrumentation instance passed through init's
48
+ # `additional_instrumentations:` installs FIRST (install_additional
49
+ # runs before this pre-install; Instrumentation::Base#install is
50
+ # first-wins) — the operator's config, header options included, wins
51
+ # entirely and foam's pre-install no-ops.
52
+ # 2. The gem's standard env var
53
+ # OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS
54
+ # (instrumentation-base config_overrides_from_env) is parsed by foam
55
+ # BEFORE pre-installing: any header option the operator names there
56
+ # is left OUT of foam's config — the operator's value (applied by
57
+ # the gem itself at install, where an env override also beats any
58
+ # passed config) governs that option, including the narrow-to-empty
59
+ # spelling `allowed_request_headers=`. Foam's default fills ONLY the
60
+ # header option(s) the operator did not touch. This mirrors the
61
+ # python core's F-PY3 posture: a narrower operator allowlist is
62
+ # respected, never widened.
63
+ # 3. Operator untouched → foam pre-installs the rack instrumentation
64
+ # with BOTH default lists; the later install_all sweep skips the
65
+ # already-installed gem (install is idempotent).
66
+ # Extending the list is the same standard env var, e.g.:
67
+ # OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS='allowed_request_headers=<foam defaults>,x-tenant-id'
68
+ # (the env value REPLACES the list for that option — include the
69
+ # defaults you still want).
70
+ #
71
+ # Masking stays central (never a key list here): the credential floor
72
+ # masks the seven credential headers in the emitted
73
+ # http.{request,response}.header.<name> forms at the exporter boundary
74
+ # (redaction.rb floor_kind — dash/underscore-normalized, so the gem's
75
+ # underscore forms match), and the customer masks further names via
76
+ # redact_keys / redact: {secrets:/pii:}. The floor-listed headers are
77
+ # deliberately IN the default lists so their PRESENCE is visible as
78
+ # [REDACTED] — matching the js/python wire shape.
79
+ #
80
+ # DELIBERATELY OUT OF SCOPE (no standard seam — skipped, not hacked):
81
+ # outbound Net::HTTP / Excon / HTTP (httprb) / HTTPX headers (their
82
+ # official instrumentations expose no request/response hook or header
83
+ # option) and gRPC metadata. Bodies are NOT captured (a separate future
84
+ # pass).
85
+ module HeaderCapture
86
+ # ---- THE DEFAULT HEADER LISTS (foam's Ruby fleet default) --------------
87
+ # Frozen, documented, grouped by purpose. Survey basis (recorded in the
88
+ # PR design record): New Relic's captured request/response header
89
+ # attributes, AppSignal's default request_headers config, Sentry's
90
+ # header capture w/ denylist, the OTel semconv opt-in capture model —
91
+ # erring toward MORE coverage. Extendable per customer via
92
+ # OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS (recipe above/README).
93
+ # Deliberately EXCLUDED: propagation headers (traceparent, tracestate,
94
+ # baggage, b3) — they are extracted as span context, capturing them
95
+ # would duplicate the trace ids; hop-by-hop plumbing (connection,
96
+ # keep-alive, transfer-encoding, upgrade, te, trailer) — no diagnostic
97
+ # value.
98
+ DEFAULT_REQUEST_HEADERS = %w[
99
+ content-type content-length content-encoding
100
+ accept accept-charset accept-encoding accept-language
101
+ user-agent referer origin host
102
+ cache-control pragma if-none-match if-modified-since range
103
+ via forwarded x-forwarded-for x-forwarded-proto x-forwarded-host
104
+ x-forwarded-port x-real-ip
105
+ x-request-id x-correlation-id
106
+ authorization proxy-authorization cookie x-api-key x-auth-token
107
+ ].freeze
108
+ # Group purposes (in list order):
109
+ # content-type/-length/-encoding — body metadata (mismatch debugging);
110
+ # accept* — content negotiation;
111
+ # user-agent/referer/origin/host — client + navigation context
112
+ # (CORS/vhost triage);
113
+ # cache-control..range — caching / conditional requests
114
+ # (304-vs-200 and partial-GET triage);
115
+ # via..x-real-ip — proxy/LB chain (x-forwarded-for and
116
+ # x-real-ip are on the 52-entry
117
+ # credential/PII key floor, so they
118
+ # arrive [REDACTED] — presence still
119
+ # visible);
120
+ # x-request-id/x-correlation-id — cross-system correlation ids;
121
+ # authorization..x-auth-token — the request half of the credential
122
+ # floor: ALWAYS [REDACTED] on the
123
+ # wire, captured so auth PRESENCE is
124
+ # visible (matches js/python).
125
+
126
+ DEFAULT_RESPONSE_HEADERS = %w[
127
+ content-type content-length content-encoding content-language
128
+ content-range
129
+ cache-control pragma expires age etag last-modified vary
130
+ location retry-after
131
+ x-request-id x-correlation-id x-runtime server-timing
132
+ set-cookie www-authenticate
133
+ ].freeze
134
+ # Group purposes (in list order):
135
+ # content-* — body metadata;
136
+ # cache-control..vary — caching + validators (CDN/cache
137
+ # debugging);
138
+ # location/retry-after — redirect + backpressure flow control;
139
+ # x-request-id..server-timing — correlation ids + server-side
140
+ # timing breakdowns (x-runtime is the
141
+ # Rack/Rails standard);
142
+ # set-cookie/www-authenticate — the response half of the credential
143
+ # floor: ALWAYS [REDACTED], presence
144
+ # visible.
145
+
146
+ # The rack instrumentation class foam pre-installs (resolved lazily —
147
+ # same shape as init.rb's HTTP_CLIENT_INSTRUMENTATIONS: only the
148
+ # …::Instrumentation class responds to `.instance.install`).
149
+ RACK_INSTRUMENTATION = "OpenTelemetry::Instrumentation::Rack::Instrumentation"
150
+
151
+ # The gem's own standard per-instrumentation config env var
152
+ # (instrumentation-base config_overrides_from_env). Foam PARSES it only
153
+ # to stand down per option — foam never writes it, and the gem itself
154
+ # applies it at install (where it also overrides any passed config).
155
+ RACK_CONFIG_OPTS_VAR = "OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS"
156
+
157
+ # The two rack header options foam defaults (everything else is the
158
+ # gem's own business).
159
+ RACK_HEADER_OPTIONS = {
160
+ allowed_request_headers: DEFAULT_REQUEST_HEADERS,
161
+ allowed_response_headers: DEFAULT_RESPONSE_HEADERS,
162
+ }.freeze
163
+
164
+ # The official faraday instrumentation's tracer name — the scope gate
165
+ # FaradayMiddleware enriches through (Instrumentation::Base#install
166
+ # binds the tracer under the instrumentation's own name).
167
+ FARADAY_SCOPE_NAME = "OpenTelemetry::Instrumentation::Faraday"
168
+
169
+ # The public Faraday middleware-registry key foam registers under
170
+ # (README recipe: `f.use :foam_otel_headers`).
171
+ FARADAY_MIDDLEWARE_NAME = :foam_otel_headers
172
+
173
+ class << self
174
+ # ---- inbound: pre-install the OFFICIAL rack gem with foam defaults --
175
+ # Called from activate_instrumentations AFTER install_additional (an
176
+ # operator's explicit rack instance wins — precedence rule 1 above)
177
+ # and BEFORE install_all (which skips the already-installed gem).
178
+ # Never raises; a failure warns loudly (rule 15 — header capture
179
+ # silently off was the 1.0.0 loop-guard bug's hiding shape).
180
+ def preinstall_rack_defaults!
181
+ klass = rack_instrumentation_class
182
+ if klass.nil?
183
+ # The rack instrumentation gem is not bundled (it is a hard
184
+ # gemspec dependency, so this is belt-and-braces like
185
+ # configure_loop_guard's NameError path).
186
+ Diagnostics.info("header capture: #{RACK_INSTRUMENTATION} not present — inbound header capture skipped")
187
+ return false
188
+ end
189
+
190
+ instance = klass.instance
191
+ if instance.installed?
192
+ # Precedence rule 1: an explicit operator install (usually via
193
+ # additional_instrumentations) already configured the gem — foam
194
+ # touches nothing, including its header options.
195
+ Diagnostics.info("header capture: rack instrumentation already installed by the operator — " \
196
+ "foam's default header lists NOT applied (operator config wins)")
197
+ return false
198
+ end
199
+
200
+ config = rack_header_defaults
201
+ if config.empty?
202
+ # Precedence rule 2, both options operator-set: nothing left to
203
+ # default; install_all installs the gem and the gem applies the
204
+ # operator's env values itself.
205
+ Diagnostics.info("header capture: both rack header options set via #{RACK_CONFIG_OPTS_VAR} — " \
206
+ "operator lists win; foam defaults not applied")
207
+ return false
208
+ end
209
+
210
+ installed = instance.install(config)
211
+ unless installed
212
+ # Present-check false (a non-Rack process: Sidekiq-only worker) or
213
+ # OTEL_RUBY_INSTRUMENTATION_RACK_ENABLED=false — expected states,
214
+ # narrated under diagnostics only (rule 10). There are no server
215
+ # spans to enrich in either case.
216
+ Diagnostics.info("header capture: rack instrumentation not installable here (Rack absent or " \
217
+ "disabled by env) — no inbound spans, no inbound header capture")
218
+ end
219
+ installed
220
+ rescue StandardError => e
221
+ Diagnostics.warn("header capture: rack pre-install failed (#{e.class}: #{e.message}) — inbound " \
222
+ "header capture is OFF (the official rack spans still flow, without header attributes)")
223
+ false
224
+ end
225
+
226
+ # Foam's defaults MINUS every header option the operator named in the
227
+ # gem's standard env var (precedence rule 2: an operator-named option
228
+ # is theirs — even the narrow-to-empty `allowed_request_headers=`
229
+ # spelling, which the gem's own env parser drops, must fall back to
230
+ # the GEM default [], never to foam's list).
231
+ def rack_header_defaults
232
+ operator = operator_rack_options
233
+ RACK_HEADER_OPTIONS.reject { |name, _| operator.include?(name) }
234
+ end
235
+
236
+ # The option names the operator set in
237
+ # OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS, parsed with the exact
238
+ # upstream grammar (`;`-separated `name=value` pairs —
239
+ # instrumentation-base config_overrides_from_env). Never raises.
240
+ def operator_rack_options
241
+ raw = ENV[RACK_CONFIG_OPTS_VAR]
242
+ return [] if raw.nil? || raw.strip.empty?
243
+
244
+ raw.split(";").filter_map do |pair|
245
+ name = pair.split("=", 2).first.to_s.strip
246
+ name.to_sym unless name.empty?
247
+ end
248
+ rescue StandardError
249
+ []
250
+ end
251
+
252
+ # ---- outbound: Faraday wiring, PUBLIC API ONLY ----------------------
253
+ # Register the middleware under its registry name so the README
254
+ # one-liner (`f.use :foam_otel_headers`) works. Faraday has no public
255
+ # "add to every connection" hook — the official instrumentation gets
256
+ # its default-on placement by PREPENDING Faraday::Connection, which
257
+ # is exactly the third-party-internals patching foam never does — so
258
+ # per-connection insertion is the documented recipe, not automatic
259
+ # (README "Header capture"). Idempotent; inert without Faraday.
260
+ def register_faraday_middleware!
261
+ return false if @faraday_registered
262
+ return false unless defined?(::Faraday::Middleware) &&
263
+ ::Faraday::Middleware.respond_to?(:register_middleware)
264
+
265
+ ::Faraday::Middleware.register_middleware(FARADAY_MIDDLEWARE_NAME => FaradayMiddleware)
266
+ @faraday_registered = true
267
+ rescue StandardError => e
268
+ Diagnostics.warn("header capture: Faraday middleware registration failed " \
269
+ "(#{e.class}: #{e.message}) — wire it manually with " \
270
+ "`use Foam::Otel::HeaderCapture::FaradayMiddleware` (README)")
271
+ false
272
+ end
273
+
274
+ # Test hook (mirrors Foam::Otel.reset_for_tests!).
275
+ def reset_for_tests!
276
+ @faraday_registered = false
277
+ end
278
+
279
+ # ---- gates shared with the Faraday middleware ------------------------
280
+ # Foam's OWN pipeline only: the traces slot must still hold the
281
+ # provider foam registered (rule 18 B — pre-init, disabled, killed,
282
+ # foreign-owned and displaced-after-init all land here as false).
283
+ def foam_owned_traces?
284
+ registered = Foam::Otel.instance_variable_get(:@foam_registered)
285
+ provider = registered && registered[:traces]
286
+ !provider.nil? && OpenTelemetry.tracer_provider.equal?(provider)
287
+ rescue StandardError
288
+ false
289
+ end
290
+
291
+ # The current span, but ONLY when it is recording AND was minted by
292
+ # the named official instrumentation (the scope gate): a mis-ordered
293
+ # middleware stack must silently capture nothing, never enrich some
294
+ # OTHER producer's span with header attributes.
295
+ def official_recording_span(scope_name)
296
+ span = OpenTelemetry::Trace.current_span
297
+ return nil unless span.respond_to?(:recording?) && span.recording?
298
+
299
+ scope = span.respond_to?(:instrumentation_scope) ? span.instrumentation_scope : nil
300
+ return nil unless scope.respond_to?(:name) && scope.name == scope_name
301
+
302
+ span
303
+ rescue StandardError
304
+ nil
305
+ end
306
+
307
+ # Never-throw attribute write with a re-checked recording gate (the
308
+ # span may have ended between capture start and this write).
309
+ def set_attr(span, key, value)
310
+ return if value.nil?
311
+
312
+ span.set_attribute(key, value) if span.respond_to?(:recording?) && span.recording?
313
+ nil
314
+ rescue StandardError, SystemStackError
315
+ nil
316
+ end
317
+
318
+ private
319
+
320
+ def rack_instrumentation_class
321
+ RACK_INSTRUMENTATION.split("::").reduce(Object) { |mod, name| mod.const_get(name) }
322
+ rescue NameError
323
+ nil
324
+ end
325
+ end
326
+
327
+ # Outbound header capture on the official Faraday CLIENT span, via
328
+ # Faraday's first-class public middleware API (a plain #call(env)
329
+ # middleware — no inheritance from Faraday internals, so this file
330
+ # loads without Faraday present). Unlike inbound (the official rack
331
+ # gem's named allowlist), this captures EVERY outbound header — the
332
+ # faraday instrumentation has no header option, so foam's middleware is
333
+ # the seam and it has no list to be limited by (explicitly accepted
334
+ # asymmetry, user ruling 2026-07-28). It must sit directly INSIDE the
335
+ # official :open_telemetry middleware (README recipe):
336
+ #
337
+ # Faraday.new(url) do |f|
338
+ # f.use :open_telemetry # the official client span
339
+ # f.use :foam_otel_headers # foam's header capture, inside it
340
+ # end
341
+ #
342
+ # because the official tracer middleware activates its span only
343
+ # around the handlers BELOW it. The scope gate makes any other
344
+ # placement a silent no-op — never a wrong-span write (GOTCHAS F14).
345
+ class FaradayMiddleware
346
+ def initialize(app, *_args)
347
+ @app = app
348
+ end
349
+
350
+ # Request headers are captured on the way in (the traceparent the
351
+ # official middleware injected included — it IS an outbound header);
352
+ # response headers via the response's public on_complete hook, which
353
+ # runs while the official span is still open (its own status hook
354
+ # rides the same mechanism). The app's/adapter's exception
355
+ # propagates untouched.
356
+ def call(env)
357
+ span = capturable_span
358
+ set_request_headers(span, env) unless span.nil?
359
+ response = @app.call(env)
360
+ unless span.nil?
361
+ begin
362
+ response.on_complete { |renv| set_response_headers(span, renv) }
363
+ rescue StandardError, SystemStackError
364
+ nil
365
+ end
366
+ end
367
+ response
368
+ end
369
+
370
+ private
371
+
372
+ def capturable_span
373
+ return nil unless HeaderCapture.foam_owned_traces?
374
+
375
+ HeaderCapture.official_recording_span(FARADAY_SCOPE_NAME)
376
+ rescue StandardError
377
+ nil
378
+ end
379
+
380
+ def set_request_headers(span, env)
381
+ each_header(env, :request_headers) do |name, value|
382
+ HeaderCapture.set_attr(span, "http.request.header.#{name}",
383
+ [Redaction.scrub_utf8(value.to_s)])
384
+ end
385
+ end
386
+
387
+ def set_response_headers(span, env)
388
+ each_header(env, :response_headers) do |name, value|
389
+ HeaderCapture.set_attr(span, "http.response.header.#{name}",
390
+ [Redaction.scrub_utf8(value.to_s)])
391
+ end
392
+ end
393
+
394
+ # Faraday header maps (Faraday::Utils::Headers) carry one string per
395
+ # name (repeats are comma-joined per RFC 9110 by the adapter), so
396
+ # each name emits a one-element semconv array.
397
+ def each_header(env, reader)
398
+ headers = env.respond_to?(reader) ? env.public_send(reader) : nil
399
+ return if headers.nil? || !headers.respond_to?(:each)
400
+
401
+ headers.each do |key, value|
402
+ name = key.to_s.downcase
403
+ next if name.empty?
404
+
405
+ yield name, value
406
+ rescue StandardError
407
+ next # one poisoned header never costs the rest
408
+ end
409
+ nil
410
+ rescue StandardError
411
+ nil
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end
@@ -18,6 +18,7 @@ require_relative "ingest" # door-2 entries (SDK still lazy — rule 41)
18
18
  require_relative "logger_bridge"
19
19
  require_relative "runtime_metrics"
20
20
  require_relative "llm"
21
+ require_relative "header_capture" # default-on header capture (rule 32a module; wired below)
21
22
 
22
23
  module Foam
23
24
  module Otel
@@ -210,6 +211,7 @@ module Foam
210
211
  Metrics.reset_for_tests!
211
212
  RuntimeMetrics.reset_for_tests!
212
213
  Redaction.reset_secret_scan_warnings!
214
+ HeaderCapture.reset_for_tests!
213
215
  end
214
216
 
215
217
  private
@@ -554,6 +556,16 @@ module Foam
554
556
 
555
557
  configure_loop_guard(config)
556
558
  install_additional(additional)
559
+ # Inbound header capture (user ruling 2026-07-28): pre-install the
560
+ # OFFICIAL rack instrumentation with foam's default header lists
561
+ # (HeaderCapture::DEFAULT_{REQUEST,RESPONSE}_HEADERS) — the same
562
+ # pre-install shape as configure_loop_guard; install_all below skips
563
+ # the already-installed gem. Ordered AFTER install_additional so an
564
+ # operator's explicit rack instance (their own header config) wins,
565
+ # and per-option operator env config
566
+ # (OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS) is never overridden —
567
+ # precedence documented in header_capture.rb.
568
+ HeaderCapture.preinstall_rack_defaults!
557
569
  OpenTelemetry::Instrumentation.registry.install_all(
558
570
  FLOOR_INSTRUMENTATION_CONFIG.merge(SUPERSEDED_INSTRUMENTATION_CONFIG)
559
571
  )
@@ -586,6 +598,16 @@ module Foam
586
598
 
587
599
  return unless foam_exports_signal?(:traces)
588
600
 
601
+ begin
602
+ # Outbound: register foam's Faraday header middleware under its
603
+ # public registry name (`f.use :foam_otel_headers` — README
604
+ # recipe). Registration only — Faraday has no public add-to-every-
605
+ # connection hook, so placement stays a per-connection line.
606
+ HeaderCapture.register_faraday_middleware!
607
+ rescue StandardError => e
608
+ Diagnostics.warn("header capture: Faraday registration failed: #{e.class}: #{e.message}")
609
+ end
610
+
589
611
  begin
590
612
  LLM.activate!
591
613
  rescue StandardError => e
@@ -61,6 +61,28 @@ module Foam
61
61
  # metric-label exemption as the secret layer. Unknown redact fields,
62
62
  # non-Array values, and unknown detect entities raise at init (loud at
63
63
  # boot). With redact: absent, behavior is byte-identical to 1.4.0.
64
- VERSION = "1.5.0"
64
+ # 1.6.0: DEFAULT-ON HTTP HEADER CAPTURE (2026-07-28; MINOR — additive wire
65
+ # change, no API/config change). New rule-32a module HeaderCapture.
66
+ # INBOUND: foam pre-installs the OFFICIAL rack instrumentation with the
67
+ # documented default header lists (DEFAULT_REQUEST_HEADERS /
68
+ # DEFAULT_RESPONSE_HEADERS) via its own allowed_request_headers /
69
+ # allowed_response_headers options (user ruling 2026-07-28 — the
70
+ # official gem's options are the seam, not a custom Rack middleware),
71
+ # so the rack SERVER span carries the LISTED headers as
72
+ # http.{request,response}.header.<name> (underscored suffix — the gem's
73
+ # form). HONEST LIMIT: a named enumeration, NOT capture-all like
74
+ # js/python (the official option cannot express capture-all); extend
75
+ # per service via OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS — operator
76
+ # header config there (or an explicit instance via
77
+ # additional_instrumentations) is always respected, never overridden.
78
+ # OUTBOUND: a Faraday middleware (`f.use :foam_otel_headers`) enriching
79
+ # the official faraday client span with every outbound header (no
80
+ # upstream header option exists — accepted asymmetry). Credential floor
81
+ # masks the seven auth headers, zero config, no off switch.
82
+ # Net::HTTP/Excon/httprb/HTTPX outbound headers deliberately out of
83
+ # scope (their official instrumentations expose no hook —
84
+ # OTel-fundamentals ruling). Bodies are NOT captured (that is the
85
+ # separate capture_payloads pass).
86
+ VERSION = "1.6.0"
65
87
  end
66
88
  end
data/lib/foam/otel.rb CHANGED
@@ -10,9 +10,14 @@
10
10
  # the PIPELINE (providers, batch processors, OTLP export to the pinned fleet
11
11
  # endpoint, the always-on redaction floor) and turns on automatic tier-1/2
12
12
  # instrumentation; the ecosystem owns the INSTRUMENTATION. The package ships
13
- # a small set of never-throw helpers and nothing else no stamping, no
14
- # request-input capture, no request-id enrichment (all removed per the spec;
15
- # stamps are the collector's job server-side).
13
+ # a small set of never-throw helpers, plus default-on HTTP header capture
14
+ # inbound via the OFFICIAL rack gem's own allowed_request_headers /
15
+ # allowed_response_headers options, pre-installed by foam with a documented
16
+ # DEFAULT LIST (a named enumeration, NOT all headers — the honest divergence
17
+ # from js/python; header_capture.rb), outbound via foam's Faraday middleware
18
+ # on the official client span — and nothing else: no stamping, no
19
+ # request-INPUT (body) capture, no request-id enrichment (removed per the
20
+ # spec; stamps are the collector's job server-side).
16
21
 
17
22
  require_relative "otel/version"
18
23
  require_relative "otel/constants"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foam-otel
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.0
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foam
@@ -384,6 +384,7 @@ files:
384
384
  - lib/foam/otel/diagnostics.rb
385
385
  - lib/foam/otel/errors.rb
386
386
  - lib/foam/otel/fork_hooks.rb
387
+ - lib/foam/otel/header_capture.rb
387
388
  - lib/foam/otel/ingest.rb
388
389
  - lib/foam/otel/ingest_metric_reader.rb
389
390
  - lib/foam/otel/init.rb