foam-otel 1.4.0 → 1.6.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: 180e8af99c1f5c9bfc35de221c4985fb3ebac7b52ddf6fc99d6552a52aaea836
4
- data.tar.gz: 14a5d81c673af1a20a75e483e076d2c6b1820fa14e5a587e2a23451da3cccf77
3
+ metadata.gz: cb5e4f91fea3170bbb206582adade6ca516824edad064a4be911ebe912ce7abf
4
+ data.tar.gz: 67ee9186826b4bc31c7f51deeaf8fd91bec6aaec393099f23b000384821230b9
5
5
  SHA512:
6
- metadata.gz: a307d175e903abbbbd1c382e65694664f47aba18408fc7153f9a43e9fb72f8131fedcfc71a59e16ee4f7446dee0f3460233883f96a7048e1284fff75b54e16c7
7
- data.tar.gz: 7322e0a91e04092d494150ad9b003e4a6fd61b23ebc7468a8c715eec2d5ab8aac64d1edc4c9fb3cb1a844c3d3a56c167b9bbf05bed2bb1d4cb491eba20695f5d
6
+ metadata.gz: 4c4c7288e2b049a9f686d0eae3637a74261820bc779d6e8ec4daf14347b6ed3b423648c4c0d226982663465907465fc350a44cd52b998ad97b4766b69ecb727b
7
+ data.tar.gz: 7eb2f27f17fc9fcd9649b72c68b59493b366ff66950c86ff42f4baa414b7ea222b9e251f30264adbfe32400fe7851b2cf64505bf666c5385546b44f4a5714768
data/GOTCHAS.md CHANGED
@@ -633,6 +633,94 @@ exfiltratable — the value-pattern secret layer is the required second control
633
633
  `spec/vuln_suite_spec.rb` (the adversarial exfil/ReDoS/bypass suite +
634
634
  mutation spot-checks).
635
635
 
636
+ ## F14: Default-on header capture — the official rack options are the seam (a NAMED list, not capture-all), operator header config is never overridden, and Faraday middleware ORDER decides which span gets enriched
637
+
638
+ - **Trap**: four ways foam's default-on header capture
639
+ (`header_capture.rb`) could go wrong. (1) The official rack gem's
640
+ `allowed_request_headers:`/`allowed_response_headers:` options are a
641
+ STATIC ALLOWLIST compiled at install time with no capture-all form — so
642
+ Ruby inbound cannot be capture-all like js/python; pretending otherwise
643
+ (or shipping an empty default) silently costs the whole signal. (2) The
644
+ gem applies operator env config
645
+ (`OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS`,
646
+ instrumentation-base `config_overrides_from_env`) per option at install,
647
+ where an env value BEATS any passed config — except the narrow-to-empty
648
+ spelling (`allowed_request_headers=`), which the gem's env parser DROPS
649
+ (`parts[1]` nil): foam blindly passing its defaults would then override
650
+ the operator's explicit opt-out. And `Instrumentation::Base#install` is
651
+ first-wins (`return true if installed?`) — foam pre-installing BEFORE the
652
+ operator's explicit instance would freeze the operator's header config
653
+ out entirely. (3) A middleware writing to
654
+ `OpenTelemetry::Trace.current_span` writes to WHATEVER span is ambient:
655
+ foam's Faraday middleware registered without `use :open_telemetry` FIRST
656
+ (the official faraday instrumentation auto-appends its tracer middleware
657
+ AFTER the customer's block, i.e. INSIDE any middleware the block added)
658
+ would stamp `http.request.header.*` onto the enclosing app span,
659
+ silently corrupting another producer's data. (4) Outbound Net::HTTP has
660
+ NO hook surface in its official instrumentation (a closed
661
+ `connect`/`request` patch, no request/response hook, no header option) —
662
+ the only route would be monkey-patching stdlib or gem internals.
663
+ - **Sources**: installed source —
664
+ opentelemetry-instrumentation-rack-0.31.1 `instrumentation.rb:24-25`
665
+ (allowlist options), `instrumentation.rb:108-127` (compiled at install:
666
+ enumerate-up-front, no capture-all; `build_attribute_name` folds `-` to
667
+ `_`, so the emitted suffixes are underscored);
668
+ opentelemetry-instrumentation-base-0.26.1 `base.rb:218-227` (install is
669
+ first-wins/idempotent), `base.rb:274-313` (env override beats passed
670
+ config per option), `base.rb:343-363` (the `;`-separated `name=value`
671
+ env grammar; a valueless `name=` is dropped);
672
+ opentelemetry-instrumentation-faraday-0.33.0 `instrumentation.rb` (no
673
+ header option; `patches/stable/connection.rb:18-25` — the auto-insert
674
+ appends `use(:open_telemetry)` AFTER the customer's builder block) and
675
+ `middlewares/stable/tracer_middleware.rb:27-45` (the span is active only
676
+ around the handlers BELOW the tracer middleware);
677
+ opentelemetry-instrumentation-net_http-0.29.0 `patches/instrument.rb`
678
+ (no extension point).
679
+ - **Decision & why** (user ruling 2026-07-28, superseding the first-pass
680
+ capture-all Rack middleware): INBOUND capture is the official rack gem's
681
+ OWN header options, which foam PRE-INSTALLS with documented default
682
+ lists (`DEFAULT_REQUEST_HEADERS`/`DEFAULT_RESPONSE_HEADERS` — the
683
+ standard APM set plus the seven floor headers, which arrive `[REDACTED]`
684
+ so presence stays visible) — the same pre-install shape as the loop
685
+ guard, ordered AFTER `install_additional` so an operator's explicit rack
686
+ instance wins entirely, and computed per option AGAINST the operator's
687
+ env var so an operator-named option is never filled by foam (the F-PY3
688
+ posture: a narrower operator list is never widened, and `name=` narrows
689
+ to the gem default `[]`, never to foam's list). The honest consequence
690
+ is documented, not papered over: a header outside the list is NOT
691
+ captured (README shows the env-var extension recipe). OUTBOUND stays
692
+ foam's Faraday middleware (Faraday's first-class public API — the
693
+ official faraday instrumentation has no header option), capture-all,
694
+ triple-gated per write: foam owns the traces slot (rule 18 B), the span
695
+ is recording, AND the span's `instrumentation_scope.name` is
696
+ "OpenTelemetry::Instrumentation::Faraday" — a mis-ordered stack captures
697
+ NOTHING instead of enriching the wrong span. Faraday placement stays a
698
+ per-connection line (`f.use :open_telemetry` then
699
+ `f.use :foam_otel_headers`): the only default-on mechanism is the
700
+ Connection prepend the official gem uses — exactly the
701
+ internals-patching foam never does. Net::HTTP (and Excon / httprb /
702
+ HTTPX) outbound headers are deliberately OUT OF SCOPE — skipped and
703
+ documented rather than hacked. Masking stays central: the credential
704
+ floor covers the emitted `http.{request,response}.header.<name>` forms
705
+ at the exporter boundary (dash/underscore-normalized, so the rack gem's
706
+ underscored suffixes match), never a key list in the capture path.
707
+ - **Mitigation**: `lib/foam/otel/header_capture.rb` (the default lists,
708
+ `preinstall_rack_defaults!` + the operator-precedence parsing, the
709
+ Faraday middleware + public-registry registration, the shared gates);
710
+ `lib/foam/otel/init.rb` (`activate_instrumentations` pre-install
711
+ ordering — after `install_additional`, before `install_all` — gated on
712
+ foam owning traces; `activate_floor_extensions` Faraday registration).
713
+ - **Test**: `spec/header_capture_spec.rb` — default-on capture both
714
+ directions on the official rack span, the honest negative (an unlisted
715
+ header is NOT captured), floor headers arrive `[REDACTED]` on the wire
716
+ with zero config, multi-value arity, operator env respect (named option
717
+ wins, narrow-to-empty honored, explicit instance wins entirely),
718
+ install idempotence (a second sweep never clobbers the config),
719
+ foreign-traces stand-down, hostile values (invalid UTF-8 / huge /
720
+ raising readers), Faraday scope/ownership gates + mis-ordered-stack
721
+ no-op + registration idempotence; `spec/conventions_spec.rb` (rule 21:
722
+ header capture never grows a `rack.input` body tee).
723
+
636
724
  ---
637
725
 
638
726
  ## General gotchas (applicable to Ruby)
data/README.md CHANGED
@@ -33,8 +33,11 @@ gem "foam-otel"
33
33
  # Faraday), the primary datastores (pg, mysql2, redis, mongo) with RAW
34
34
  # db.statement capture, Sidekiq, the stdlib Logger bridge, hand-written Ruby
35
35
  # runtime + GC metrics, session stitching (browser session.id via baggage),
36
- # and the LLM shims (OpenAI, Anthropic, Gemini, ruby_llm activity + raw
37
- # content). Add only the niche long tail your app needs beyond the floor:
36
+ # default-on HTTP header capture (a documented default header list on the
37
+ # official rack server span; every Faraday client header see "Header
38
+ # capture" below), and the LLM shims
39
+ # (OpenAI, Anthropic, Gemini, ruby_llm — activity + raw content). Add only
40
+ # the niche long tail your app needs beyond the floor:
38
41
  gem "opentelemetry-instrumentation-graphql" # e.g. GraphQL
39
42
  gem "opentelemetry-instrumentation-resque" # e.g. Resque
40
43
  ```
@@ -131,6 +134,106 @@ baggage is absent. **CORS note (the FDE wires this on every frontend-called
131
134
  API):** the allowlist must include BOTH headers —
132
135
  `Access-Control-Allow-Headers: traceparent, baggage`.
133
136
 
137
+ ### Header capture — request + response headers on the official spans
138
+
139
+ **Inbound (default-on, no init option):** foam configures the OFFICIAL
140
+ `opentelemetry-instrumentation-rack` gem's own header options
141
+ (`allowed_request_headers:` / `allowed_response_headers:`) with a
142
+ documented **default list** at init, so the rack SERVER span carries the
143
+ listed request and response headers as
144
+ `http.request.header.<name>` / `http.response.header.<name>` attributes
145
+ (the gem's form: lowercase with `-` folded to `_`, e.g.
146
+ `http.request.header.x_request_id`), on error requests too.
147
+
148
+ > **Honest limit (deliberate divergence from foam's js/python cores):**
149
+ > Ruby inbound captures the **named default list below, NOT all headers** —
150
+ > the official gem's options are an enumerated allowlist with no
151
+ > capture-all form, and that official option is the chosen seam (ruling
152
+ > 2026-07-28). A custom header outside the list is **not captured** unless
153
+ > you extend the list (recipe below).
154
+
155
+ The default lists (`Foam::Otel::HeaderCapture::DEFAULT_REQUEST_HEADERS` /
156
+ `DEFAULT_RESPONSE_HEADERS` — frozen, documented constants):
157
+
158
+ - **request** — `content-type`, `content-length`, `content-encoding`,
159
+ `accept`, `accept-charset`, `accept-encoding`, `accept-language`,
160
+ `user-agent`, `referer`, `origin`, `host`, `cache-control`, `pragma`,
161
+ `if-none-match`, `if-modified-since`, `range`, `via`, `forwarded`,
162
+ `x-forwarded-for`, `x-forwarded-proto`, `x-forwarded-host`,
163
+ `x-forwarded-port`, `x-real-ip`, `x-request-id`, `x-correlation-id`,
164
+ plus the request half of the credential floor — `authorization`,
165
+ `proxy-authorization`, `cookie`, `x-api-key`, `x-auth-token`;
166
+ - **response** — `content-type`, `content-length`, `content-encoding`,
167
+ `content-language`, `content-range`, `cache-control`, `pragma`,
168
+ `expires`, `age`, `etag`, `last-modified`, `vary`, `location`,
169
+ `retry-after`, `x-request-id`, `x-correlation-id`, `x-runtime`,
170
+ `server-timing`, plus the response half of the floor — `set-cookie`,
171
+ `www-authenticate`.
172
+
173
+ Propagation headers (`traceparent`, `tracestate`, `baggage`) are
174
+ deliberately not listed — they are extracted as span context, not captured
175
+ as attributes; hop-by-hop plumbing (`connection`, `keep-alive`, …) is
176
+ skipped too.
177
+
178
+ **Extending (or narrowing) the list — the gem's own standard env var,
179
+ always respected, never overridden by foam:**
180
+
181
+ ```bash
182
+ # The value REPLACES the list for that option — name every header you want:
183
+ export OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS='allowed_request_headers=content-type,accept,x-request-id,x-tenant-id'
184
+ ```
185
+
186
+ Precedence: (1) an explicit rack instrumentation instance you pass via
187
+ `additional_instrumentations:` installs first and its header config wins
188
+ entirely; (2) any header option you set in
189
+ `OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS` governs that option (foam's
190
+ default fills only the option you did not touch — a narrower operator list
191
+ is never widened, even the narrow-to-empty `allowed_request_headers=`
192
+ spelling); (3) otherwise foam's defaults apply. Foam never installs the
193
+ rack instrumentation (or its header config) when a foreign SDK owns the
194
+ traces slot.
195
+
196
+ **Floor masking, zero config:** the always-on credential floor masks the
197
+ seven credential headers (`authorization`, `proxy-authorization`, `cookie`,
198
+ `set-cookie`, `x-api-key`, `x-auth-token`, `www-authenticate`) to
199
+ `[REDACTED]` in the emitted attribute forms (dash/underscore-normalized
200
+ match) — they ride the default lists deliberately so their PRESENCE is
201
+ visible, value never (see "The default credential denylist";
202
+ `x-forwarded-for` / `x-real-ip` are on the 52-entry key floor and arrive
203
+ `[REDACTED]` too). **Masking further captured headers is redaction's job,
204
+ not a capture switch:** list the emitted name form in `redact_keys` /
205
+ `redact: { secrets: [...] }` (tail mask) or `redact_pii_keys` /
206
+ `redact: { pii: [...] }` (full `[REDACTED]`) — the substring key match
207
+ reaches the attribute names (inbound names are underscored by the rack gem,
208
+ so list e.g. `x_runtime`; Faraday's outbound names keep dashes).
209
+
210
+ **Outbound (Faraday):** the official faraday instrumentation has no header
211
+ option at all, so outbound stays foam's own Faraday middleware (its
212
+ first-class public extension API), enriching the official CLIENT span with
213
+ **every** request header actually sent (the injected `traceparent`
214
+ included) and every response header received — string-array attributes,
215
+ dash-form names. It never creates a span and stands down when foam is
216
+ disabled/killed, when a foreign SDK owns traces, or when the current span
217
+ is not the official faraday instrumentation's own (a mis-ordered stack
218
+ captures nothing rather than writing onto the wrong span). One line per
219
+ connection (Faraday has no public add-to-every-connection hook — the
220
+ middleware name is registered by init, placement is yours; ORDER MATTERS,
221
+ the official middleware first):
222
+
223
+ ```ruby
224
+ conn = Faraday.new(url: "https://partner-api.example") do |f|
225
+ f.use :open_telemetry # the official client span (explicit, so it sits OUTSIDE)
226
+ f.use :foam_otel_headers # foam's header capture, directly inside it
227
+ end
228
+ ```
229
+
230
+ **Deliberately out of scope** (no standard extension point exists —
231
+ skipped, not monkey-patched): outbound **Net::HTTP / Excon / HTTP (httprb)
232
+ / HTTPX** headers (their official instrumentations expose no
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.
236
+
134
237
  ---
135
238
 
136
239
  ## The `init` options
@@ -142,8 +245,9 @@ API):** the allowlist must include BOTH headers —
142
245
  | `enabled:` | Boolean | yes | — | The config switch. `false` = fully inert (no SDK, no providers, no network, helpers no-op). `true` = export, in EVERY environment. No default — you write the logic. |
143
246
  | `token:` | String | when enabled | — | `Authorization: Bearer` for export. Required (and validated) only when `enabled: true`; wired explicitly from your secret source — never an env fallback read by the gem. **Fleet convention: read it from the `FOAM_OTEL_TOKEN` env var.** Blank while enabled → raises at boot. |
144
247
  | `version:` | String | no | nil | `service.version`, verbatim (git SHA recommended). Missing → warns and continues. Never detected at runtime. |
145
- | `redact_keys:` | Array<String> | no | `[]` | The ONLY field names tail-masked (shape-preserving, e.g. `********f456`). Opt-in and ADDITIVE on top of the always-on credential floorempty (the default) means no masking beyond the floor. Matches by case-insensitive substring, including keys nested in structured values and query-string keys. A key that is also on the floor stays fully `[REDACTED]` (the floor wins; it is never downgraded to a tail). |
146
- | `redact_pii_keys:` | Array<String> | no | `[]` | Field names fully erased to `[REDACTED]` (no tail). Opt-in and additive above the floor — empty (the default) means no erasure beyond the floor. Foam ships no PII preset; this is your enumeration. |
248
+ | `redact:` | Hash | no | nil | The grouped redaction object (1.5.0, the preferred surface): `{ secrets: [...], pii: [...], detect: [...] }`symbol or string keys. `secrets:` has `redact_keys` semantics (tail mask) and `pii:` has `redact_pii_keys` semantics (full `[REDACTED]`); when both a legacy option and its `redact` field are set, the lists UNION. `detect:` is the opt-in PII detection tier's entity list value-shape detection with typed placeholders (see "PII detection (opt-in)" below). An unknown field, a non-Array value, or an unknown `detect` entity name raises at boot. Absent (the default) behavior byte-identical to before. |
249
+ | `redact_keys:` | Array<String> | no | `[]` | Alias the preferred spelling is `redact: { secrets: [...] }` (same semantics; the lists union when both are set). The ONLY field names tail-masked (shape-preserving, e.g. `********f456`). Opt-in and ADDITIVE on top of the always-on credential floor — empty (the default) means no masking beyond the floor. Matches by case-insensitive substring, including keys nested in structured values and query-string keys. A key that is also on the floor stays fully `[REDACTED]` (the floor wins; it is never downgraded to a tail). |
250
+ | `redact_pii_keys:` | Array<String> | no | `[]` | Alias — the preferred spelling is `redact: { pii: [...] }` (same semantics; the lists union when both are set). Field names fully erased to `[REDACTED]` (no tail). Opt-in and additive above the floor — empty (the default) means no erasure beyond the floor. Foam ships no PII preset; this is your enumeration. |
147
251
  | `additional_span_processors:` | Array | no | `[]` | Tenant seam: constructed SpanProcessor instances added to foam's pipeline (additive; never replace foam's export). See coexistence. |
148
252
  | `additional_log_record_processors:` | Array | no | `[]` | Tenant seam, logs. |
149
253
  | `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
@@ -161,6 +265,22 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: EN
161
265
  redact_pii_keys: %w[customer_email full_name]) # fully [REDACTED]
162
266
  ```
163
267
 
268
+ ```ruby
269
+ # The grouped redact object (1.5.0, preferred — the flat options above are
270
+ # aliases and UNION with it when both are set), plus the opt-in PII
271
+ # detection tier (typed placeholders; see "PII detection (opt-in)"):
272
+ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
273
+ redact: {
274
+ secrets: %w[internal_ref voucher_code], # == redact_keys (tail mask)
275
+ pii: %w[customer_email full_name], # == redact_pii_keys (full [REDACTED])
276
+ detect: %w[email phone ssn credit_card ip], # value-shape detection → [EMAIL], [PHONE], …
277
+ })
278
+ # An unknown field, a non-Array value, or an unknown detect entity raises at boot:
279
+ # Foam::Otel.init(..., redact: { detect: ["name"] })
280
+ # => raises ArgumentError: Foam::Otel.init redact: unknown detect entity "name" — the valid
281
+ # entity names are exactly {email, phone, ssn, credit_card, ip} (contract/pii-detect.json)
282
+ ```
283
+
164
284
  ```ruby
165
285
  # Only if your legitimate telemetry collides with the generic secret
166
286
  # heuristics (e.g. base64 content-addressed ids masked as high-entropy
@@ -365,6 +485,58 @@ changed only by fleet ruling.
365
485
 
366
486
  ---
367
487
 
488
+ ## PII detection (opt-in)
489
+
490
+ The floor and the secret layer protect credentials. PII in VALUES — an email
491
+ address inside a log line, a card number inside an exception message — still
492
+ exports RAW by default, because coverage-over-masking is the mission and only
493
+ you know your privacy posture. As of 1.5.0 you can opt into value-shape PII
494
+ detection per entity, via `redact: { detect: [...] }` (fleet ruling
495
+ 2026-07-28; frozen fixture: `contract/pii-detect.json` — the entity names and
496
+ placeholders are byte-identical in every foam core and gate-checked in CI by
497
+ `spec/pii_detect_spec.rb`). Nothing detects unless you list the entity —
498
+ an empty/absent `detect` list is exactly today's behavior.
499
+
500
+ **The exact entity set** (these five, nothing else — an unknown name raises
501
+ at boot):
502
+
503
+ | Entity | Placeholder | Catches |
504
+ | --- | --- | --- |
505
+ | `email` | `[EMAIL]` | `john.smith+test@example.co.uk` — requires a real TLD (`user@localhost` rides raw) |
506
+ | `phone` | `[PHONE]` | `+1 (415) 555-0142`, `415-555-0199` — separated groups; bare digit runs, dotted versions (`2024.10.05`) and clock times ride raw |
507
+ | `ssn` | `[SSN]` | `536-90-4399`, `536 90 4399` — delimited 3-2-4 only; structurally invalid SSNs (`000-…`, group `00`, serial `0000`) and undelimited runs ride raw |
508
+ | `credit_card` | `[CREDIT_CARD]` | 13–19 digit PANs (spaced/dashed/contiguous) that pass **Luhn** — a card-shaped tracking id failing the checksum is never masked |
509
+ | `ip` | `[IP]` | IPv4 (octet-validated — `999.1.1.1` and `10.4.1.2000` ride raw) and IPv6, `::`-compressed included |
510
+
511
+ **How it masks.** Only the matched character span is replaced with the
512
+ entity's typed placeholder — surrounding text keeps its diagnostic value
513
+ (`login from [IP] flagged`). It runs everywhere the value-pattern secret
514
+ layer runs (span/event/link attributes, `status.message`, log bodies, URL
515
+ query and fragment leaves, nested structures, both doors), immediately AFTER
516
+ the secret layer (a shaped credential still becomes `[REDACTED]` — detect
517
+ never weakens it) and BEFORE your key lists; metric datapoint attributes stay
518
+ exempt exactly like the secret layer. Detection is idempotent (placeholders
519
+ never re-match), rides the same execution caps and fail-closed discipline
520
+ (any detector fault masks the whole value to `[REDACTED]`, loudly), and the
521
+ patterns follow the same bounded-quantifier authoring rules.
522
+
523
+ **What it can NOT do — read this before relying on it.** This is value-SHAPE
524
+ detection, nothing more: **person names and free-text prose are NOT
525
+ detectable** — recognizing "the patient, John Smith, reported…" takes
526
+ server-side NER, which is platform scope, not an SDK regex. Field-NAME-keyed
527
+ PII (`patient_name`) is what `redact: { pii: [...] }` is for; enumerate those
528
+ keys yourself. Foam still ships no PII preset — the detect list is your
529
+ explicit, audited enumeration.
530
+
531
+ ```ruby
532
+ # Door-2 taps take the same redact object, scoped to that tap:
533
+ processor = Foam::Otel.create_ingest_span_processor(
534
+ token: ENV.fetch("FOAM_OTEL_TOKEN"), environment: ENV.fetch("APP_ENV"),
535
+ redact: { pii: %w[customer_email], detect: %w[email credit_card] })
536
+ ```
537
+
538
+ ---
539
+
368
540
  ## The helpers
369
541
 
370
542
  All helpers never raise, and no-op silently before `init` and when disabled.
@@ -607,7 +779,9 @@ readers only: they never mutate their data, their resource, or their export.
607
779
  ### The three entries
608
780
 
609
781
  All three share one signature (`token:` and `environment:` required; the
610
- redaction kwargs are the only CUSTOMER keys that tap redacts — opt-in, none by
782
+ redaction kwargs the grouped `redact:` object and its legacy aliases
783
+ `redact_keys:`/`redact_pii_keys:`, union semantics exactly as on `init` —
784
+ are the only CUSTOMER redaction that tap applies — opt-in, none by
611
785
  default, scoped to that tap, additive above the always-on credential floor,
612
786
  which every tap applies with zero configuration; `diagnostics:` is tap-scoped
613
787
  narration). Construction
@@ -752,6 +926,7 @@ end
752
926
  | `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). |
753
927
  | `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). |
754
928
  | `OTEL_BSP_SCHEDULE_DELAY` / `OTEL_BLRP_SCHEDULE_DELAY` / `OTEL_METRIC_EXPORT_INTERVAL` | HONORED — batch cadence, read natively by the upstream SDK. |
929
+ | `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). |
755
930
  | `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. |
756
931
  | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | INERT — as above (warns when set). |
757
932
  | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | INERT — as above (warns when set). |
@@ -11,11 +11,17 @@ module Foam
11
11
  # loop guard and the redacting exporters can read one authoritative value.
12
12
  Config = Struct.new(
13
13
  :name, :environment, :version, :enabled,
14
- :redact_keys, :redact_pii_keys, :ignored_outbound_hosts,
14
+ :redact_keys, :redact_pii_keys, :redact_detect, :ignored_outbound_hosts,
15
15
  :diagnostics, :endpoint, :secret_heuristics,
16
16
  keyword_init: true
17
17
  )
18
18
 
19
+ # The exact field names the grouped `redact:` option accepts (fleet ruling
20
+ # 2026-07-28, docs/decisions/redact-object-detect-design.md §2): `secrets`
21
+ # unions with the legacy redact_keys, `pii` with redact_pii_keys, `detect`
22
+ # opts into the PII detection tier. Anything else raises at boot.
23
+ REDACT_OPTION_FIELDS = %w[secrets pii detect].freeze
24
+
19
25
  class << self
20
26
  # The inert config the helpers read before init() runs (everything a
21
27
  # no-op needs: empty redaction lists, export disabled).
@@ -23,6 +29,7 @@ module Foam
23
29
  Config.new(
24
30
  name: "", environment: "", version: nil, enabled: false,
25
31
  redact_keys: [].freeze, redact_pii_keys: [].freeze,
32
+ redact_detect: [].freeze,
26
33
  ignored_outbound_hosts: [].freeze,
27
34
  diagnostics: false, endpoint: FOAM_OTEL_ENDPOINT,
28
35
  secret_heuristics: true
@@ -37,7 +44,8 @@ module Foam
37
44
 
38
45
  def resolve_config(name:, environment:, version:, enabled:,
39
46
  redact_keys:, redact_pii_keys:, ignored_outbound_hosts:,
40
- diagnostics:, endpoint:, secret_heuristics: true)
47
+ diagnostics:, endpoint:, secret_heuristics: true, redact: nil)
48
+ keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
41
49
  Config.new(
42
50
  name: name,
43
51
  environment: environment,
@@ -47,9 +55,13 @@ module Foam
47
55
  # always-on credential floor (which is a module constant, NOT config
48
56
  # state — no config shape can alter it). Empty (the default) → no
49
57
  # customer redaction, raw capture above the floor. Stored lowercased
50
- # for the engine's case-insensitive substring match.
51
- redact_keys: downcase_list(redact_keys),
52
- redact_pii_keys: downcase_list(redact_pii_keys),
58
+ # for the engine's case-insensitive substring match. The grouped
59
+ # `redact:` object (2026-07-28 ruling) UNIONS with the legacy flat
60
+ # lists; redact.detect is the opt-in PII detection tier's entity
61
+ # list — empty (the default) means zero PII detection.
62
+ redact_keys: keys,
63
+ redact_pii_keys: pii,
64
+ redact_detect: detect,
53
65
  ignored_outbound_hosts: Array(ignored_outbound_hosts).map { |h| h.to_s.downcase }.freeze,
54
66
  diagnostics: diagnostics ? true : false,
55
67
  endpoint: endpoint,
@@ -69,21 +81,84 @@ module Foam
69
81
  Array(list).map { |k| k.to_s.downcase }.reject(&:empty?).uniq.freeze
70
82
  end
71
83
 
84
+ # ---- the grouped `redact:` object (fleet ruling 2026-07-28) -----------
85
+ # Parse + validate the object into its three raw lists. LOUD AT BOOT
86
+ # (rule 10, exactly like the name/environment validation): a non-Hash
87
+ # redact, an unknown field, a non-Array field value, or an unknown
88
+ # detect entity name raises ArgumentError here — never a silent skip,
89
+ # never a dark boot. Symbol AND string keys/entities are accepted
90
+ # (design §2 Ruby row); entities are coerced with the same downcase
91
+ # normalization the legacy lists use, then validated against the frozen
92
+ # fixture entity names (contract/pii-detect.json — the ONLY valid
93
+ # spellings). Called by both doors: init() via resolve_config /
94
+ # refresh_post_init_config, and the ingest factory's step-2 validation
95
+ # (with the entry name as context).
96
+ def parse_redact!(redact, context: "Foam::Otel.init")
97
+ return { secrets: nil, pii: nil, detect: nil } if redact.nil?
98
+
99
+ unless redact.is_a?(Hash)
100
+ raise ArgumentError, "#{context} redact: must be a Hash with fields drawn from " \
101
+ "{#{REDACT_OPTION_FIELDS.join(', ')}} (e.g. redact: { detect: [\"email\"] }), " \
102
+ "got #{redact.class}"
103
+ end
104
+ out = { secrets: nil, pii: nil, detect: nil }
105
+ redact.each do |key, value|
106
+ field = key.to_s
107
+ unless REDACT_OPTION_FIELDS.include?(field)
108
+ raise ArgumentError, "#{context} redact: unknown field #{key.inspect} — the valid fields are " \
109
+ "exactly {#{REDACT_OPTION_FIELDS.join(', ')}}"
110
+ end
111
+ unless value.is_a?(Array)
112
+ raise ArgumentError, "#{context} redact: #{field} must be an Array of strings, got #{value.class}"
113
+ end
114
+ out[field.to_sym] = Array(out[field.to_sym]) + value
115
+ end
116
+ validate_detect_entities!(out[:detect], context)
117
+ out
118
+ end
119
+
120
+ # Fixture rule "unknown_entity": the valid names are exactly the keys of
121
+ # contract/pii-detect.json's `entities` — anything else raises at init.
122
+ def validate_detect_entities!(detect, context)
123
+ return if detect.nil?
124
+
125
+ valid = PII_DETECT_ENTITIES.keys
126
+ detect.each do |entity|
127
+ next if valid.include?(entity.to_s.downcase)
128
+
129
+ raise ArgumentError, "#{context} redact: unknown detect entity #{entity.inspect} — the valid entity " \
130
+ "names are exactly {#{valid.join(', ')}} (contract/pii-detect.json)"
131
+ end
132
+ end
133
+
134
+ # Legacy flat lists ∪ the grouped object's fields (design §2: the legacy
135
+ # aliases live on unchanged; when both are set, the lists UNION), all
136
+ # three normalized exactly as the legacy options always were.
137
+ def merged_redact_lists(redact_keys, redact_pii_keys, redact, context: "Foam::Otel.init")
138
+ parsed = parse_redact!(redact, context: context)
139
+ [downcase_list(Array(redact_keys) + Array(parsed[:secrets])),
140
+ downcase_list(Array(redact_pii_keys) + Array(parsed[:pii])),
141
+ downcase_list(parsed[:detect])]
142
+ end
143
+
72
144
  # A second init() refreshes ONLY the redaction lists (and diagnostics,
73
145
  # and the heuristic-tier flag — redaction state) onto the EXISTING
74
146
  # config: enabled/endpoint/identity are process-global and immutable
75
147
  # after the first init (see init.rb — a second init must never flip
76
- # enabled and leave foam half-dark). Private: not public API surface
77
- # (pinned by export_surface_spec).
148
+ # enabled and leave foam half-dark). The `redact:` object refreshes
149
+ # exactly like the legacy lists (design §2 second-init row). Private:
150
+ # not public API surface (pinned by export_surface_spec).
78
151
  def refresh_post_init_config(existing, redact_keys:, redact_pii_keys:, diagnostics:,
79
- secret_heuristics: true)
152
+ secret_heuristics: true, redact: nil)
153
+ keys, pii, detect = merged_redact_lists(redact_keys, redact_pii_keys, redact)
80
154
  Config.new(
81
155
  name: existing.name,
82
156
  environment: existing.environment,
83
157
  version: existing.version,
84
158
  enabled: existing.enabled,
85
- redact_keys: downcase_list(redact_keys),
86
- redact_pii_keys: downcase_list(redact_pii_keys),
159
+ redact_keys: keys,
160
+ redact_pii_keys: pii,
161
+ redact_detect: detect,
87
162
  ignored_outbound_hosts: existing.ignored_outbound_hosts,
88
163
  diagnostics: diagnostics ? true : false,
89
164
  endpoint: existing.endpoint,
@@ -342,5 +342,74 @@ module Foam
342
342
  yourkey your_api_key your-api-key xxxxxxxx todo
343
343
  notreal loremipsum deadbeef 0000000000 1111111111
344
344
  ].freeze
345
+
346
+ # ---- THE PII DETECT TIER (fleet ruling 2026-07-28, FULLY OPT-IN) --------
347
+ # docs/decisions/redact-object-detect-design.md §3; frozen fixture:
348
+ # contract/pii-detect.json (entity names + placeholders byte-identical,
349
+ # gate-checked by spec/pii_detect_spec.rb; behavior proven against the
350
+ # fixture's positive/negative vectors). Nothing here runs unless the
351
+ # customer lists the entity in init's redact.detect (or a door-2 tap's
352
+ # redact:) — the empty/absent list is today's raw-by-default behavior,
353
+ # byte-identical. The engine (redaction.rb detect_pii) runs this tier
354
+ # immediately AFTER the value-pattern secret layer and BEFORE customer
355
+ # key masking, masking ONLY the matched character span with the entity's
356
+ # typed :placeholder (surrounding text preserved verbatim; emitted
357
+ # placeholders never re-match — fixture "placeholder_idempotence").
358
+ #
359
+ # Authoring rules mirror the secret layer's (design §V.5.2 / rule 49):
360
+ # compile-once at require (a bad pattern fails the require, loudly),
361
+ # bounded quantifiers only, no lookbehind, no atomic/possessive groups;
362
+ # :group names the span to mask (0 = the whole match; phone masks group 1
363
+ # behind a consumed non-digit boundary — never a lookbehind). Patterns
364
+ # are byte-consistent with the python core's PII_DETECT_ENTITIES; the one
365
+ # recorded portability translation is phone's `^` → `\A` (Ruby's ^ is a
366
+ # LINE anchor — same translation note as SECRET_HEURISTIC_H2). :filter
367
+ # (:luhn) names a mandatory post-match validator — a card-shaped digit
368
+ # string failing Luhn is NEVER masked (fixture rule "checksum"). NOT
369
+ # detectable by design: person names and free-text prose (server-side
370
+ # NER scope — the README states this plainly).
371
+ PII_DETECT_ENTITIES = {
372
+ "email" => {
373
+ placeholder: "[EMAIL]", group: 0,
374
+ regex: '\b[A-Za-z0-9._%+\-]{1,64}@[A-Za-z0-9\-]{1,63}' \
375
+ '(?:\.[A-Za-z0-9\-]{1,63}){0,10}\.[A-Za-z]{2,24}\b',
376
+ }.freeze,
377
+ # Group 1 is the number; the leading alternation is a consumed boundary
378
+ # guard (no lookbehind — rule-49 discipline). Separators are space/dash
379
+ # only, so dotted versions (2024.10.05) and clock times (12:30:45)
380
+ # never trip.
381
+ "phone" => {
382
+ placeholder: "[PHONE]", group: 1,
383
+ regex: '(?:\A|[^0-9A-Za-z])((?:\+[0-9]{1,3}[ \-]?)?' \
384
+ '(?:\([0-9]{1,4}\)[ \-]?|[0-9]{1,4}[ \-])' \
385
+ '[0-9]{3,4}[ \-][0-9]{3,4})(?![0-9])',
386
+ }.freeze,
387
+ # Delimited 3-2-4 with a CONSISTENT delimiter (the \1 backreference —
388
+ # a one-char group, linear by construction) — an undelimited 9-digit
389
+ # run never matches. Area 000/666/9xx, group 00, and serial 0000 are
390
+ # structurally invalid SSNs and ride raw.
391
+ "ssn" => {
392
+ placeholder: "[SSN]", group: 0,
393
+ regex: '\b(?!000|666|9[0-9]{2})[0-9]{3}([ \-])' \
394
+ '(?!00)[0-9]{2}\1(?!0000)[0-9]{4}\b',
395
+ }.freeze,
396
+ # 13-19 digits in 4-4-4-rest grouping (space/dash/contiguous). The
397
+ # regex is deliberately loose on grouping; the LUHN filter is the
398
+ # normative gate (fixture "checksum" rule).
399
+ "credit_card" => {
400
+ placeholder: "[CREDIT_CARD]", group: 0, filter: :luhn,
401
+ regex: '\b[0-9]{4}(?:[ \-]?[0-9]{4}){2}[ \-]?[0-9]{1,7}\b',
402
+ }.freeze,
403
+ # IPv4 with per-octet range validation (999.1.1.1 and 4-digit octets
404
+ # ride raw), full 8-group IPv6, and ::-compressed IPv6.
405
+ "ip" => {
406
+ placeholder: "[IP]", group: 0,
407
+ regex: '\b(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}' \
408
+ '(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\b' \
409
+ '|\b(?:[0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}\b' \
410
+ '|\b(?:[0-9A-Fa-f]{1,4}:){1,6}:' \
411
+ '(?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,5})?(?![0-9A-Fa-f:])',
412
+ }.freeze,
413
+ }.freeze
345
414
  end
346
415
  end