foam-otel 1.6.0 → 1.8.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: 3e034361554ad48530554a1bdc62d75adf1899e3ef57104a367abd2c412d2f93
4
+ data.tar.gz: 40b6db9d7f11ed9105b00c9998b87da51e0822480d97da52be766ddea24435ca
5
5
  SHA512:
6
- metadata.gz: 4c4c7288e2b049a9f686d0eae3637a74261820bc779d6e8ec4daf14347b6ed3b423648c4c0d226982663465907465fc350a44cd52b998ad97b4766b69ecb727b
7
- data.tar.gz: 7eb2f27f17fc9fcd9649b72c68b59493b366ff66950c86ff42f4baa414b7ea222b9e251f30264adbfe32400fe7851b2cf64505bf666c5385546b44f4a5714768
6
+ metadata.gz: 9c71df1b9ce568dabd996b659d13e0bc05e93a81e070376a77e8c09ccd6a6fc51dcaa028c19a904e78d38daae532c6bc75077e3fa5331047aa1c64e08a9609ee
7
+ data.tar.gz: 90623d934372eb908d8c9d13e5ddd4108faad54bea4c5a647e7934959562ff130c2cdfdc2f0c5c5cc00a1a86eb5fcc4ee03c745c69c8c258fced2caf6ceb2f31
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,8 @@ 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
+ | `before_send:` | Proc/Array | no | `[]` | Export hook (1.8.0): one callable — or an Array run as a pipeline — called with every **span and log record** at the export boundary, after batching and BEFORE foam's redaction pass and serialization, so nothing a hook drops ever leaves the process. Return the record to export it (mutating it in place is supported — `record.attributes` arrives unfrozen), or `nil` to drop it. A hook that raises (or returns anything else) drops **that record** loudly and the batch reports failure — fail-closed, never a raise into your threads. Runs BEFORE redaction, so the credential floor and your redact lists still apply to whatever the hook returns. Spans and logs only (metrics are aggregated state, not records). A non-callable raises at boot. Process-global: a second `init` never changes it. See "The `before_send` hook" below. |
335
+ | `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
336
  | `diagnostics:` | Boolean | no | false | Verbose `[foam]` self-reporting of init/health. Warnings and errors are always loud regardless. |
257
337
  | `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
338
 
@@ -309,6 +389,24 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: EN
309
389
  ignored_outbound_hosts: ["ingest.eval-tool.example"]) # the tenant loop guard — required
310
390
  ```
311
391
 
392
+ ```ruby
393
+ # The before_send export hook (1.8.0): transform or drop spans/logs right
394
+ # before they are exported to foam — after batching, BEFORE redaction and
395
+ # serialization (a dropped record never leaves the process). Return the
396
+ # record (mutate in place freely) or nil to drop; a raising hook drops ONLY
397
+ # its record, loudly, and never raises into your threads (fail-closed).
398
+ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
399
+ before_send: lambda { |record|
400
+ next nil if record.respond_to?(:name) && record.name == "GET /healthz" # drop noise
401
+
402
+ record.attributes.delete("internal.debug.blob") # trim before it ships
403
+ record
404
+ })
405
+ # Or an Array pipeline — hooks run in order, each receiving the previous
406
+ # hook's return; the first nil drops the record and short-circuits:
407
+ # Foam::Otel.init(..., before_send: [DropHealthchecks, TrimDebugAttrs, StampTeam])
408
+ ```
409
+
312
410
  ```ruby
313
411
  # Verbose self-reporting while wiring foam up (warnings are loud regardless):
314
412
  Foam::Otel.init(name: "checkout-api", environment: "development", enabled: true,
@@ -537,6 +635,54 @@ processor = Foam::Otel.create_ingest_span_processor(
537
635
 
538
636
  ---
539
637
 
638
+ ## The `before_send` hook
639
+
640
+ `before_send:` is the last word your code gets on every span and log record
641
+ before it is exported to foam. It runs at the **export boundary** — after the
642
+ batch processor hands foam the finished records, immediately **before**
643
+ foam's redaction pass and the OTLP serialization — so a record your hook
644
+ drops is never serialized and never leaves the process. (This is the same
645
+ seam redaction lives at, and for the same Ruby SDK reason: a span's
646
+ attributes freeze at finish before any `on_finish` processor runs, so a
647
+ `SpanProcessor` could neither mutate nor drop. The export boundary is the
648
+ first point where the record is a mutable struct.)
649
+
650
+ The pipeline order is pinned:
651
+
652
+ ```
653
+ BatchProcessor → before_send hooks → redaction (floor + your lists) → OTLP wire
654
+ ```
655
+
656
+ Your hooks run **first**, on the raw record (it is your own data, still
657
+ in-process), and redaction runs on whatever they return — so `before_send`
658
+ can never widen what ships: an attribute a hook adds is masked by the
659
+ credential floor and your redact lists exactly like one an instrumentation
660
+ set.
661
+
662
+ The per-record contract (Sentry `beforeSend` semantics):
663
+
664
+ * **return the record** → it continues to the next hook / to export.
665
+ Mutating it in place is supported — `record.attributes` arrives as an
666
+ unfrozen copy, so `record.attributes["k"] = v` and
667
+ `record.attributes.delete("k")` just work.
668
+ * **return `nil`** → the record is dropped, silently (that is the filter
669
+ mechanism, not an error).
670
+ * **raise, or return anything else** → that record is dropped **loudly**
671
+ (`[foam]` warning) and the batch reports failure — fail-closed: a faulted
672
+ hook never ships a record you may have meant to scrub, and never raises
673
+ into your application or export threads. Healthy records in the same batch
674
+ still export.
675
+
676
+ Scope: **spans and log records** (the two record-shaped signals — the hook
677
+ receives `SpanData` or `LogRecordData`; distinguish them with `is_a?` or
678
+ `respond_to?`). Metrics are aggregated state, not discrete records, and are
679
+ deliberately not routed through `before_send`. Door-2 ingest taps are
680
+ unchanged. Like `enabled:` and the endpoint, `before_send` is process-global:
681
+ the exporter chain is built at the first `init`, and a second `init` never
682
+ changes it.
683
+
684
+ ---
685
+
540
686
  ## The helpers
541
687
 
542
688
  All helpers never raise, and no-op silently before `init` and when disabled.
@@ -926,6 +1072,7 @@ end
926
1072
  | `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
1073
  | `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
1074
  | `OTEL_BSP_SCHEDULE_DELAY` / `OTEL_BLRP_SCHEDULE_DELAY` / `OTEL_METRIC_EXPORT_INTERVAL` | HONORED — batch cadence, read natively by the upstream SDK. |
1075
+ | `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
1076
  | `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
1077
  | `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
1078
  | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | INERT — as above (warns when set). |
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "diagnostics"
4
+
5
+ module Foam
6
+ module Otel
7
+ # The customer's `before_send:` hook(s) (1.8.0), run at the EXPORT
8
+ # BOUNDARY — after batching, immediately BEFORE foam's redaction pass and
9
+ # the OTLP serialization, so a record a hook drops is never serialized
10
+ # and never leaves the process. Why not a SpanProcessor: the same Ruby
11
+ # SDK constraint that forced redaction to the exporter boundary — a span
12
+ # freezes its attributes at finish BEFORE any on_finish processor runs
13
+ # (GOTCHAS F1), so a processor could neither mutate nor reliably drop.
14
+ # The mutable SpanData / LogRecordData Structs the batch processors hand
15
+ # the exporter are the first (and last) point where per-record transform
16
+ # + drop is possible before the wire.
17
+ #
18
+ # Composition order (pinned, pipelines.rb):
19
+ # BatchProcessor -> BeforeSend::*Exporter -> Redacting*Exporter -> OTLP
20
+ # The hooks run FIRST so the credential floor, the value-pattern secret
21
+ # layer and the customer's key lists still apply to whatever a hook
22
+ # returns — before_send can never widen what ships past redaction (an
23
+ # attribute a hook ADDS is masked exactly like one an instrumentation
24
+ # set).
25
+ #
26
+ # Contract per record (Sentry beforeSend semantics — the spec's named
27
+ # fail-closed precedent, BASE_PACKAGE_SPEC D6):
28
+ # * return the record (mutating it in place is fine) → next hook /
29
+ # export;
30
+ # * return nil → record DROPPED (the intentional filter mechanism —
31
+ # success, not a failure);
32
+ # * raise, or return a foreign object → record DROPPED, loudly, and
33
+ # the batch reports FAILURE (fail-closed: a hook fault must never
34
+ # ship a record the customer may have meant to scrub, and must never
35
+ # raise into the export thread — rules 9/14/15). SystemStackError is
36
+ # rescued explicitly (not a StandardError): a hook recursing on a
37
+ # poisoned payload kills neither the batch thread nor the caller.
38
+ # The record reaches the first hook as a shallow dup with an UNFROZEN
39
+ # attributes copy (span attributes freeze at finish — GOTCHAS F1), so
40
+ # `record.attributes["k"] = v` just works; the dup also keeps the
41
+ # customer's mutations off the struct any other consumer might hold.
42
+ #
43
+ # Scope: spans and logs (the two record-shaped signals). Metric data is
44
+ # aggregated state, not records — deliberately NOT routed through
45
+ # before_send (documented in the README).
46
+ module BeforeSend
47
+ # Internal sentinel distinguishing a FAULTED drop (counts toward batch
48
+ # FAILURE) from an intentional nil drop. A hook can never return it
49
+ # legitimately — any non-record return is itself classified a fault.
50
+ ERROR = Object.new.freeze
51
+
52
+ module_function
53
+
54
+ # Run the hook pipeline over one record: each hook receives the
55
+ # previous hook's return. Returns the surviving record, nil for an
56
+ # intentional drop, or ERROR for a faulted one.
57
+ def run(hooks, record, signal)
58
+ current = thaw(record)
59
+ hooks.each do |hook|
60
+ result = hook.call(current)
61
+ return nil if result.nil?
62
+
63
+ unless result.is_a?(record.class)
64
+ Diagnostics.warn("before_send returned a #{result.class} for a #{signal} record — record " \
65
+ "dropped (return the record, a replacement of the same type, or nil to drop; " \
66
+ "fail-closed, rule 14)")
67
+ return ERROR
68
+ end
69
+ current = result
70
+ end
71
+ current
72
+ rescue StandardError, SystemStackError => e
73
+ Diagnostics.warn("before_send raised for a #{signal} record — record dropped, never exported " \
74
+ "half-transformed (fail-closed, rule 14): #{e.class}: #{e.message}")
75
+ ERROR
76
+ end
77
+
78
+ # A shallow dup with an unfrozen attributes copy — the hook gets its
79
+ # own mutable record (span attributes arrive frozen; GOTCHAS F1).
80
+ def thaw(record)
81
+ copy = record.dup
82
+ if copy.respond_to?(:attributes) && copy.respond_to?(:attributes=)
83
+ attributes = copy.attributes
84
+ copy.attributes = attributes.nil? ? {} : attributes.dup
85
+ end
86
+ copy
87
+ end
88
+
89
+ # The span-side wrapper (composition over the redacting exporter, same
90
+ # wrap-never-subclass posture as redacting_exporter.rb: only the public
91
+ # export/force_flush/shutdown contract the batch processors call).
92
+ class SpanExporter
93
+ def initialize(inner, config)
94
+ @inner = inner
95
+ @hooks = config.before_send
96
+ end
97
+
98
+ def export(span_data, timeout: nil)
99
+ batch = span_data.to_a
100
+ faulted = 0
101
+ kept = batch.filter_map do |sd|
102
+ result = BeforeSend.run(@hooks, sd, :span)
103
+ if result.equal?(ERROR)
104
+ faulted += 1
105
+ nil
106
+ else
107
+ result
108
+ end
109
+ end
110
+ status = kept.empty? ? OpenTelemetry::SDK::Trace::Export::SUCCESS : @inner.export(kept, timeout: timeout)
111
+ faulted.positive? ? OpenTelemetry::SDK::Trace::Export::FAILURE : status
112
+ rescue StandardError, SystemStackError
113
+ # Fail closed past the per-record guards, loudly (rule 15) —
114
+ # never raise into the batch thread.
115
+ Diagnostics.warn("before_send pass raised past its guards — span batch dropped (fail-closed, rule 14/15)")
116
+ OpenTelemetry::SDK::Trace::Export::FAILURE
117
+ end
118
+
119
+ def force_flush(timeout: nil) = @inner.force_flush(timeout: timeout)
120
+ def shutdown(timeout: nil) = @inner.shutdown(timeout: timeout)
121
+ end
122
+
123
+ class LogRecordExporter
124
+ def initialize(inner, config)
125
+ @inner = inner
126
+ @hooks = config.before_send
127
+ end
128
+
129
+ def export(log_record_data, timeout: nil)
130
+ batch = log_record_data.to_a
131
+ faulted = 0
132
+ kept = batch.filter_map do |lrd|
133
+ result = BeforeSend.run(@hooks, lrd, :log)
134
+ if result.equal?(ERROR)
135
+ faulted += 1
136
+ nil
137
+ else
138
+ result
139
+ end
140
+ end
141
+ status = kept.empty? ? OpenTelemetry::SDK::Logs::Export::SUCCESS : @inner.export(kept, timeout: timeout)
142
+ faulted.positive? ? OpenTelemetry::SDK::Logs::Export::FAILURE : status
143
+ rescue StandardError, SystemStackError
144
+ Diagnostics.warn("before_send pass raised past its guards — log batch dropped (fail-closed, rule 14/15)")
145
+ OpenTelemetry::SDK::Logs::Export::FAILURE
146
+ end
147
+
148
+ def force_flush(timeout: nil) = @inner.force_flush(timeout: timeout)
149
+ def shutdown(timeout: nil) = @inner.shutdown(timeout: timeout)
150
+ end
151
+ end
152
+ end
153
+ end
@@ -12,7 +12,8 @@ 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
+ :before_send,
16
17
  keyword_init: true
17
18
  )
18
19
 
@@ -22,6 +23,14 @@ module Foam
22
23
  # opts into the PII detection tier. Anything else raises at boot.
23
24
  REDACT_OPTION_FIELDS = %w[secrets pii detect].freeze
24
25
 
26
+ # The exact modes the `capture_payloads:` init option accepts
27
+ # (payload-capture mandate 2026-07-28; equivalent strings — trimmed,
28
+ # case-insensitive — are accepted and canonicalized to these symbols).
29
+ # :off is the default: zero body teeing, zero per-request allocation, no
30
+ # middleware shipped. The FOAM_CAPTURE_PAYLOADS operator env clamp
31
+ # (init.rb) accepts the same spellings and overrides the option.
32
+ CAPTURE_PAYLOAD_MODES = %i[off errors always].freeze
33
+
25
34
  class << self
26
35
  # The inert config the helpers read before init() runs (everything a
27
36
  # no-op needs: empty redaction lists, export disabled).
@@ -32,7 +41,8 @@ module Foam
32
41
  redact_detect: [].freeze,
33
42
  ignored_outbound_hosts: [].freeze,
34
43
  diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT,
35
- secret_heuristics: true
44
+ secret_heuristics: true, capture_payloads: :off,
45
+ before_send: [].freeze
36
46
  ).freeze
37
47
  end
38
48
 
@@ -44,8 +54,13 @@ module Foam
44
54
 
45
55
  def resolve_config(name:, environment:, version:, enabled:,
46
56
  redact_keys:, redact_pii_keys:, ignored_outbound_hosts:,
47
- diagnostics:, endpoint:, secret_heuristics: true, redact: nil)
57
+ diagnostics:, endpoint:, secret_heuristics: true, redact: nil,
58
+ capture_payloads: :off, before_send: nil)
48
59
  keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
60
+ # capture_payloads is validated LOUDLY at boot (rule 10, exactly like
61
+ # the redact: object): a value outside CAPTURE_PAYLOAD_MODES (or its
62
+ # string spellings) raises here — never a silent :off.
63
+ capture_mode = validate_capture_payloads!(capture_payloads)
49
64
  Config.new(
50
65
  name: name,
51
66
  environment: environment,
@@ -71,12 +86,71 @@ module Foam
71
86
  # module-constant machinery no config shape can narrow. Anything
72
87
  # but literal false means ON (default-on preserves the fleet's
73
88
  # no-leakage bar; disabling is an explicit, audited decision).
74
- secret_heuristics: secret_heuristics == false ? false : true
89
+ secret_heuristics: secret_heuristics == false ? false : true,
90
+ # The payload-capture mode (2026-07-28 mandate): :off (the default
91
+ # — zero teeing, zero middleware) | :errors | :always, canonical
92
+ # symbol form. init.rb applies the FOAM_CAPTURE_PAYLOADS operator
93
+ # env clamp BEFORE this resolves.
94
+ capture_payloads: capture_mode,
95
+ # The customer's export hook(s) (1.8.0): one callable or an Array
96
+ # pipeline, validated LOUDLY at boot (rule 10 — a non-callable is a
97
+ # programming error). Stored as a frozen Array; empty (the default)
98
+ # means the exporter chain gains no before_send stage at all.
99
+ before_send: validate_before_send!(before_send)
75
100
  ).freeze
76
101
  end
77
102
 
78
103
  private
79
104
 
105
+ # ---- the before_send option (1.8.0) ------------------------------------
106
+ # Loud at boot (rule 10, same posture as capture_payloads and the
107
+ # redact: object): before_send must be one callable (responds to
108
+ # #call — a lambda, a Proc, a Method, or any hook object) or an Array
109
+ # of callables run as a pipeline. Anything else — including a nil or
110
+ # non-callable INSIDE the array — raises here, never a silent no-op
111
+ # that leaves the customer believing their filter runs.
112
+ def validate_before_send!(value, context: "Foam::Otel.init")
113
+ hooks = if value.nil?
114
+ []
115
+ else
116
+ value.is_a?(Array) ? value : [value]
117
+ end
118
+ hooks.each_with_index do |hook, index|
119
+ next if hook.respond_to?(:call)
120
+
121
+ raise ArgumentError, "#{context} before_send: must be a callable (responds to #call) or an " \
122
+ "Array of callables, got #{hook.class} at position #{index}"
123
+ end
124
+ hooks.freeze
125
+ end
126
+
127
+ # ---- the capture_payloads option (payload-capture mandate 2026-07-28)
128
+ # Canonicalize a mode value: the three symbols, or their string
129
+ # spellings trimmed + case-insensitive, map to the canonical symbol;
130
+ # anything else is nil (the callers decide raise-vs-fallback).
131
+ def normalize_capture_payloads(value)
132
+ return value if CAPTURE_PAYLOAD_MODES.include?(value)
133
+ return nil unless value.is_a?(String) || value.is_a?(Symbol)
134
+
135
+ mode = value.to_s.strip.downcase.to_sym
136
+ CAPTURE_PAYLOAD_MODES.include?(mode) ? mode : nil
137
+ rescue StandardError
138
+ nil
139
+ end
140
+
141
+ # The loud-at-boot door (rule 10): an invalid INIT value is a
142
+ # programming error the engineer must catch on their machine.
143
+ # (The env clamp's invalid values warn + fall back instead — init.rb.)
144
+ def validate_capture_payloads!(value, context: "Foam::Otel.init")
145
+ mode = normalize_capture_payloads(value)
146
+ if mode.nil?
147
+ raise ArgumentError, "#{context} capture_payloads: must be one of " \
148
+ "#{CAPTURE_PAYLOAD_MODES.map(&:inspect).join(', ')} " \
149
+ "(equivalent strings accepted), got #{value.inspect}"
150
+ end
151
+ mode
152
+ end
153
+
80
154
  def downcase_list(list)
81
155
  Array(list).map { |k| k.to_s.downcase }.reject(&:empty?).uniq.freeze
82
156
  end
@@ -162,7 +236,16 @@ module Foam
162
236
  ignored_outbound_hosts: existing.ignored_outbound_hosts,
163
237
  diagnostics: diagnostics ? true : false,
164
238
  endpoint: existing.endpoint,
165
- secret_heuristics: secret_heuristics == false ? false : true
239
+ secret_heuristics: secret_heuristics == false ? false : true,
240
+ # Like enabled/endpoint, capture_payloads is process-global after
241
+ # the first init: middleware insertion already happened (or
242
+ # deliberately did not — an :off boot shipped none), so a second
243
+ # init cannot meaningfully flip it. Carried forward unchanged.
244
+ capture_payloads: existing.capture_payloads,
245
+ # before_send is process-global too: the exporter chain was built
246
+ # (with or without its before_send stage) at the first init and a
247
+ # second init never rebuilds pipelines. Carried forward unchanged.
248
+ before_send: existing.before_send
166
249
  ).freeze
167
250
  end
168
251
  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