foam-otel 1.6.0 → 1.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb5e4f91fea3170bbb206582adade6ca516824edad064a4be911ebe912ce7abf
4
- data.tar.gz: 67ee9186826b4bc31c7f51deeaf8fd91bec6aaec393099f23b000384821230b9
3
+ metadata.gz: 48166d05b120dd8bdca15a9b45db5b4c16a6574fcb6890f4caff2c17c2ae9add
4
+ data.tar.gz: 2dcd4b3b146a0221c231ba5efc057085418b74e21b24d8441396d47cfc0a8288
5
5
  SHA512:
6
- metadata.gz: 4c4c7288e2b049a9f686d0eae3637a74261820bc779d6e8ec4daf14347b6ed3b423648c4c0d226982663465907465fc350a44cd52b998ad97b4766b69ecb727b
7
- data.tar.gz: 7eb2f27f17fc9fcd9649b72c68b59493b366ff66950c86ff42f4baa414b7ea222b9e251f30264adbfe32400fe7851b2cf64505bf666c5385546b44f4a5714768
6
+ metadata.gz: 4e92c7af8cc5cf7cc177b792f87a8107b918035efdce080e2a974750180ebbb9aac4a5460cb08372f3ded2724d136804bcb8f0574ee4685ebbdd94fde7afb3e8
7
+ data.tar.gz: ddd7b6e431034663ec94dacd8b7b051ee63bfecdbeb2a38d383c58531854ba0580f34c9a04ed56473b654b89155fc9a3b7ce140b073ba346d90a5a801b9c6d4b
data/GOTCHAS.md CHANGED
@@ -721,6 +721,91 @@ exfiltratable — the value-pattern secret layer is the required second control
721
721
  no-op + registration idempotence; `spec/conventions_spec.rb` (rule 21:
722
722
  header capture never grows a `rack.input` body tee).
723
723
 
724
+ ## F15: Opt-in payload (body) capture must TEE, never consume — and the rack span's lifetime decides when body attributes can land
725
+
726
+ - **Trap**: five ways foam's opt-in body capture (`payload_capture.rb`,
727
+ behind `capture_payloads:` — default `:off`) could break the customer or
728
+ silently lose data. (1) Reading `env['rack.input']` ahead of the app —
729
+ or rewinding it — breaks Rack 3 apps: inputs are no longer required to
730
+ be rewindable, and a consumed stream starves the framework's own parser.
731
+ (2) Buffering the response to capture it breaks streaming (SSE, large
732
+ downloads), and Rack 3 `#call`-only streaming bodies must not be wrapped
733
+ at all — defining `#each` on the wrapper would change how the server
734
+ drives the response. (3) The official rack instrumentation ends its span
735
+ at DIFFERENT times per mode: the default `Rack::Events` handler finishes
736
+ it when the response body is CLOSED (`EventedBodyProxy` fires
737
+ `on_finish` after the inner body's close — so a tee flushing at close
738
+ writes in time), but the non-events `TracerMiddleware` ends it when
739
+ `@app.call` RETURNS — attributes flushed from a streamed body's close
740
+ there would hit an ENDED span (upstream logs a warning per write). (4)
741
+ An OUTER middleware (`Rack::MethodOverride`) reads the form body,
742
+ rewinds — or repositions via `seek(0)`/`pos=`, equivalent idioms on the
743
+ rewindable inputs real servers hand out — and the framework re-reads it:
744
+ a naive tee (or one that dedupes `rewind` alone) captures and counts it
745
+ twice. (5) The `:errors` mode needs "an exception was recorded on the
746
+ span" — re-implementing that check would duplicate
747
+ `Errors.record_once`'s dedupe registry and drift from it.
748
+ - **Sources**: installed source — rack `body_proxy.rb:28-35` (`close`
749
+ closes the inner body BEFORE the proxy's block) and `:47-53` (`to_ary`
750
+ auto-closes), rack `events.rb:140-142` (the body proxy that fires
751
+ `on_finish` on close); opentelemetry-instrumentation-rack-0.31.1 stable
752
+ `event_handler.rb:109-117,199-205` (`on_finish`/`detach_context` finish
753
+ the span at body close) vs stable `tracer_middleware.rb:78-90`
754
+ (`in_span` ends the span when the block — `@app.call(env).tap` —
755
+ returns); Rack SPEC (input `gets`/`read`/`each`; `to_ary` bodies;
756
+ streaming `#call` bodies); `errors.rb:17-43` (the span-carried
757
+ `@__foam_otel_recorded` ivar registry).
758
+ - **Decision & why**: delegating tees only, on BOTH directions, behind the
759
+ ONE mode switch. `:off` (the default) ships ZERO middleware (the Railtie
760
+ hook runs after `:load_config_initializers` — when init has resolved the
761
+ mode — and stands down; a manual `use` line is a one-branch
762
+ passthrough). The input tee observes exactly what the APP reads
763
+ (read/gets/each), delegates everything else with truthful `respond_to?`
764
+ (a non-rewindable input stays non-rewindable), and dedupes across EVERY
765
+ repositioning method — `rewind`, `seek`, `pos=` — with a
766
+ position/high-water scheme — bytes are captured and counted once no
767
+ matter how often an outer middleware repositions the stream. The response side
768
+ captures `to_ary`-able (buffered) bodies SYNCHRONOUSLY at middleware
769
+ return — the span is provably alive in both official modes — returning
770
+ the array as the new body (the Rack spec's sanctioned move;
771
+ `BodyProxy#to_ary` closes the original). Genuinely streaming `#each`
772
+ bodies are teed chunk-by-chunk, unbuffered, flushing attributes on
773
+ iteration-complete/close: that lands in the default `Rack::Events` mode
774
+ and is silently skipped (a `recording?` gate, no upstream warn-spam) in
775
+ `TracerMiddleware` mode — a recorded, documented loss shape, never a
776
+ broken stream. `#call`-only bodies are never wrapped (sizes ride the
777
+ declared Content-Length when present). `:errors` reads the SAME ivar
778
+ registry `Errors.record_once` stamps, through the thin
779
+ `Errors.recorded?` seam — one registry, no duplicated dedupe — plus the
780
+ status >= 500 triplet check; an exception raised through the middleware
781
+ attaches the request side before the IDENTICAL re-raise. Capture is
782
+ gated on foam OWNING the traces slot (rule 18 B — a foreign SDK's rack
783
+ span is never enriched) and on a recording span; every path is
784
+ individually rescued (rule 9). Redaction stays central: JSON-shaped body
785
+ strings are deep-masked BY FIELD NAME at the exporter boundary
786
+ (`redaction.rb` `BODY_ATTRIBUTE_NAMES` — floor + customer lists reach
787
+ inside the body JSON; a cap-truncated body no longer parses, so only the
788
+ value-shape scans cover it — documented limit).
789
+ - **Mitigation**: `lib/foam/otel/payload_capture.rb` (the whole module:
790
+ the mode gate + FOAM_CAPTURE_PAYLOADS clamp plumbing, ownership/
791
+ recording gates, tees, `to_ary`/streaming discrimination,
792
+ textual/identity classification, the 8192-char cap + fleet truncation
793
+ marker); `lib/foam/otel/init.rb` (option validation + env clamp,
794
+ mode-gated Railtie-fallback wiring); `lib/foam/otel/errors.rb`
795
+ (`recorded?`); `lib/foam/otel/redaction.rb` (`mask_body_attribute`).
796
+ - **Test**: `spec/payload_capture_spec.rb` — all three modes on the real
797
+ redacting wire path (`:off` ships zero middleware and zero attrs;
798
+ `:errors` attaches on exception AND 5xx and on the record_exception ivar
799
+ seam, NOT on 200/404; `:always` attaches on 200), env-clamp precedence
800
+ both directions + invalid-env fallback, the streaming-body proof (chunks
801
+ intact, close preserved, attributes landed), the rewind/seek/pos=
802
+ re-read dedupe, the
803
+ never-reads case, cap/true-size/truncation, binary/gzip sizes-only,
804
+ concurrent-request isolation, the foreign-provider and non-recording
805
+ no-ops, the hostile-input battery, the error-path capture, and the
806
+ JSON-body deep-redaction wire proof; `spec/conventions_spec.rb` (rule 21
807
+ as amended: `rack.input` touched ONLY by payload_capture.rb).
808
+
724
809
  ---
725
810
 
726
811
  ## General gotchas (applicable to Ruby)
data/README.md CHANGED
@@ -231,8 +231,86 @@ end
231
231
  skipped, not monkey-patched): outbound **Net::HTTP / Excon / HTTP (httprb)
232
232
  / HTTPX** headers (their official instrumentations expose no
233
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.
234
+ coverage today is Faraday. **Bodies/payloads are not captured by header
235
+ capture** bodies are the separate, opt-in `capture_payloads:` capability
236
+ below.
237
+
238
+ ### Payload (body) capture — opt-in, `capture_payloads:`
239
+
240
+ **Off by default.** One init option turns on inbound HTTP **body** capture
241
+ on the official rack SERVER span (headers stay the header-capture seam
242
+ above — this never duplicates them):
243
+
244
+ ```ruby
245
+ Foam::Otel.init(
246
+ name: "checkout-api", environment: ENV.fetch("APP_ENV"),
247
+ enabled: ENV.fetch("APP_ENV") == "production", token: ENV.fetch("FOAM_OTEL_TOKEN"),
248
+ capture_payloads: :errors # :off (default) | :errors | :always — equivalent strings accepted
249
+ )
250
+ ```
251
+
252
+ - **`:off` (default)** — zero body teeing, zero per-request allocation, and
253
+ **zero middleware shipped**: with the default, foam's Rails wiring
254
+ installs nothing at all.
255
+ - **`:errors`** — bodies are teed per request but attributes attach to the
256
+ span **only when the request errored**: `Foam::Otel.record_exception`
257
+ called while the rack server span is current, an exception raised through
258
+ the middleware, or a response status **>= 500**. A clean 2xx/4xx
259
+ attaches **nothing**.
260
+ - **`:always`** — attach on every request.
261
+
262
+ An invalid option value raises `ArgumentError` at boot. The operator env
263
+ clamp **`FOAM_CAPTURE_PAYLOADS=off|errors|always` overrides the option in
264
+ both directions** (force payloads off on a misbehaving deploy, or force
265
+ them on without a code change — one loud `[foam]` line when it changes the
266
+ mode); an invalid env value warns and falls back to the option, never a
267
+ crashed boot.
268
+
269
+ **What lands on the span** (in `:always`, or in `:errors` on an errored
270
+ request): `http.request.body` / `http.response.body` for **textual**
271
+ payloads (`text/*`, JSON/`+json`, urlencoded forms, XML/`+xml`, GraphQL;
272
+ skipped when `Content-Encoding` is not identity), capped at **8192 chars**
273
+ with an `…[truncated]` marker, plus `http.request.body.size` /
274
+ `http.response.body.size` — **true byte sizes** when known (declared
275
+ `Content-Length`, else the bytes actually observed). Non-textual payloads
276
+ contribute sizes only.
277
+
278
+ **Stream safety (GOTCHAS F15):** bodies are TEED, never consumed — foam
279
+ records exactly what your app reads from `rack.input` (an app that never
280
+ reads its body captures nothing; a re-read form body — rewound, or
281
+ repositioned via `seek`/`pos=` — is captured and counted once), and
282
+ streaming/SSE responses pass through
283
+ unbuffered chunk-by-chunk with `close` preserved. Rack 3 `#call`-only
284
+ streaming bodies are never wrapped. The request and response are never
285
+ altered, and an app exception re-raises identically.
286
+
287
+ **Redaction stays central, on export:** a JSON-shaped body is
288
+ **deep-redacted by field name** at the exporter boundary — the credential
289
+ floor (`password`, `authorization`, …) and your `redact_keys` /
290
+ `redact: {secrets:/pii:}` lists reach *inside* the body JSON; urlencoded
291
+ bodies get their `k=v` pair names masked by the same tokenizer that covers
292
+ query strings; and the value-pattern secret layer scans all captured body
293
+ text. (A body truncated at the cap no longer parses as JSON, so field-name
294
+ deep-redaction cannot apply to it — the value-shape scans still run.)
295
+
296
+ **Wiring:** in **Rails**, automatic when the mode is not `:off` (foam's
297
+ railtie inserts `Foam::Otel::PayloadCapture` directly inside the official
298
+ rack middleware after your initializers run; when foam-otel loads before
299
+ rails, `init` wires it instead, as long as init runs during boot — the
300
+ standard `config/initializers/foam.rb` recipe). **Plain Rack / Sinatra**
301
+ apps add one line under the official middleware:
302
+
303
+ ```ruby
304
+ # config.ru
305
+ use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args)
306
+ use Foam::Otel::PayloadCapture # opt-in body capture (inert while capture_payloads is :off)
307
+ run MyApp
308
+ ```
309
+
310
+ The middleware enriches only: it never creates a span, and it stands down
311
+ per request when foam is disabled/killed, when the span is not recording,
312
+ or when a foreign SDK owns the traces slot. Outbound (client) bodies are
313
+ not captured — inbound only.
236
314
 
237
315
  ---
238
316
 
@@ -253,6 +331,7 @@ separate, future capability, not part of header capture.
253
331
  | `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
254
332
  | `additional_instrumentations:` | Array | no | `[]` | Constructed tier-2 instrumentation instances to register (fault-isolated: one that throws is skipped with a `[foam]` warning). |
255
333
  | `ignored_outbound_hosts:` | Array<String> | no | `[]` | Hosts whose outbound calls produce no spans — EXTENDS the built-in export-loop guard. For a co-resident agent's intake or a tenant exporter's endpoint. Applies to the clients whose official instrumentation supports host suppression: **Net::HTTP and Excon**. Faraday/HTTP (httprb)/HTTPX have no upstream `untraced_hosts` option — foam warns loudly at init when one of those is bundled (GOTCHAS G13); foam's own export loop is guarded for every client regardless. |
334
+ | `capture_payloads:` | Symbol/String | no | `:off` | Inbound HTTP **body** capture on the rack server span (see "Payload (body) capture"). `:off` = zero teeing, zero middleware (the default); `:errors` = tee per request, attach only on exception/5xx; `:always` = attach on every request. Equivalent strings accepted; anything else raises at boot. Overridden in both directions by the `FOAM_CAPTURE_PAYLOADS` env clamp (env table below). Textual payloads only, 8192-char cap + `…[truncated]`, true `.size` attributes; sizes-only for binary/compressed. |
256
335
  | `diagnostics:` | Boolean | no | false | Verbose `[foam]` self-reporting of init/health. Warnings and errors are always loud regardless. |
257
336
  | `secret_heuristics:` | Boolean | no | true | The value-pattern secret layer's HEURISTIC tier (generic keyword+entropy detection, see "The value-pattern secret layer"). `false` disables ONLY the heuristics — the named provider patterns, the credential floor and the redaction-coverage contract have no off switch. Disabling logs one loud `[foam]` line at init (an explicit, audited decision for telemetry whose legitimate values collide with the heuristics). |
258
337
 
@@ -926,6 +1005,7 @@ end
926
1005
  | `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). |
927
1006
  | `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). |
928
1007
  | `OTEL_BSP_SCHEDULE_DELAY` / `OTEL_BLRP_SCHEDULE_DELAY` / `OTEL_METRIC_EXPORT_INTERVAL` | HONORED — batch cadence, read natively by the upstream SDK. |
1008
+ | `FOAM_CAPTURE_PAYLOADS` | HONORED — the operator clamp over the `capture_payloads:` init option (`off`/`errors`/`always`, case-insensitive), overriding it in BOTH directions with one loud `[foam]` line when it changes the mode. An invalid value warns and falls back to the init option (never crashes a boot). This is the ONE foam-named env var the gem reads — an override valve over an init-declared option, never an on/off switch, token, or config fallback (those still arrive only through `init`'s explicit arguments). Read once at init; changing it implies a restart. |
929
1009
  | `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). |
930
1010
  | `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. |
931
1011
  | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | INERT — as above (warns when set). |
@@ -12,7 +12,7 @@ module Foam
12
12
  Config = Struct.new(
13
13
  :name, :environment, :version, :enabled,
14
14
  :redact_keys, :redact_pii_keys, :redact_detect, :ignored_outbound_hosts,
15
- :diagnostics, :endpoint, :secret_heuristics,
15
+ :diagnostics, :endpoint, :secret_heuristics, :capture_payloads,
16
16
  keyword_init: true
17
17
  )
18
18
 
@@ -22,6 +22,14 @@ module Foam
22
22
  # opts into the PII detection tier. Anything else raises at boot.
23
23
  REDACT_OPTION_FIELDS = %w[secrets pii detect].freeze
24
24
 
25
+ # The exact modes the `capture_payloads:` init option accepts
26
+ # (payload-capture mandate 2026-07-28; equivalent strings — trimmed,
27
+ # case-insensitive — are accepted and canonicalized to these symbols).
28
+ # :off is the default: zero body teeing, zero per-request allocation, no
29
+ # middleware shipped. The FOAM_CAPTURE_PAYLOADS operator env clamp
30
+ # (init.rb) accepts the same spellings and overrides the option.
31
+ CAPTURE_PAYLOAD_MODES = %i[off errors always].freeze
32
+
25
33
  class << self
26
34
  # The inert config the helpers read before init() runs (everything a
27
35
  # no-op needs: empty redaction lists, export disabled).
@@ -32,7 +40,7 @@ module Foam
32
40
  redact_detect: [].freeze,
33
41
  ignored_outbound_hosts: [].freeze,
34
42
  diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT,
35
- secret_heuristics: true
43
+ secret_heuristics: true, capture_payloads: :off
36
44
  ).freeze
37
45
  end
38
46
 
@@ -44,8 +52,13 @@ module Foam
44
52
 
45
53
  def resolve_config(name:, environment:, version:, enabled:,
46
54
  redact_keys:, redact_pii_keys:, ignored_outbound_hosts:,
47
- diagnostics:, endpoint:, secret_heuristics: true, redact: nil)
55
+ diagnostics:, endpoint:, secret_heuristics: true, redact: nil,
56
+ capture_payloads: :off)
48
57
  keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
58
+ # capture_payloads is validated LOUDLY at boot (rule 10, exactly like
59
+ # the redact: object): a value outside CAPTURE_PAYLOAD_MODES (or its
60
+ # string spellings) raises here — never a silent :off.
61
+ capture_mode = validate_capture_payloads!(capture_payloads)
49
62
  Config.new(
50
63
  name: name,
51
64
  environment: environment,
@@ -71,12 +84,44 @@ module Foam
71
84
  # module-constant machinery no config shape can narrow. Anything
72
85
  # but literal false means ON (default-on preserves the fleet's
73
86
  # no-leakage bar; disabling is an explicit, audited decision).
74
- secret_heuristics: secret_heuristics == false ? false : true
87
+ secret_heuristics: secret_heuristics == false ? false : true,
88
+ # The payload-capture mode (2026-07-28 mandate): :off (the default
89
+ # — zero teeing, zero middleware) | :errors | :always, canonical
90
+ # symbol form. init.rb applies the FOAM_CAPTURE_PAYLOADS operator
91
+ # env clamp BEFORE this resolves.
92
+ capture_payloads: capture_mode
75
93
  ).freeze
76
94
  end
77
95
 
78
96
  private
79
97
 
98
+ # ---- the capture_payloads option (payload-capture mandate 2026-07-28)
99
+ # Canonicalize a mode value: the three symbols, or their string
100
+ # spellings trimmed + case-insensitive, map to the canonical symbol;
101
+ # anything else is nil (the callers decide raise-vs-fallback).
102
+ def normalize_capture_payloads(value)
103
+ return value if CAPTURE_PAYLOAD_MODES.include?(value)
104
+ return nil unless value.is_a?(String) || value.is_a?(Symbol)
105
+
106
+ mode = value.to_s.strip.downcase.to_sym
107
+ CAPTURE_PAYLOAD_MODES.include?(mode) ? mode : nil
108
+ rescue StandardError
109
+ nil
110
+ end
111
+
112
+ # The loud-at-boot door (rule 10): an invalid INIT value is a
113
+ # programming error the engineer must catch on their machine.
114
+ # (The env clamp's invalid values warn + fall back instead — init.rb.)
115
+ def validate_capture_payloads!(value, context: "Foam::Otel.init")
116
+ mode = normalize_capture_payloads(value)
117
+ if mode.nil?
118
+ raise ArgumentError, "#{context} capture_payloads: must be one of " \
119
+ "#{CAPTURE_PAYLOAD_MODES.map(&:inspect).join(', ')} " \
120
+ "(equivalent strings accepted), got #{value.inspect}"
121
+ end
122
+ mode
123
+ end
124
+
80
125
  def downcase_list(list)
81
126
  Array(list).map { |k| k.to_s.downcase }.reject(&:empty?).uniq.freeze
82
127
  end
@@ -162,7 +207,12 @@ module Foam
162
207
  ignored_outbound_hosts: existing.ignored_outbound_hosts,
163
208
  diagnostics: diagnostics ? true : false,
164
209
  endpoint: existing.endpoint,
165
- secret_heuristics: secret_heuristics == false ? false : true
210
+ secret_heuristics: secret_heuristics == false ? false : true,
211
+ # Like enabled/endpoint, capture_payloads is process-global after
212
+ # the first init: middleware insertion already happened (or
213
+ # deliberately did not — an :off boot shipped none), so a second
214
+ # init cannot meaningfully flip it. Carried forward unchanged.
215
+ capture_payloads: existing.capture_payloads
166
216
  ).freeze
167
217
  end
168
218
  end
@@ -41,6 +41,18 @@ module Foam
41
41
  # exception" into a host-thread crash (rule 9).
42
42
  false
43
43
  end
44
+
45
+ # Read-only view of the registry above (the payload-capture :errors
46
+ # mode's thin seam, mandate 2026-07-28): true when at least one
47
+ # exception was recorded on this span via record_once. Reads the SAME
48
+ # span-carried ivar — never a second registry, never duplicated dedupe
49
+ # logic. Never raises (rule 9).
50
+ def recorded?(span)
51
+ seen = span.instance_variable_get(IVAR)
52
+ seen.is_a?(Array) && !seen.empty?
53
+ rescue StandardError
54
+ false
55
+ end
44
56
  end
45
57
  end
46
58
  end
@@ -19,6 +19,7 @@ require_relative "logger_bridge"
19
19
  require_relative "runtime_metrics"
20
20
  require_relative "llm"
21
21
  require_relative "header_capture" # default-on header capture (rule 32a module; wired below)
22
+ require_relative "payload_capture" # opt-in body capture behind capture_payloads: (rule 32a module; wired below)
22
23
 
23
24
  module Foam
24
25
  module Otel
@@ -57,7 +58,8 @@ module Foam
57
58
  additional_metric_readers: nil,
58
59
  ignored_outbound_hosts: nil,
59
60
  diagnostics: false,
60
- secret_heuristics: true)
61
+ secret_heuristics: true,
62
+ capture_payloads: :off)
61
63
  # Identity is validated at boot (rule 10): a blank required value is a
62
64
  # programming error the engineer must catch on their machine.
63
65
  validate_present!(:name, name)
@@ -88,7 +90,13 @@ module Foam
88
90
  redact_keys: redact_keys, redact_pii_keys: redact_pii_keys, redact: redact,
89
91
  ignored_outbound_hosts: ignored_outbound_hosts,
90
92
  diagnostics: diagnostics, endpoint: endpoint,
91
- secret_heuristics: secret_heuristics
93
+ secret_heuristics: secret_heuristics,
94
+ # The payload-capture mode: the init option validated loudly (an
95
+ # invalid option raises inside resolve_config), with the
96
+ # FOAM_CAPTURE_PAYLOADS operator env clamp applied first (a valid
97
+ # env value overrides the option in BOTH directions; an invalid
98
+ # one warns + falls back — never a crashed boot).
99
+ capture_payloads: resolve_capture_payloads(capture_payloads)
92
100
  )
93
101
  # The heuristic-tier opt-out is an explicit, audited customer decision
94
102
  # (security-fixes-design §V.8) — always one loud line, never silent.
@@ -212,10 +220,38 @@ module Foam
212
220
  RuntimeMetrics.reset_for_tests!
213
221
  Redaction.reset_secret_scan_warnings!
214
222
  HeaderCapture.reset_for_tests!
223
+ PayloadCapture.reset_for_tests!
215
224
  end
216
225
 
217
226
  private
218
227
 
228
+ # The capture_payloads mode init hands to resolve_config: the INIT
229
+ # OPTION validated loudly first (rule 10 — garbage in code raises at
230
+ # boot even when an env clamp would override it), then the
231
+ # FOAM_CAPTURE_PAYLOADS operator env clamp applied on top. A VALID env
232
+ # value OVERRIDES the option in BOTH directions (the FDE valve: force
233
+ # payloads :off on a misbehaving deploy, or force :errors/:always on
234
+ # without a code change), with one loud line naming the override; an
235
+ # INVALID env value warns and falls back to the option — an env typo
236
+ # must never crash a boot (rule 9).
237
+ def resolve_capture_payloads(option)
238
+ mode = validate_capture_payloads!(option)
239
+ raw = ENV["FOAM_CAPTURE_PAYLOADS"]
240
+ return mode if raw.nil? || raw.strip.empty?
241
+
242
+ env_mode = normalize_capture_payloads(raw)
243
+ if env_mode.nil?
244
+ Diagnostics.warn("FOAM_CAPTURE_PAYLOADS=#{raw.inspect} is invalid — expected off|errors|always; " \
245
+ "falling back to the init option (capture_payloads: :#{mode})")
246
+ return mode
247
+ end
248
+ if env_mode != mode
249
+ Diagnostics.warn("FOAM_CAPTURE_PAYLOADS=#{env_mode} overrides capture_payloads: :#{mode} " \
250
+ "(operator env clamp)")
251
+ end
252
+ env_mode
253
+ end
254
+
219
255
  # Classifies the slot IMMEDIATELY before registering into it (the
220
256
  # narrowest pre-read→set window Ruby's non-atomic globals allow —
221
257
  # GOTCHAS F8), registers into a FREE slot, and warns once (honestly)
@@ -608,6 +644,20 @@ module Foam
608
644
  Diagnostics.warn("header capture: Faraday registration failed: #{e.class}: #{e.message}")
609
645
  end
610
646
 
647
+ begin
648
+ # Rails fallback wiring for the opt-in payload-capture middleware:
649
+ # covers apps whose Gemfile loads foam-otel BEFORE rails (no
650
+ # Railtie was defined at require time). Idempotent — the Railtie
651
+ # path, when it runs, shares the same one-insertion record. Gated
652
+ # on foam owning traces (rule 18 B — this branch), on the resolved
653
+ # mode not being :off (an :off config ships ZERO middleware), and
654
+ # per request on ownership + a recording span inside the
655
+ # middleware itself.
656
+ PayloadCapture.install_rails_middleware!
657
+ rescue StandardError => e
658
+ Diagnostics.warn("payload capture activation failed: #{e.class}: #{e.message}")
659
+ end
660
+
611
661
  begin
612
662
  LLM.activate!
613
663
  rescue StandardError => e
@@ -0,0 +1,655 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "opentelemetry"
4
+
5
+ require_relative "diagnostics"
6
+ require_relative "errors"
7
+ require_relative "redaction"
8
+
9
+ module Foam
10
+ module Otel
11
+ # Opt-in HTTP network payload (BODY) capture behind ONE init option —
12
+ # `capture_payloads:` — DEFAULT OFF (payload-capture mandate 2026-07-28;
13
+ # rule 32a: one graduated addition, one module — init.rb and config.rb
14
+ # carry only thin wiring). Headers are NOT this module's job: they are
15
+ # captured upstream by the official rack gem's own header options
16
+ # (header_capture.rb) — this middleware tees BODIES only.
17
+ #
18
+ # The three modes (constants.rb-style frozen set lives on Config —
19
+ # Foam::Otel::CAPTURE_PAYLOAD_MODES; equivalent strings accepted):
20
+ #
21
+ # * :off — THE DEFAULT. Zero body teeing, zero per-request
22
+ # allocation: init ships NO middleware at all (the Railtie and the
23
+ # init fallback both stand down), and a manually-`use`d middleware is
24
+ # a one-branch passthrough.
25
+ # * :errors — bodies are TEED on every request, but attributes ATTACH
26
+ # to the rack server span ONLY when the request ERRORED: an exception
27
+ # raised through the middleware, an exception recorded on the span
28
+ # (the Errors.record_once ivar registry — read through the thin
29
+ # Errors.recorded? seam, never a second dedupe), or a response status
30
+ # >= 500. A clean 2xx/4xx attaches NOTHING.
31
+ # * :always — attach on every request.
32
+ #
33
+ # The operator env clamp FOAM_CAPTURE_PAYLOADS=off|errors|always
34
+ # OVERRIDES the init option in BOTH directions (resolved by init.rb —
35
+ # the FDE valve: force payloads off on a misbehaving deploy, or force
36
+ # them on without a code change); an invalid env value warns and falls
37
+ # back to the init option, never a crashed boot.
38
+ #
39
+ # What attaches (wire names, fleet parity with the js cores):
40
+ #
41
+ # * http.request.body / http.response.body — TEXTUAL payloads only
42
+ # (text/*, JSON/+json, urlencoded forms, XML/+xml, GraphQL; skipped
43
+ # when Content-Encoding is not identity), capped at
44
+ # MAX_CAPTURED_BODY_CHARS with the fleet truncation marker;
45
+ # * http.request.body.size / http.response.body.size — TRUE byte sizes
46
+ # when known (declared Content-Length, else bytes observed);
47
+ # non-textual payloads contribute sizes alone.
48
+ #
49
+ # This middleware NEVER creates a span of its own — one producer per
50
+ # signal (rules 1/16): it writes to OpenTelemetry::Trace.current_span
51
+ # (the official rack gem's span, which surrounds this middleware) and
52
+ # no-ops entirely when that span is non-recording, when foam is
53
+ # uninitialized/disabled/killed, or when foam does not own the traces
54
+ # slot (rule 18 B — foam never enriches data flowing through a FOREIGN
55
+ # pipeline; a foreign SDK's rack span is theirs, untouched).
56
+ #
57
+ # Bodies are TEED, never consumed: the request tee observes exactly what
58
+ # the APP reads from env['rack.input'] (an app that never reads its body
59
+ # captures nothing — correct; Rack 3 non-rewindable inputs are never
60
+ # rewound or read ahead), and the response tee observes chunks in flight
61
+ # during #each without buffering the stream (Rack 3 to_ary/streaming-#call
62
+ # contracts honored — GOTCHAS F15). Every capture path is individually
63
+ # rescued (rule 9): recording can never break a request, alter a
64
+ # response, or swallow the app's own exception (identical re-raise).
65
+ #
66
+ # Redaction is NOT this module's job: every attribute lands BEFORE span
67
+ # finish, so the central exporter-boundary pass masks it — a JSON-shaped
68
+ # body string is DEEP-masked by field name there (redaction.rb
69
+ # BODY_ATTRIBUTE_NAMES: the credential floor + the customer's redact
70
+ # lists reach inside the JSON), urlencoded/free-text bodies ride the C1
71
+ # tokenizer + the value-pattern secret layer. Never a local key list
72
+ # here. scrub_utf8 at capture is encoding hygiene only (one invalid byte
73
+ # would drop the whole OTLP batch upstream — rule 15).
74
+ class PayloadCapture
75
+ # Bound on captured body text so a single span cannot balloon the
76
+ # batch — same name and value as the js/otel and browser caps.
77
+ MAX_CAPTURED_BODY_CHARS = 8192
78
+
79
+ # Appended when the captured text was cut at the cap (fleet parity).
80
+ TRUNCATION_MARKER = "…[truncated]"
81
+
82
+ # Worst-case UTF-8 is 4 bytes per character: buffering this many raw
83
+ # bytes always yields MAX_CAPTURED_BODY_CHARS characters when the
84
+ # payload has them, while the transient per-request buffer stays
85
+ # hard-bounded either way.
86
+ CAPTURE_BYTE_CAP = MAX_CAPTURED_BODY_CHARS * 4
87
+
88
+ # Textual payloads only (the fleet set): text/*, JSON (+json),
89
+ # urlencoded forms, XML (+xml), GraphQL. Everything else — and any
90
+ # non-identity content-encoding — contributes sizes alone.
91
+ TEXTUAL_MIME_TYPES = %w[
92
+ application/json
93
+ application/x-www-form-urlencoded
94
+ application/xml
95
+ application/graphql
96
+ ].freeze
97
+ TEXTUAL_MIME_SUFFIXES = %w[+json +xml].freeze
98
+
99
+ class << self
100
+ # The resolved capture mode: the active config's capture_payloads
101
+ # (init option, already clamped by FOAM_CAPTURE_PAYLOADS — init.rb).
102
+ # Anything unexpected degrades to :off, never a raise (rule 9).
103
+ def mode
104
+ config = Foam::Otel.active_config
105
+ value = config.respond_to?(:capture_payloads) ? config.capture_payloads : nil
106
+ CAPTURE_PAYLOAD_MODES.include?(value) ? value : :off
107
+ rescue StandardError
108
+ :off
109
+ end
110
+
111
+ def active?
112
+ mode != :off
113
+ end
114
+
115
+ # ---- zero-effort Rails wiring (idempotent, never boot-breaking) ----
116
+ # Mirrors the action_pack railtie's mechanism: the official rack
117
+ # middleware is inserted at position 0 (outermost) by that railtie's
118
+ # own before_initialize hook, so inserting foam AFTER index 0 places
119
+ # this middleware directly INSIDE the official span in every replay
120
+ # order (the middleware-op list is applied when the stack finalizes).
121
+ # Records at most one insertion per process.
122
+ def insert_into!(app)
123
+ return false if @inserted
124
+
125
+ @inserted = true
126
+ app.middleware.insert_after(0, self)
127
+ true
128
+ rescue StandardError => e
129
+ Diagnostics.warn("payload capture: Rails middleware insertion failed " \
130
+ "(#{e.class}: #{e.message}) — body capture is OFF; " \
131
+ "wire it manually with `use Foam::Otel::PayloadCapture` (README)")
132
+ false
133
+ end
134
+
135
+ # Shared by the Railtie hook and the init-time fallback below.
136
+ # Installs ONLY when the resolved mode captures at all — an :off
137
+ # config ships ZERO middleware, the cheapest possible default — and
138
+ # never after the stack finalized (inserting then would be a silent
139
+ # no-op, so foam stands down; manual `use` is the documented
140
+ # fallback). Ownership/recording are per-request gates in #call.
141
+ def install_into_app!(app)
142
+ return false unless active?
143
+ return false if app.nil?
144
+ return false if app.respond_to?(:initialized?) && app.initialized?
145
+
146
+ insert_into!(app)
147
+ rescue StandardError => e
148
+ Diagnostics.warn("payload capture: Rails wiring failed (#{e.class}: #{e.message})")
149
+ false
150
+ end
151
+
152
+ # init-time fallback for apps whose Gemfile loads foam-otel BEFORE
153
+ # rails (the Railtie at the bottom of this file never got defined
154
+ # because ::Rails::Railtie did not exist yet): as long as init runs
155
+ # before Rails.application.initialize! completes — the README recipe
156
+ # — the middleware op still lands before the stack is built.
157
+ def install_rails_middleware!
158
+ return false unless defined?(::Rails) && ::Rails.respond_to?(:application)
159
+
160
+ install_into_app!(::Rails.application)
161
+ rescue StandardError => e
162
+ Diagnostics.warn("payload capture: Rails wiring failed (#{e.class}: #{e.message})")
163
+ false
164
+ end
165
+
166
+ # Test hook (mirrors Foam::Otel.reset_for_tests!).
167
+ def reset_for_tests!
168
+ @inserted = false
169
+ end
170
+ end
171
+
172
+ def initialize(app)
173
+ @app = app
174
+ end
175
+
176
+ # Bracketed by the mode gate (an :off mode is a one-branch
177
+ # passthrough — zero per-request allocation) and the two capture gates
178
+ # (recording span + foam owns traces); past them, every capture step is
179
+ # individually rescued so the request NEVER breaks and the response is
180
+ # NEVER altered beyond the delegating body tee (rule 9).
181
+ def call(env)
182
+ mode = self.class.mode
183
+ return @app.call(env) if mode == :off
184
+
185
+ span = capturable_span
186
+ return @app.call(env) if span.nil?
187
+
188
+ acc = attach_input_tee(env)
189
+ begin
190
+ response = @app.call(env)
191
+ rescue Exception # rubocop:disable Lint/RescueException
192
+ # The app's error re-raises IDENTICALLY — and raising through the
193
+ # middleware IS the errored case, so BOTH attach modes flush the
194
+ # request side first: the official rack middleware ABOVE us records
195
+ # the exception and finishes the span only after this re-raise.
196
+ flush_request_capture(span, env, acc)
197
+ raise
198
+ end
199
+ # :errors — a clean request attaches NOTHING (the tee's transient
200
+ # accumulator is simply dropped); errored is: exception recorded on
201
+ # the span (the Errors.record_once ivar seam), or status >= 500.
202
+ return response if mode == :errors && !errored?(span, response)
203
+
204
+ flush_request_capture(span, env, acc)
205
+ capture_response(span, env, response)
206
+ end
207
+
208
+ private
209
+
210
+ # The official rack gem's span, and only when it is foam's OWN to
211
+ # enrich: a recording current span AND the traces slot still holding
212
+ # the provider foam registered (rule 18 B — pre-init, disabled, killed,
213
+ # foreign-owned and displaced-after-init all land here as nil).
214
+ def capturable_span
215
+ registered = Foam::Otel.instance_variable_get(:@foam_registered)
216
+ provider = registered && registered[:traces]
217
+ return nil if provider.nil? || !OpenTelemetry.tracer_provider.equal?(provider)
218
+
219
+ span = OpenTelemetry::Trace.current_span
220
+ span.respond_to?(:recording?) && span.recording? ? span : nil
221
+ rescue StandardError
222
+ nil
223
+ end
224
+
225
+ # The :errors attach predicate, decided when the middleware sees the
226
+ # triplet: an exception was recorded on the span (foam's record_once
227
+ # ivar registry, read through the thin Errors.recorded? seam — never a
228
+ # second dedupe here), or the response status is a server error.
229
+ def errored?(span, response)
230
+ return true if Errors.recorded?(span)
231
+
232
+ response.is_a?(Array) && response.length == 3 && response[0].to_i >= 500
233
+ rescue StandardError
234
+ false
235
+ end
236
+
237
+ # ---- request side ------------------------------------------------------
238
+
239
+ # Wrap env['rack.input'] in the delegating tee. NEVER reads ahead and
240
+ # never rewinds — it observes exactly the bytes the app itself pulls.
241
+ # A binary/compressed payload WITH a declared size has nothing left to
242
+ # learn, so the stream is not touched at all (sizes ride the declared
243
+ # Content-Length at flush).
244
+ def attach_input_tee(env)
245
+ input = env["rack.input"]
246
+ return nil if input.nil?
247
+
248
+ textual = textual_content_type?(env["CONTENT_TYPE"]) &&
249
+ identity_encoding?(env["HTTP_CONTENT_ENCODING"])
250
+ return nil if !textual && content_length_from(env["CONTENT_LENGTH"])
251
+
252
+ acc = BodyAccumulator.new(capture_text: textual)
253
+ env["rack.input"] = InputTee.new(input, acc)
254
+ acc
255
+ rescue StandardError
256
+ nil
257
+ end
258
+
259
+ # After the app ran (or raised), on the attach path only: the captured
260
+ # text, plus the true size — the declared Content-Length when present,
261
+ # else the high-water byte count of what the app actually read.
262
+ def flush_request_capture(span, env, acc)
263
+ declared = content_length_from(env["CONTENT_LENGTH"])
264
+ size = declared
265
+ size = acc.bytes if size.nil? && acc && acc.saw_data?
266
+ set_attr(span, "http.request.body.size", size) unless size.nil? || size.zero?
267
+ return if acc.nil? || !acc.saw_data?
268
+
269
+ text = acc.snapshot
270
+ set_attr(span, "http.request.body", text) if text
271
+ rescue StandardError
272
+ nil
273
+ end
274
+
275
+ # ---- response side -----------------------------------------------------
276
+
277
+ # Only a well-formed [status, headers, body] triplet is touched; any
278
+ # other shape (or any internal failure) returns the app's response
279
+ # VERBATIM (rule 9: never alter the response). A BODILESS exchange —
280
+ # a HEAD request (Rack::Head strips the body but keeps the entity's
281
+ # Content-Length), a 204, a 304, a 1xx — attaches NO response body
282
+ # attributes at all: a declared Content-Length there describes an
283
+ # entity that is NOT on the wire, and capturing it would fabricate a
284
+ # body the response never had.
285
+ def capture_response(span, env, response)
286
+ return response unless response.is_a?(Array) && response.length == 3
287
+
288
+ status, headers, body = response
289
+ return response if bodiless?(env, status)
290
+
291
+ [status, headers, capture_response_body(span, headers, body)]
292
+ rescue StandardError
293
+ response
294
+ end
295
+
296
+ def bodiless?(env, status)
297
+ return true if env.is_a?(Hash) && env["REQUEST_METHOD"].to_s.upcase == "HEAD"
298
+
299
+ code = status.to_i
300
+ code == 204 || code == 304 || (code >= 100 && code < 200)
301
+ rescue StandardError
302
+ false
303
+ end
304
+
305
+ # Body strategy, in Rack-3 contract order (GOTCHAS F15):
306
+ # * to_ary-able (buffered) bodies are captured SYNCHRONOUSLY — the
307
+ # span is provably alive in both official middleware modes — and
308
+ # the array is returned as the new body (BodyProxy#to_ary closes
309
+ # the original per the Rack spec);
310
+ # * streaming #call-only bodies (Rack 3 hijack-style) are NEVER
311
+ # wrapped — defining #each on them would change how the server
312
+ # drives the response;
313
+ # * everything else is teed chunk-by-chunk during #each, unbuffered,
314
+ # with the attributes flushed when iteration completes or on close.
315
+ def capture_response_body(span, headers, body)
316
+ declared = content_length_from(header_value(headers, "content-length"))
317
+ # Zero-guard parity with the request side: an empty body never
318
+ # contributes a size-0 attribute.
319
+ set_attr(span, "http.response.body.size", declared) if declared&.positive?
320
+ textual = textual_content_type?(header_value(headers, "content-type")) &&
321
+ identity_encoding?(header_value(headers, "content-encoding"))
322
+
323
+ return capture_buffered_body(span, body, textual, declared) if body.respond_to?(:to_ary)
324
+ return body unless body.respond_to?(:each)
325
+ return body if !textual && declared # nothing left to learn — leave it alone
326
+
327
+ acc = BodyAccumulator.new(capture_text: textual)
328
+ BodyTee.new(body, acc) do |completed|
329
+ flush_response_body(span, acc, declared, completed)
330
+ end
331
+ rescue StandardError
332
+ body
333
+ end
334
+
335
+ def capture_buffered_body(span, body, textual, declared)
336
+ parts = body.to_ary # the full body; a BodyProxy's to_ary also closes it (Rack spec)
337
+ return body unless parts.is_a?(Array)
338
+
339
+ if textual || !declared
340
+ acc = BodyAccumulator.new(capture_text: textual)
341
+ parts.each { |chunk| acc.observe(chunk) }
342
+ flush_response_body(span, acc, declared, true)
343
+ end
344
+ parts
345
+ rescue StandardError
346
+ body
347
+ end
348
+
349
+ # `completed` guards the size: a stream closed mid-flight has an
350
+ # UNKNOWN true size (the declared Content-Length, when present, was
351
+ # already set up front). set_attr's recording? gate makes this a silent
352
+ # no-op when the span already ended (the TracerMiddleware streaming
353
+ # divergence — GOTCHAS F15).
354
+ def flush_response_body(span, acc, declared, completed)
355
+ return unless acc.saw_data?
356
+
357
+ text = acc.snapshot
358
+ set_attr(span, "http.response.body", text) if text
359
+ set_attr(span, "http.response.body.size", acc.bytes) if !declared && completed
360
+ rescue StandardError
361
+ nil
362
+ end
363
+
364
+ # ---- shared helpers ------------------------------------------------------
365
+
366
+ def set_attr(span, key, value)
367
+ return if value.nil?
368
+
369
+ span.set_attribute(key, value) if span.respond_to?(:recording?) && span.recording?
370
+ nil
371
+ rescue StandardError, SystemStackError
372
+ nil
373
+ end
374
+
375
+ def header_value(headers, want)
376
+ return nil unless headers.respond_to?(:each)
377
+
378
+ headers.each do |key, value|
379
+ next unless key.to_s.downcase == want
380
+
381
+ return value.is_a?(Array) ? value.first : value
382
+ end
383
+ nil
384
+ rescue StandardError
385
+ nil
386
+ end
387
+
388
+ def content_length_from(raw)
389
+ return nil if raw.nil?
390
+
391
+ parsed = Integer(raw.to_s, 10)
392
+ parsed >= 0 ? parsed : nil
393
+ rescue StandardError
394
+ nil
395
+ end
396
+
397
+ def textual_content_type?(raw)
398
+ return false if raw.nil?
399
+
400
+ mime = raw.to_s.split(";").first.to_s.strip.downcase
401
+ return false if mime.empty?
402
+ return true if mime.start_with?("text/")
403
+ return true if TEXTUAL_MIME_TYPES.include?(mime)
404
+
405
+ TEXTUAL_MIME_SUFFIXES.any? { |suffix| mime.end_with?(suffix) }
406
+ rescue StandardError
407
+ false
408
+ end
409
+
410
+ def identity_encoding?(raw)
411
+ return true if raw.nil?
412
+
413
+ encoding = raw.to_s.strip.downcase
414
+ encoding.empty? || encoding == "identity"
415
+ rescue StandardError
416
+ false
417
+ end
418
+
419
+ # Bounded, defensive accumulation shared by both directions. Tracks a
420
+ # stream POSITION and a HIGH-WATER mark so a reposition + re-read (the
421
+ # Rack::MethodOverride shape: an outer middleware reads the form body,
422
+ # rewinds, and the framework re-reads it) never double-captures or
423
+ # double-counts — only bytes beyond the high-water mark are new. The
424
+ # raw buffer is binary and hard-capped; text is scrubbed to valid
425
+ # UTF-8 and cap-cut only at snapshot time.
426
+ class BodyAccumulator
427
+ def initialize(capture_text:)
428
+ @buffer = capture_text ? String.new(encoding: Encoding::BINARY) : nil
429
+ @pos = 0
430
+ @high_water = 0
431
+ end
432
+
433
+ def observe(chunk)
434
+ return unless chunk.is_a?(String)
435
+
436
+ size = chunk.bytesize
437
+ return if size.zero?
438
+
439
+ new_pos = @pos + size
440
+ if new_pos > @high_water
441
+ # Never more than the chunk itself: a forward seek past the
442
+ # high-water mark makes the positional gap look "fresh", but the
443
+ # skipped bytes were never observed — only the chunk's own tail
444
+ # is appendable (the high-water mark still advances to the true
445
+ # stream position, so re-reads after it stay deduped).
446
+ fresh = [new_pos - @high_water, size].min
447
+ append(chunk.byteslice(size - fresh, fresh))
448
+ @high_water = new_pos
449
+ end
450
+ @pos = new_pos
451
+ nil
452
+ rescue StandardError, SystemStackError
453
+ nil
454
+ end
455
+
456
+ # A reposition on the underlying stream (rewind / seek / pos=): track
457
+ # the new position so a re-read never double-captures or
458
+ # double-counts. Anything non-sensical degrades to 0 — the
459
+ # rewind-equivalent — which can only UNDER-position (bytes below the
460
+ # high-water mark are never appended twice; rule 9 direction).
461
+ def note_position(new_pos)
462
+ @pos = new_pos.is_a?(Integer) && new_pos >= 0 ? new_pos : 0
463
+ end
464
+
465
+ def saw_data?
466
+ @high_water.positive?
467
+ end
468
+
469
+ # The true byte count observed (the high-water mark: rewound
470
+ # re-reads never inflate it).
471
+ def bytes
472
+ @high_water
473
+ end
474
+
475
+ # The captured text — scrubbed to valid UTF-8, cut at
476
+ # MAX_CAPTURED_BODY_CHARS with the fleet truncation marker — or nil
477
+ # when text capture is off or nothing was seen.
478
+ def snapshot
479
+ return nil if @buffer.nil? || !saw_data?
480
+
481
+ text = Redaction.scrub_utf8(@buffer.dup)
482
+ return nil unless text.is_a?(String) && !text.empty?
483
+
484
+ if text.length > MAX_CAPTURED_BODY_CHARS
485
+ text[0, MAX_CAPTURED_BODY_CHARS] + TRUNCATION_MARKER
486
+ elsif @high_water > @buffer.bytesize
487
+ text + TRUNCATION_MARKER # bytes beyond the buffer cap were observed
488
+ else
489
+ text
490
+ end
491
+ rescue StandardError, SystemStackError
492
+ nil
493
+ end
494
+
495
+ private
496
+
497
+ def append(fresh)
498
+ return if @buffer.nil? || fresh.nil?
499
+
500
+ remaining = CAPTURE_BYTE_CAP - @buffer.bytesize
501
+ return if remaining <= 0
502
+
503
+ @buffer << fresh.b[0, remaining].to_s
504
+ nil
505
+ end
506
+ end
507
+
508
+ # The delegating rack.input tee: observes what the APP reads through
509
+ # the three Rack-SPEC input methods, delegates EVERYTHING else
510
+ # untouched (rewind/size/close/read_nonblock… exist exactly iff the
511
+ # underlying input has them — respond_to? stays truthful, so a Rack 3
512
+ # non-rewindable input still reports itself non-rewindable). Never
513
+ # reads ahead, never rewinds on its own.
514
+ class InputTee
515
+ # EVERY repositioning method is tracked for the re-read dedupe — a
516
+ # middleware that repositions via seek(0) or pos= 0 instead of
517
+ # rewind (all three are common Rack::MethodOverride-shape idioms on
518
+ # the rewindable inputs real servers hand out) must not make the
519
+ # framework's re-read double-capture the body.
520
+ REPOSITION_METHODS = %i[rewind seek pos=].freeze
521
+
522
+ def initialize(io, acc)
523
+ @io = io
524
+ @acc = acc
525
+ end
526
+
527
+ def read(*args)
528
+ result = @io.read(*args)
529
+ @acc.observe(result)
530
+ result
531
+ end
532
+
533
+ def gets(*args)
534
+ line = @io.gets(*args)
535
+ @acc.observe(line)
536
+ line
537
+ end
538
+
539
+ def each(*args, &block)
540
+ return enum_for(:each, *args) unless block
541
+
542
+ @io.each(*args) do |chunk|
543
+ @acc.observe(chunk)
544
+ yield chunk
545
+ end
546
+ end
547
+
548
+ def method_missing(name, *args, **kwargs, &block)
549
+ result = @io.__send__(name, *args, **kwargs, &block)
550
+ @acc.note_position(position_after(name, args)) if REPOSITION_METHODS.include?(name)
551
+ result
552
+ end
553
+
554
+ def respond_to_missing?(name, include_private = false)
555
+ @io.respond_to?(name, include_private)
556
+ end
557
+
558
+ private
559
+
560
+ # The stream position after a successful rewind/seek/pos= — the
561
+ # underlying IO's own #pos wherever it exists (every rewindable
562
+ # input real servers hand out — StringIO, Tempfile — has it), else
563
+ # the absolute target when the call spelled one out, else 0 (the
564
+ # rewind-equivalent: under-positioning can only ever SKIP capture of
565
+ # already-seen bytes, never duplicate them).
566
+ def position_after(name, args)
567
+ return 0 if name == :rewind
568
+
569
+ pos = (@io.pos if @io.respond_to?(:pos))
570
+ return pos if pos.is_a?(Integer)
571
+
572
+ target = args[0]
573
+ return target if target.is_a?(Integer) && (name == :pos= || absolute_seek?(args))
574
+
575
+ 0
576
+ rescue StandardError
577
+ 0
578
+ end
579
+
580
+ def absolute_seek?(args)
581
+ whence = args[1]
582
+ whence.nil? || whence == IO::SEEK_SET || whence == :SET
583
+ end
584
+ end
585
+
586
+ # The delegating response-body tee: observes chunks in flight during
587
+ # #each (never buffering the stream), preserves #close (flushing the
588
+ # attributes exactly once — on iteration completion or on close,
589
+ # whichever comes first), and delegates everything else (to_path for
590
+ # sendfile servers) with truthful respond_to?.
591
+ class BodyTee
592
+ def initialize(body, acc, &on_done)
593
+ @body = body
594
+ @acc = acc
595
+ @on_done = on_done
596
+ @flushed = false
597
+ end
598
+
599
+ def each(*args, &block)
600
+ return enum_for(:each, *args) unless block
601
+
602
+ @body.each(*args) do |chunk|
603
+ @acc.observe(chunk)
604
+ yield chunk
605
+ end
606
+ finish!(true)
607
+ end
608
+
609
+ def close
610
+ @body.close if @body.respond_to?(:close)
611
+ ensure
612
+ finish!(false)
613
+ end
614
+
615
+ def method_missing(name, ...)
616
+ @body.__send__(name, ...)
617
+ end
618
+
619
+ def respond_to_missing?(name, include_private = false)
620
+ @body.respond_to?(name, include_private)
621
+ end
622
+
623
+ private
624
+
625
+ def finish!(completed)
626
+ return if @flushed
627
+
628
+ @flushed = true
629
+ @on_done.call(completed)
630
+ nil
631
+ rescue StandardError, SystemStackError
632
+ nil
633
+ end
634
+ end
635
+ end
636
+
637
+ if defined?(::Rails::Railtie)
638
+ # The zero-effort Rails path, defined only when Rails loaded first (the
639
+ # standard Gemfile order). Unlike the action_pack railtie's
640
+ # before_initialize (which runs before config/initializers — before
641
+ # Foam::Otel.init has resolved the capture mode), this hook runs AFTER
642
+ # :load_config_initializers, when the mode is known, and still before
643
+ # the middleware stack finalizes — so an :off config really ships ZERO
644
+ # middleware. When foam-otel loads first (no Railtie), init()'s
645
+ # install_rails_middleware! fallback covers apps that init before
646
+ # Rails.application.initialize! completes (README recipe), and
647
+ # plain-Rack apps `use Foam::Otel::PayloadCapture` manually.
648
+ class PayloadCaptureRailtie < ::Rails::Railtie
649
+ initializer "foam_otel.payload_capture", after: :load_config_initializers do |app|
650
+ Foam::Otel::PayloadCapture.install_into_app!(app)
651
+ end
652
+ end
653
+ end
654
+ end
655
+ end
@@ -641,8 +641,66 @@ module Foam
641
641
  when :floor then floor_mask(floor_kind(key), value)
642
642
  when :erase then REDACTED
643
643
  when :mask then masked_value_for(value, config, value_scan)
644
- else descend(value, config, 0, value_scan: value_scan)
644
+ else
645
+ # The two payload-capture BODY attributes carry customer FIELD
646
+ # NAMES inside a JSON string — the key passes must reach them
647
+ # (deep-redaction below), not scan them as free text.
648
+ if value.is_a?(String) && body_attribute?(key)
649
+ mask_body_attribute(value, config, value_scan)
650
+ else
651
+ descend(value, config, 0, value_scan: value_scan)
652
+ end
653
+ end
654
+ end
655
+
656
+ # ---- payload-capture body attributes (mandate 2026-07-28) -------------
657
+ # The two value-bearing BODY names payload_capture.rb emits. A
658
+ # JSON-shaped string under one of these names is parsed and DEEP-masked
659
+ # as STRUCTURE — the credential floor and the customer's
660
+ # redact_keys/redact_pii_keys reach the field names inside the body
661
+ # exactly as they reach any nested attribute ({"password": …} masks) —
662
+ # then re-serialized. When nothing masks, the ORIGINAL string rides
663
+ # through byte-identical (raw capture stands). Non-JSON bodies — and
664
+ # cap-TRUNCATED JSON, which no longer parses (documented limit, GOTCHAS
665
+ # F15) — fall back to the central leaf-string pass: urlencoded form
666
+ # bodies get their k=v pairs name-matched by the C1 tokenizer, and the
667
+ # value-pattern secret layer scans everything. Fail CLOSED: a body that
668
+ # parsed but cannot be re-serialized is [REDACTED], never half-masked.
669
+ BODY_ATTRIBUTE_NAMES = %w[http.request.body http.response.body].freeze
670
+
671
+ # EXACT match, deliberately not canon(): the only emitters spell these
672
+ # names verbatim (payload_capture.rb; the js cores use the same exact
673
+ # strings), and this predicate runs for every unmatched string
674
+ # attribute on the hot export path — a canon() here would allocate per
675
+ # attribute for nothing.
676
+ def body_attribute?(name)
677
+ BODY_ATTRIBUTE_NAMES.include?(name)
678
+ end
679
+
680
+ def mask_body_attribute(text, config, value_scan = true)
681
+ if text.lstrip.start_with?("{", "[")
682
+ parsed = parse_json_body(text)
683
+ unless parsed.nil?
684
+ masked = deep_mask(parsed, config, 0, value_scan: value_scan)
685
+ return text if masked == parsed # nothing masked: byte-identical raw capture
686
+
687
+ begin
688
+ return JSON.generate(masked)
689
+ rescue StandardError, SystemStackError
690
+ return REDACTED # fail closed once the structural pass committed
691
+ end
692
+ end
645
693
  end
694
+ value_scan ? redact_leaf_string(text, config) : text
695
+ rescue StandardError, SystemStackError
696
+ REDACTED
697
+ end
698
+
699
+ def parse_json_body(text)
700
+ parsed = JSON.parse(text)
701
+ parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : nil
702
+ rescue StandardError
703
+ nil
646
704
  end
647
705
 
648
706
  # A redact_keys match: the value-pattern scan runs FIRST (design §V.0 —
@@ -83,6 +83,32 @@ module Foam
83
83
  # scope (their official instrumentations expose no hook —
84
84
  # OTel-fundamentals ruling). Bodies are NOT captured (that is the
85
85
  # separate capture_payloads pass).
86
- VERSION = "1.6.0"
86
+ # 1.7.0: OPT-IN HTTP BODY CAPTURE — `capture_payloads:` (2026-07-28
87
+ # payload-capture mandate; MINOR — one additive init option, default
88
+ # OFF, zero default-path behavior change). New rule-32a module
89
+ # PayloadCapture: a Rack middleware teeing BODIES ONLY (headers stayed
90
+ # the 1.6.0 official-rack-options seam) onto the official rack SERVER
91
+ # span. Modes: :off (DEFAULT — zero teeing, zero per-request
92
+ # allocation, NO middleware shipped) | :errors (tee per-request, attach
93
+ # ONLY on exception-recorded-on-span or status >= 500; clean 2xx/4xx
94
+ # attach nothing) | :always; equivalent strings accepted; invalid init
95
+ # value raises at boot. FOAM_CAPTURE_PAYLOADS=off|errors|always is the
96
+ # operator env clamp overriding the option in both directions (invalid
97
+ # env warns + falls back, never crashes). Wire: http.request.body /
98
+ # http.response.body capped at 8192 chars + "…[truncated]", textual
99
+ # content-types only, identity encoding only, plus true
100
+ # http.{request,response}.body.size (declared Content-Length, else
101
+ # observed bytes; non-textual payloads sizes-only; HEAD/204/304 and
102
+ # other bodiless responses attach NO response-body attributes).
103
+ # Stream-safe: delegating tees only (never read ahead of the app on
104
+ # the rack input stream, re-reads after rewind/seek/pos= deduped,
105
+ # streaming #each bodies teed unbuffered with close preserved, Rack 3
106
+ # #call-only bodies never wrapped). Redaction
107
+ # stays central at the exporter boundary — JSON-shaped body strings
108
+ # under the body names are now DEEP-masked by field name (floor +
109
+ # customer redact lists reach inside the body JSON). Rails auto-wiring
110
+ # (Railtie + init fallback) inserts the middleware ONLY when the mode
111
+ # is not :off; plain Rack adds one `use` line.
112
+ VERSION = "1.7.0"
87
113
  end
88
114
  end
data/lib/foam/otel.rb CHANGED
@@ -15,9 +15,11 @@
15
15
  # allowed_response_headers options, pre-installed by foam with a documented
16
16
  # DEFAULT LIST (a named enumeration, NOT all headers — the honest divergence
17
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).
18
+ # on the official client span — and OPT-IN HTTP body capture behind the ONE
19
+ # `capture_payloads:` init option (default :off zero teeing, zero
20
+ # middleware; payload_capture.rb) and nothing else: no stamping, no
21
+ # request-id enrichment (removed per the spec; stamps are the collector's
22
+ # job server-side).
21
23
 
22
24
  require_relative "otel/version"
23
25
  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.6.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foam
@@ -396,6 +396,7 @@ files:
396
396
  - lib/foam/otel/llm/ruby_llm_shim.rb
397
397
  - lib/foam/otel/logger_bridge.rb
398
398
  - lib/foam/otel/metrics.rb
399
+ - lib/foam/otel/payload_capture.rb
399
400
  - lib/foam/otel/pipelines.rb
400
401
  - lib/foam/otel/redacting_exporter.rb
401
402
  - lib/foam/otel/redaction.rb