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. forms at the exporter boundary (redaction.rb floor_kind — dash/underscore-normalized, so the gem's underscore forms match), and the customer masks further names via redact_keys / redact: secrets:/pii:. The floor-listed headers are deliberately IN the default lists so their PRESENCE is visible as [REDACTED] — matching the js/python wire shape.

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

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).

Returns:

  • (Boolean)


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_optionsObject

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 operator_rack_options
  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.message}) — inbound " \
                   "header capture is OFF (the official rack spans still flow, without header attributes)")
  false
end

.rack_header_defaultsObject

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 = operator_rack_options
  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.message}) — 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