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 +4 -4
- data/GOTCHAS.md +88 -0
- data/README.md +106 -2
- data/lib/foam/otel/header_capture.rb +416 -0
- data/lib/foam/otel/init.rb +22 -0
- data/lib/foam/otel/version.rb +23 -1
- data/lib/foam/otel.rb +8 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb5e4f91fea3170bbb206582adade6ca516824edad064a4be911ebe912ce7abf
|
|
4
|
+
data.tar.gz: 67ee9186826b4bc31c7f51deeaf8fd91bec6aaec393099f23b000384821230b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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,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
|
data/lib/foam/otel/init.rb
CHANGED
|
@@ -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
|
data/lib/foam/otel/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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.
|
|
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
|