Class: Foam::Otel::PayloadCapture
- Inherits:
-
Object
- Object
- Foam::Otel::PayloadCapture
- 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
- .active? ⇒ Boolean
-
.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).
-
.install_into_app!(app) ⇒ Object
Shared by the Railtie hook and the init-time fallback below.
-
.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.
-
.mode ⇒ Object
The resolved capture mode: the active config's capture_payloads (init option, already clamped by FOAM_CAPTURE_PAYLOADS — init.rb).
-
.reset_for_tests! ⇒ Object
Test hook (mirrors Foam::Otel.reset_for_tests!).
Instance Method Summary collapse
-
#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).
-
#initialize(app) ⇒ PayloadCapture
constructor
A new instance of PayloadCapture.
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
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.}) — 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.})") 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.})") false end |
.mode ⇒ Object
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 |