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 +4 -4
- data/GOTCHAS.md +85 -0
- data/README.md +82 -2
- data/lib/foam/otel/config.rb +55 -5
- data/lib/foam/otel/errors.rb +12 -0
- data/lib/foam/otel/init.rb +52 -2
- data/lib/foam/otel/payload_capture.rb +655 -0
- data/lib/foam/otel/redaction.rb +59 -1
- data/lib/foam/otel/version.rb +27 -1
- data/lib/foam/otel.rb +5 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 48166d05b120dd8bdca15a9b45db5b4c16a6574fcb6890f4caff2c17c2ae9add
|
|
4
|
+
data.tar.gz: 2dcd4b3b146a0221c231ba5efc057085418b74e21b24d8441396d47cfc0a8288
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
235
|
-
|
|
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). |
|
data/lib/foam/otel/config.rb
CHANGED
|
@@ -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
|
data/lib/foam/otel/errors.rb
CHANGED
|
@@ -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
|
data/lib/foam/otel/init.rb
CHANGED
|
@@ -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
|
data/lib/foam/otel/redaction.rb
CHANGED
|
@@ -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
|
|
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 —
|
data/lib/foam/otel/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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.
|
|
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
|