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 +4 -4
- data/GOTCHAS.md +88 -0
- data/README.md +180 -5
- data/lib/foam/otel/config.rb +85 -10
- data/lib/foam/otel/constants.rb +69 -0
- data/lib/foam/otel/header_capture.rb +416 -0
- data/lib/foam/otel/ingest.rb +21 -8
- data/lib/foam/otel/init.rb +28 -3
- data/lib/foam/otel/redaction.rb +142 -7
- data/lib/foam/otel/version.rb +38 -1
- data/lib/foam/otel.rb +8 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb5e4f91fea3170bbb206582adade6ca516824edad064a4be911ebe912ce7abf
|
|
4
|
+
data.tar.gz: 67ee9186826b4bc31c7f51deeaf8fd91bec6aaec393099f23b000384821230b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
37
|
-
#
|
|
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
|
-
| `
|
|
146
|
-
| `
|
|
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
|
|
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). |
|
data/lib/foam/otel/config.rb
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
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).
|
|
77
|
-
# (
|
|
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:
|
|
86
|
-
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,
|
data/lib/foam/otel/constants.rb
CHANGED
|
@@ -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
|