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 +4 -4
- data/GOTCHAS.md +85 -0
- data/README.md +149 -2
- data/lib/foam/otel/before_send.rb +153 -0
- data/lib/foam/otel/config.rb +88 -5
- data/lib/foam/otel/errors.rb +12 -0
- data/lib/foam/otel/init.rb +56 -2
- data/lib/foam/otel/payload_capture.rb +655 -0
- data/lib/foam/otel/pipelines.rb +8 -0
- data/lib/foam/otel/redaction.rb +59 -1
- data/lib/foam/otel/version.rb +42 -1
- data/lib/foam/otel.rb +5 -3
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3e034361554ad48530554a1bdc62d75adf1899e3ef57104a367abd2c412d2f93
|
|
4
|
+
data.tar.gz: 40b6db9d7f11ed9105b00c9998b87da51e0822480d97da52be766ddea24435ca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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,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
|
data/lib/foam/otel/config.rb
CHANGED
|
@@ -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
|
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
|