Class: Foam::Otel::PayloadCapture

Inherits:
Object
  • Object
show all
Defined in:
lib/foam/otel/payload_capture.rb

Overview

Opt-in HTTP network payload (BODY) capture behind ONE init option — capture_payloads: — DEFAULT OFF (payload-capture mandate 2026-07-28; rule 32a: one graduated addition, one module — init.rb and config.rb carry only thin wiring). Headers are NOT this module's job: they are captured upstream by the official rack gem's own header options (header_capture.rb) — this middleware tees BODIES only.

The three modes (constants.rb-style frozen set lives on Config — Foam::Otel::CAPTURE_PAYLOAD_MODES; equivalent strings accepted):

* :off    — THE DEFAULT. Zero body teeing, zero per-request
allocation: init ships NO middleware at all (the Railtie and the
init fallback both stand down), and a manually-`use`d middleware is
a one-branch passthrough.
* :errors — bodies are TEED on every request, but attributes ATTACH
to the rack server span ONLY when the request ERRORED: an exception
raised through the middleware, an exception recorded on the span
(the Errors.record_once ivar registry — read through the thin
Errors.recorded? seam, never a second dedupe), or a response status
>= 500. A clean 2xx/4xx attaches NOTHING.
* :always — attach on every request.

The operator env clamp FOAM_CAPTURE_PAYLOADS=off|errors|always OVERRIDES the init option in BOTH directions (resolved by init.rb — the FDE valve: force payloads off on a misbehaving deploy, or force them on without a code change); an invalid env value warns and falls back to the init option, never a crashed boot.

What attaches (wire names, fleet parity with the js cores):

* http.request.body / http.response.body — TEXTUAL payloads only
(text/*, JSON/+json, urlencoded forms, XML/+xml, GraphQL; skipped
when Content-Encoding is not identity), capped at
MAX_CAPTURED_BODY_CHARS with the fleet truncation marker;
* http.request.body.size / http.response.body.size — TRUE byte sizes
when known (declared Content-Length, else bytes observed);
non-textual payloads contribute sizes alone.

This middleware NEVER creates a span of its own — one producer per signal (rules 1/16): it writes to OpenTelemetry::Trace.current_span (the official rack gem's span, which surrounds this middleware) and no-ops entirely when that span is non-recording, when foam is uninitialized/disabled/killed, or when foam does not own the traces slot (rule 18 B — foam never enriches data flowing through a FOREIGN pipeline; a foreign SDK's rack span is theirs, untouched).

Bodies are TEED, never consumed: the request tee observes exactly what the APP reads from env (an app that never reads its body captures nothing — correct; Rack 3 non-rewindable inputs are never rewound or read ahead), and the response tee observes chunks in flight during #each without buffering the stream (Rack 3 to_ary/streaming-#call contracts honored — GOTCHAS F15). Every capture path is individually rescued (rule 9): recording can never break a request, alter a response, or swallow the app's own exception (identical re-raise).

Redaction is NOT this module's job: every attribute lands BEFORE span finish, so the central exporter-boundary pass masks it — a JSON-shaped body string is DEEP-masked by field name there (redaction.rb BODY_ATTRIBUTE_NAMES: the credential floor + the customer's redact lists reach inside the JSON), urlencoded/free-text bodies ride the C1 tokenizer + the value-pattern secret layer. Never a local key list here. scrub_utf8 at capture is encoding hygiene only (one invalid byte would drop the whole OTLP batch upstream — rule 15).

Defined Under Namespace

Classes: BodyAccumulator, BodyTee, InputTee

Constant Summary collapse

MAX_CAPTURED_BODY_CHARS =

Bound on captured body text so a single span cannot balloon the batch — same name and value as the js/otel and browser caps.

8192
TRUNCATION_MARKER =

Appended when the captured text was cut at the cap (fleet parity).

"…[truncated]"
CAPTURE_BYTE_CAP =

Worst-case UTF-8 is 4 bytes per character: buffering this many raw bytes always yields MAX_CAPTURED_BODY_CHARS characters when the payload has them, while the transient per-request buffer stays hard-bounded either way.

MAX_CAPTURED_BODY_CHARS * 4
TEXTUAL_MIME_TYPES =

Textual payloads only (the fleet set): text/*, JSON (+json), urlencoded forms, XML (+xml), GraphQL. Everything else — and any non-identity content-encoding — contributes sizes alone.

%w[
  application/json
  application/x-www-form-urlencoded
  application/xml
  application/graphql
].freeze
TEXTUAL_MIME_SUFFIXES =
%w[+json +xml].freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(app) ⇒ PayloadCapture

Returns a new instance of PayloadCapture.



172
173
174
# File 'lib/foam/otel/payload_capture.rb', line 172

def initialize(app)
  @app = app
end

Class Method Details

.active?Boolean

Returns:

  • (Boolean)


111
112
113
# File 'lib/foam/otel/payload_capture.rb', line 111

def active?
  mode != :off
end

.insert_into!(app) ⇒ Object

---- zero-effort Rails wiring (idempotent, never boot-breaking) ---- Mirrors the action_pack railtie's mechanism: the official rack middleware is inserted at position 0 (outermost) by that railtie's own before_initialize hook, so inserting foam AFTER index 0 places this middleware directly INSIDE the official span in every replay order (the middleware-op list is applied when the stack finalizes). Records at most one insertion per process.



122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/foam/otel/payload_capture.rb', line 122

def insert_into!(app)
  return false if @inserted

  @inserted = true
  app.middleware.insert_after(0, self)
  true
rescue StandardError => e
  Diagnostics.warn("payload capture: Rails middleware insertion failed " \
                   "(#{e.class}: #{e.message}) — body capture is OFF; " \
                   "wire it manually with `use Foam::Otel::PayloadCapture` (README)")
  false
end

.install_into_app!(app) ⇒ Object

Shared by the Railtie hook and the init-time fallback below. Installs ONLY when the resolved mode captures at all — an :off config ships ZERO middleware, the cheapest possible default — and never after the stack finalized (inserting then would be a silent no-op, so foam stands down; manual use is the documented fallback). Ownership/recording are per-request gates in #call.



141
142
143
144
145
146
147
148
149
150
# File 'lib/foam/otel/payload_capture.rb', line 141

def install_into_app!(app)
  return false unless active?
  return false if app.nil?
  return false if app.respond_to?(:initialized?) && app.initialized?

  insert_into!(app)
rescue StandardError => e
  Diagnostics.warn("payload capture: Rails wiring failed (#{e.class}: #{e.message})")
  false
end

.install_rails_middleware!Object

init-time fallback for apps whose Gemfile loads foam-otel BEFORE rails (the Railtie at the bottom of this file never got defined because ::Rails::Railtie did not exist yet): as long as init runs before Rails.application.initialize! completes — the README recipe — the middleware op still lands before the stack is built.



157
158
159
160
161
162
163
164
# File 'lib/foam/otel/payload_capture.rb', line 157

def install_rails_middleware!
  return false unless defined?(::Rails) && ::Rails.respond_to?(:application)

  install_into_app!(::Rails.application)
rescue StandardError => e
  Diagnostics.warn("payload capture: Rails wiring failed (#{e.class}: #{e.message})")
  false
end

.modeObject

The resolved capture mode: the active config's capture_payloads (init option, already clamped by FOAM_CAPTURE_PAYLOADS — init.rb). Anything unexpected degrades to :off, never a raise (rule 9).



103
104
105
106
107
108
109
# File 'lib/foam/otel/payload_capture.rb', line 103

def mode
  config = Foam::Otel.active_config
  value = config.respond_to?(:capture_payloads) ? config.capture_payloads : nil
  CAPTURE_PAYLOAD_MODES.include?(value) ? value : :off
rescue StandardError
  :off
end

.reset_for_tests!Object

Test hook (mirrors Foam::Otel.reset_for_tests!).



167
168
169
# File 'lib/foam/otel/payload_capture.rb', line 167

def reset_for_tests!
  @inserted = false
end

Instance Method Details

#call(env) ⇒ Object

Bracketed by the mode gate (an :off mode is a one-branch passthrough — zero per-request allocation) and the two capture gates (recording span + foam owns traces); past them, every capture step is individually rescued so the request NEVER breaks and the response is NEVER altered beyond the delegating body tee (rule 9).



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
# File 'lib/foam/otel/payload_capture.rb', line 181

def call(env)
  mode = self.class.mode
  return @app.call(env) if mode == :off

  span = capturable_span
  return @app.call(env) if span.nil?

  acc = attach_input_tee(env)
  begin
    response = @app.call(env)
  rescue Exception # rubocop:disable Lint/RescueException
    # The app's error re-raises IDENTICALLY — and raising through the
    # middleware IS the errored case, so BOTH attach modes flush the
    # request side first: the official rack middleware ABOVE us records
    # the exception and finishes the span only after this re-raise.
    flush_request_capture(span, env, acc)
    raise
  end
  # :errors — a clean request attaches NOTHING (the tee's transient
  # accumulator is simply dropped); errored is: exception recorded on
  # the span (the Errors.record_once ivar seam), or status >= 500.
  return response if mode == :errors && !errored?(span, response)

  flush_request_capture(span, env, acc)
  capture_response(span, env, response)
end