Module: Foam::Otel::HeaderCapture
- Defined in:
- lib/foam/otel/header_capture.rb
Overview
Default-on HTTP HEADER capture with NO init knob (the init signature is FROZEN; a capture option is forbidden surface), on both wire directions:
* INBOUND — the OFFICIAL opentelemetry-instrumentation-rack gem's own
header options, `allowed_request_headers:` /
`allowed_response_headers:`
(opentelemetry-instrumentation-rack-0.31.1 instrumentation.rb:24-25),
which foam PRE-INSTALLS with the default lists below
(preinstall_rack_defaults!, wired from init.rb's
activate_instrumentations — the same pre-install shape as
configure_loop_guard's untraced_hosts). The official middleware then
records each listed header onto its own SERVER span as
`http.request.header.<name>` / `http.response.header.<name>`
(the gem's build_attribute_name lowercases and folds `-` to `_`,
so `x-request-id` lands as http.request.header.x_request_id;
values are the raw header strings).
* OUTBOUND (Faraday) — foam's own FaradayMiddleware below (Faraday's
first-class public middleware API), enriching the official faraday
CLIENT span with EVERY request/response header. Faraday's official
instrumentation has no header option at all, so the middleware seam
stays (explicitly accepted); placement is one line per connection
(README recipe: `f.use :foam_otel_headers`).
THE SEAM DECISION (superseded + re-ruled): the first pass shipped a foam Rack middleware capturing ALL headers, because the official gem's options are a static allowlist with no capture-all form. USER RULING 2026-07-28: inbound headers come from the official rack gem's own options, configured by foam with defaults — NOT from a custom Rack middleware. That capture-all middleware (and its Railtie/init wiring) is REMOVED; the official option is the chosen seam, and the honest consequence is documented everywhere it matters: Ruby inbound captures the NAMED DEFAULT LIST below, not all headers (the js/python cores capture all — the official Ruby gem cannot express capture-all). A header outside the list is NOT captured unless the operator extends the list via the gem's own standard env var (below).
OPERATOR PRECEDENCE (never override an operator's own header config):
1. An explicit rack instrumentation instance passed through init's
`additional_instrumentations:` installs FIRST (install_additional
runs before this pre-install; Instrumentation::Base#install is
first-wins) — the operator's config, header options included, wins
entirely and foam's pre-install no-ops.
2. The gem's standard env var
OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS
(instrumentation-base config_overrides_from_env) is parsed by foam
BEFORE pre-installing: any header option the operator names there
is left OUT of foam's config — the operator's value (applied by
the gem itself at install, where an env override also beats any
passed config) governs that option, including the narrow-to-empty
spelling `allowed_request_headers=`. Foam's default fills ONLY the
header option(s) the operator did not touch. This mirrors the
python core's F-PY3 posture: a narrower operator allowlist is
respected, never widened.
3. Operator untouched → foam pre-installs the rack instrumentation
with BOTH default lists; the later install_all sweep skips the
already-installed gem (install is idempotent).
Extending the list is the same standard env var, e.g.:
OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS='allowed_request_headers=<foam defaults>,x-tenant-id'
(the env value REPLACES the list for that option — include the
defaults you still want).
Masking stays central (never a key list here): the credential floor
masks the seven credential headers in the emitted
http.request,response.header.
DELIBERATELY OUT OF SCOPE (no standard seam — skipped, not hacked): outbound Net::HTTP / Excon / HTTP (httprb) / HTTPX headers (their official instrumentations expose no request/response hook or header option) and gRPC metadata. Bodies are NOT captured (a separate future pass).
Defined Under Namespace
Classes: FaradayMiddleware
Constant Summary collapse
- DEFAULT_REQUEST_HEADERS =
---- THE DEFAULT HEADER LISTS (foam's Ruby fleet default) -------------- Frozen, documented, grouped by purpose. Survey basis (recorded in the PR design record): New Relic's captured request/response header attributes, AppSignal's default request_headers config, Sentry's header capture w/ denylist, the OTel semconv opt-in capture model — erring toward MORE coverage. Extendable per customer via OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS (recipe above/README). Deliberately EXCLUDED: propagation headers (traceparent, tracestate, baggage, b3) — they are extracted as span context, capturing them would duplicate the trace ids; hop-by-hop plumbing (connection, keep-alive, transfer-encoding, upgrade, te, trailer) — no diagnostic value.
%w[ content-type content-length content-encoding accept accept-charset accept-encoding accept-language user-agent referer origin host cache-control pragma if-none-match if-modified-since range via forwarded x-forwarded-for x-forwarded-proto x-forwarded-host x-forwarded-port x-real-ip x-request-id x-correlation-id authorization proxy-authorization cookie x-api-key x-auth-token ].freeze
- DEFAULT_RESPONSE_HEADERS =
Group purposes (in list order):
content-type/-length/-encoding — body metadata (mismatch debugging); accept* — content negotiation; user-agent/referer/origin/host — client + navigation context (CORS/vhost triage); cache-control..range — caching / conditional requests (304-vs-200 and partial-GET triage); via..x-real-ip — proxy/LB chain (x-forwarded-for and x-real-ip are on the 52-entry credential/PII key floor, so they arrive [REDACTED] — presence still visible); x-request-id/x-correlation-id — cross-system correlation ids; authorization..x-auth-token — the request half of the credential floor: ALWAYS [REDACTED] on the wire, captured so auth PRESENCE is visible (matches js/python). %w[ content-type content-length content-encoding content-language content-range cache-control pragma expires age etag last-modified vary location retry-after x-request-id x-correlation-id x-runtime server-timing set-cookie www-authenticate ].freeze
- RACK_INSTRUMENTATION =
The rack instrumentation class foam pre-installs (resolved lazily — same shape as init.rb's HTTP_CLIENT_INSTRUMENTATIONS: only the …::Instrumentation class responds to
.instance.install). "OpenTelemetry::Instrumentation::Rack::Instrumentation"- RACK_CONFIG_OPTS_VAR =
The gem's own standard per-instrumentation config env var (instrumentation-base config_overrides_from_env). Foam PARSES it only to stand down per option — foam never writes it, and the gem itself applies it at install (where it also overrides any passed config).
"OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS"- RACK_HEADER_OPTIONS =
The two rack header options foam defaults (everything else is the gem's own business).
{ allowed_request_headers: DEFAULT_REQUEST_HEADERS, allowed_response_headers: DEFAULT_RESPONSE_HEADERS, }.freeze
- FARADAY_SCOPE_NAME =
The official faraday instrumentation's tracer name — the scope gate FaradayMiddleware enriches through (Instrumentation::Base#install binds the tracer under the instrumentation's own name).
"OpenTelemetry::Instrumentation::Faraday"- FARADAY_MIDDLEWARE_NAME =
The public Faraday middleware-registry key foam registers under (README recipe:
f.use :foam_otel_headers). :foam_otel_headers
Class Method Summary collapse
-
.foam_owned_traces? ⇒ Boolean
---- gates shared with the Faraday middleware ------------------------ Foam's OWN pipeline only: the traces slot must still hold the provider foam registered (rule 18 B — pre-init, disabled, killed, foreign-owned and displaced-after-init all land here as false).
-
.official_recording_span(scope_name) ⇒ Object
The current span, but ONLY when it is recording AND was minted by the named official instrumentation (the scope gate): a mis-ordered middleware stack must silently capture nothing, never enrich some OTHER producer's span with header attributes.
-
.operator_rack_options ⇒ Object
The option names the operator set in OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS, parsed with the exact upstream grammar (
;-separatedname=valuepairs — instrumentation-base config_overrides_from_env). -
.preinstall_rack_defaults! ⇒ Object
---- inbound: pre-install the OFFICIAL rack gem with foam defaults -- Called from activate_instrumentations AFTER install_additional (an operator's explicit rack instance wins — precedence rule 1 above) and BEFORE install_all (which skips the already-installed gem).
-
.rack_header_defaults ⇒ Object
Foam's defaults MINUS every header option the operator named in the gem's standard env var (precedence rule 2: an operator-named option is theirs — even the narrow-to-empty
allowed_request_headers=spelling, which the gem's own env parser drops, must fall back to the GEM default [], never to foam's list). -
.register_faraday_middleware! ⇒ Object
---- outbound: Faraday wiring, PUBLIC API ONLY ---------------------- Register the middleware under its registry name so the README one-liner (
f.use :foam_otel_headers) works. -
.reset_for_tests! ⇒ Object
Test hook (mirrors Foam::Otel.reset_for_tests!).
-
.set_attr(span, key, value) ⇒ Object
Never-throw attribute write with a re-checked recording gate (the span may have ended between capture start and this write).
Class Method Details
.foam_owned_traces? ⇒ Boolean
---- gates shared with the Faraday middleware ------------------------ Foam's OWN pipeline only: the traces slot must still hold the provider foam registered (rule 18 B — pre-init, disabled, killed, foreign-owned and displaced-after-init all land here as false).
283 284 285 286 287 288 289 |
# File 'lib/foam/otel/header_capture.rb', line 283 def foam_owned_traces? registered = Foam::Otel.instance_variable_get(:@foam_registered) provider = registered && registered[:traces] !provider.nil? && OpenTelemetry.tracer_provider.equal?(provider) rescue StandardError false end |
.official_recording_span(scope_name) ⇒ Object
The current span, but ONLY when it is recording AND was minted by the named official instrumentation (the scope gate): a mis-ordered middleware stack must silently capture nothing, never enrich some OTHER producer's span with header attributes.
295 296 297 298 299 300 301 302 303 304 305 |
# File 'lib/foam/otel/header_capture.rb', line 295 def official_recording_span(scope_name) span = OpenTelemetry::Trace.current_span return nil unless span.respond_to?(:recording?) && span.recording? scope = span.respond_to?(:instrumentation_scope) ? span.instrumentation_scope : nil return nil unless scope.respond_to?(:name) && scope.name == scope_name span rescue StandardError nil end |
.operator_rack_options ⇒ Object
The option names the operator set in
OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS, parsed with the exact
upstream grammar (;-separated name=value pairs —
instrumentation-base config_overrides_from_env). Never raises.
240 241 242 243 244 245 246 247 248 249 250 |
# File 'lib/foam/otel/header_capture.rb', line 240 def raw = ENV[RACK_CONFIG_OPTS_VAR] return [] if raw.nil? || raw.strip.empty? raw.split(";").filter_map do |pair| name = pair.split("=", 2).first.to_s.strip name.to_sym unless name.empty? end rescue StandardError [] end |
.preinstall_rack_defaults! ⇒ Object
---- inbound: pre-install the OFFICIAL rack gem with foam defaults -- Called from activate_instrumentations AFTER install_additional (an operator's explicit rack instance wins — precedence rule 1 above) and BEFORE install_all (which skips the already-installed gem). Never raises; a failure warns loudly (rule 15 — header capture silently off was the 1.0.0 loop-guard bug's hiding shape).
180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/foam/otel/header_capture.rb', line 180 def preinstall_rack_defaults! klass = rack_instrumentation_class if klass.nil? # The rack instrumentation gem is not bundled (it is a hard # gemspec dependency, so this is belt-and-braces like # configure_loop_guard's NameError path). Diagnostics.info("header capture: #{RACK_INSTRUMENTATION} not present — inbound header capture skipped") return false end instance = klass.instance if instance.installed? # Precedence rule 1: an explicit operator install (usually via # additional_instrumentations) already configured the gem — foam # touches nothing, including its header options. Diagnostics.info("header capture: rack instrumentation already installed by the operator — " \ "foam's default header lists NOT applied (operator config wins)") return false end config = rack_header_defaults if config.empty? # Precedence rule 2, both options operator-set: nothing left to # default; install_all installs the gem and the gem applies the # operator's env values itself. Diagnostics.info("header capture: both rack header options set via #{RACK_CONFIG_OPTS_VAR} — " \ "operator lists win; foam defaults not applied") return false end installed = instance.install(config) unless installed # Present-check false (a non-Rack process: Sidekiq-only worker) or # OTEL_RUBY_INSTRUMENTATION_RACK_ENABLED=false — expected states, # narrated under diagnostics only (rule 10). There are no server # spans to enrich in either case. Diagnostics.info("header capture: rack instrumentation not installable here (Rack absent or " \ "disabled by env) — no inbound spans, no inbound header capture") end installed rescue StandardError => e Diagnostics.warn("header capture: rack pre-install failed (#{e.class}: #{e.}) — inbound " \ "header capture is OFF (the official rack spans still flow, without header attributes)") false end |
.rack_header_defaults ⇒ Object
Foam's defaults MINUS every header option the operator named in the
gem's standard env var (precedence rule 2: an operator-named option
is theirs — even the narrow-to-empty allowed_request_headers=
spelling, which the gem's own env parser drops, must fall back to
the GEM default [], never to foam's list).
231 232 233 234 |
# File 'lib/foam/otel/header_capture.rb', line 231 def rack_header_defaults operator = RACK_HEADER_OPTIONS.reject { |name, _| operator.include?(name) } end |
.register_faraday_middleware! ⇒ Object
---- outbound: Faraday wiring, PUBLIC API ONLY ----------------------
Register the middleware under its registry name so the README
one-liner (f.use :foam_otel_headers) works. Faraday has no public
"add to every connection" hook — the official instrumentation gets
its default-on placement by PREPENDING Faraday::Connection, which
is exactly the third-party-internals patching foam never does — so
per-connection insertion is the documented recipe, not automatic
(README "Header capture"). Idempotent; inert without Faraday.
260 261 262 263 264 265 266 267 268 269 270 271 272 |
# File 'lib/foam/otel/header_capture.rb', line 260 def register_faraday_middleware! return false if @faraday_registered return false unless defined?(::Faraday::Middleware) && ::Faraday::Middleware.respond_to?(:register_middleware) ::Faraday::Middleware.register_middleware(FARADAY_MIDDLEWARE_NAME => FaradayMiddleware) @faraday_registered = true rescue StandardError => e Diagnostics.warn("header capture: Faraday middleware registration failed " \ "(#{e.class}: #{e.}) — wire it manually with " \ "`use Foam::Otel::HeaderCapture::FaradayMiddleware` (README)") false end |
.reset_for_tests! ⇒ Object
Test hook (mirrors Foam::Otel.reset_for_tests!).
275 276 277 |
# File 'lib/foam/otel/header_capture.rb', line 275 def reset_for_tests! @faraday_registered = false end |
.set_attr(span, key, value) ⇒ Object
Never-throw attribute write with a re-checked recording gate (the span may have ended between capture start and this write).
309 310 311 312 313 314 315 316 |
# File 'lib/foam/otel/header_capture.rb', line 309 def set_attr(span, key, value) return if value.nil? span.set_attribute(key, value) if span.respond_to?(:recording?) && span.recording? nil rescue StandardError, SystemStackError nil end |