foam-otel 1.2.0 → 1.3.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: bb41e8c47c944ac8f252f47bededb07f8c73e152411f3a7069a8fcab3d1185a2
4
- data.tar.gz: ab93229a7820e4b60439d8c30f903fd3d1be93a8d2dd723b032862813ac4278f
3
+ metadata.gz: 82f1791c9e7d9929a655760c4e2f67295bb70f55dee75a2738bfcae33f5658f6
4
+ data.tar.gz: 16917b6e7a4e1dc1f0c6160f7026d4fe90c6213a16fa0664807e459359c5ea92
5
5
  SHA512:
6
- metadata.gz: 291f81494a360ee3ce94a09b6b98dafde0574e4078d61ec01bb3e3e3b7803a4f7034bc36ef17a33db79cbb886ad5c0d337c2ffa75762c5a9d04f52e89cdf6669
7
- data.tar.gz: e81c1c075606402562ee74a8dc6752dd4d96f4b73235303a43cd0d20569ca7238a031b3c6c6d6726f5cf3f3c32858c2452cc5cbfac8ffd61f834f549738c6c1b
6
+ metadata.gz: 38df8e89604a42d3db4698f5f12934f2bf3e25e288124caf10694a0e698e50f3a732fdd9f1dd2455fb52af90777cba00a3e825f23b967817d6fbda25b567fbdf
7
+ data.tar.gz: 5034a35f4d3a7c9d1f18fb0e7adc6691e75aacf0130eb74312ce14ad0ab902e787a25b75af45d6f618cd70a13df12b8323fc172d1cb0abee2950991bb839db6d
data/GOTCHAS.md CHANGED
@@ -240,34 +240,48 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
240
240
  `@attributes = validated_attributes(@attributes).freeze` then sets `@ended`
241
241
  (opentelemetry-sdk `trace/span.rb:277,280`); `to_span_data` returns the frozen
242
242
  `@attributes` by reference (`trace/span.rb:296-307`).
243
- - **Opt-in redaction (holistic redesign, 2026-07-26)**: foam does NO redaction by
244
- default with no `redact_keys`/`redact_pii_keys` every value is captured RAW
245
- (no secret floor, no value-pattern masking). When keys ARE configured, foam
246
- applies the key pass at the EXPORTER boundary a `RedactingSpanExporter`/
247
- `RedactingLogRecordExporter` that rebuilds the mutable `SpanData`/
248
- `LogRecordData` Structs with masked attributes/events/body before
249
- serialization. With no keys configured the exporter is a true pass-through.
250
- - **Divergence (NARROWED)**: when keys are configured foam's HELPERS apply the
251
- key pass AT CAPTURE (`api.rb` `stringify`/`set_attribute`/`log`; `metrics.rb`
252
- always did), so a tenant `additional_*` instance receives foam-helper data
243
+ - **Redaction posture (holistic redesign 2026-07-26, amended same day by the
244
+ credential-floor ruling)**: foam does NO customer redaction by default — with
245
+ no `redact_keys`/`redact_pii_keys` every value is captured RAW (no
246
+ value-pattern masking) EXCEPT the ALWAYS-ON CREDENTIAL FLOOR: the frozen
247
+ header/key name list of `contract/credential-denylist.json` masks to
248
+ `[REDACTED]` unconditionally, with no off switch (README "The default
249
+ credential denylist"; design doc `docs/decisions/credential-denylist-design.md`).
250
+ The floor + any configured keys run at the EXPORTER boundary — a
251
+ `RedactingSpanExporter`/`RedactingLogRecordExporter` that rebuilds the
252
+ mutable `SpanData`/`LogRecordData` Structs with masked
253
+ attributes/events/body before serialization. The pass runs unconditionally
254
+ (the floor has no off state); values under unmatched names ride through raw.
255
+ - **Divergence (CLOSED in 1.3.0)**: foam's HELPERS apply the floor + key pass
256
+ AT CAPTURE (`api.rb` `stringify`/`set_attribute`/`log`; `metrics.rb` always
257
+ did), so a tenant `additional_*` instance receives foam-helper data
253
258
  ALREADY-MASKED (rule 18 C.3) — the pass is idempotent, so the wire value is
254
- unchanged. The residual divergence is THIRD-PARTY INSTRUMENTATION attributes
255
- only: those freeze at finish before any processor runs, so a tenant span
256
- processor sees them as foam captured them and foam can only apply the key pass
257
- at the exporter boundary. Ruby offers no safe pre-freeze hook the
258
- `on_finishing` hook runs inside the span mutex, so a processor calling
259
- `set_attribute` there would deadlock. Foam's own export applies the configured
260
- key pass, and the exporters FAIL CLOSED once a pass has begun: a struct whose
261
- rebuild raises is DROPPED with a loud warning (`Redaction.mask_*` return nil on
262
- hard failure), never exported half-masked.
263
- - **Mitigation**: capture-time key pass in `lib/foam/otel/api.rb` +
259
+ unchanged. THIRD-PARTY INSTRUMENTATION attributes freeze at finish before
260
+ any processor runs (no safe pre-freeze hook `on_finishing` runs inside
261
+ the span mutex, so a processor calling `set_attribute` there would
262
+ deadlock), so they CANNOT be masked in place instead the guarded tenant
263
+ wrappers (`pipelines.rb` `GuardedSpanProcessor#on_finish` /
264
+ `GuardedLogRecordProcessor#on_emit`) hand tenants a READ-ONLY MASKED VIEW
265
+ (`MaskedSpanView`/`MaskedLogRecordView`, served through the same
266
+ `Redaction.mask_span_data`/`mask_attributes`/`mask_body` pass foam's export
267
+ applies) — a tenant never sees a raw floor (or configured-key) value, and a
268
+ span whose masked view cannot be built is WITHHELD loudly, never handed
269
+ over raw. Foam's own export applies the same pass, and the exporters FAIL
270
+ CLOSED once the pass has begun: a struct whose rebuild raises is DROPPED
271
+ with a loud warning (`Redaction.mask_*` return nil on hard failure), never
272
+ exported half-masked.
273
+ - **Mitigation**: capture-time pass in `lib/foam/otel/api.rb` + the masked
274
+ tenant view in `lib/foam/otel/pipelines.rb` +
264
275
  `lib/foam/otel/redacting_exporter.rb` + `lib/foam/otel/redaction.rb`.
265
- - **Test**: `spec/tenant_seam_spec.rb` (tenant processors receive helper-set
266
- LISTED keys ALREADY-MASKED, free-text bodies RAW, the byte-identical
267
- with/without-tenant proofs for spans, logs, AND metrics),
268
- `spec/redacting_exporter_spec.rb` (opt-in masking on the wire; "a POISONED
269
- SpanData (dup raises) is DROPPED never exported raw"), `spec/redaction_spec.rb`
270
- (default zero-redaction + the two opt-in key lists; capture+exporter idempotence).
276
+ - **Test**: `spec/credential_floor_spec.rb` + `spec/credential_floor_wire_spec.rb`
277
+ (the floor per signal/door; the tenant masked-view regression gate a
278
+ third-party-set `authorization` attribute never reaches a tenant raw),
279
+ `spec/tenant_seam_spec.rb` (tenant processors receive helper-set LISTED keys
280
+ ALREADY-MASKED, free-text bodies RAW, the byte-identical with/without-tenant
281
+ proofs for spans, logs, AND metrics), `spec/redacting_exporter_spec.rb`
282
+ (masking on the wire; "a POISONED SpanData (dup raises) is DROPPED — never
283
+ exported raw"), `spec/redaction_spec.rb` (raw-above-the-floor defaults + the
284
+ two opt-in key lists; capture+exporter idempotence).
271
285
 
272
286
  ## F2: OTEL_SDK_DISABLED and OTEL_PROPAGATORS are only honored by SDK.configure
273
287
 
@@ -623,8 +637,10 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
623
637
  mitigation (foam never rewrites or drops metric dimensions — rule 4/26);
624
638
  the mitigation is the README metrics warning with the classic bad example —
625
639
  *Documented* (README "Metrics" section). *Test*: `spec/metrics_spec.rb`
626
- ("captures a metric attribute RAW by default; masks it only when the key is in
627
- redact_keys" — pins that unlisted attributes pass through VERBATIM).
640
+ ("captures a NON-floor metric attribute RAW by default; masks it only when
641
+ the key is in redact_keys" — pins that unlisted, non-floor attributes pass
642
+ through VERBATIM; the credential floor's frozen names are the one always-on
643
+ exception, `spec/credential_floor_wire_spec.rb`).
628
644
  - **G8 — never-ended spans leak memory**: a span started and never ended holds
629
645
  memory and context forever (and never exports — the batch processor only
630
646
  sees FINISHED spans, `on_finish`). `Foam::Otel.span {}` always ends the span
@@ -681,6 +697,22 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
681
697
  beside it (`lib/foam/otel/runtime_metrics.rb` `guarded_callback`).
682
698
  *Test*: `spec/runtime_metrics_spec.rb` ("a poisoned reading is
683
699
  fail-to-dark", the foreign-slot and idempotence cases).
700
+ - **G16 — Sidekiq's queue-hop trace shape is `propagation_style: :link`**:
701
+ the bundled contrib instrumentation's default (installed source:
702
+ opentelemetry-instrumentation-sidekiq-0.29.0 `instrumentation.rb:104`)
703
+ makes a performed job a SEPARATE trace whose first (consumer) span carries
704
+ a span LINK back to the enqueuing producer span — NOT a child inside the
705
+ web request's trace. Foam ships that upstream default untouched (rule 26:
706
+ the contrib pin sets the semconv; `FLOOR_INSTRUMENTATION_CONFIG` overrides
707
+ only the datastore raw-capture posture), so dashboards that follow ONLY
708
+ parent/child edges see the web trace end at the enqueue — follow the link,
709
+ or clamp the posture per deployment with the standard contrib lever
710
+ `OTEL_RUBY_INSTRUMENTATION_SIDEKIQ_CONFIG_OPTS='propagation_style=child'`
711
+ for one continuous web→job trace. Context is propagated across the hop
712
+ either way — a hop that DROPS it is a correctness bug, never accepted.
713
+ *Test*: `spec/floor_sidekiq_wire_spec.rb` (real Redis enqueue→perform:
714
+ producer span joins the request trace; consumer span LINKS the producer
715
+ span on the wire).
684
716
 
685
717
  ---
686
718
 
data/README.md CHANGED
@@ -4,9 +4,12 @@ Foam's OpenTelemetry core for Ruby services. A thin, safe wrapper over the
4
4
  official OpenTelemetry libraries: foam owns the pipeline (providers, batch
5
5
  processors, OTLP export to the foam fleet endpoint), turns on automatic
6
6
  tier-1/2 instrumentation, and hands you a small set of never-throw helpers —
7
- and nothing else. **Redaction is fully opt-in: by default foam captures every
8
- value RAW** (no secret floor, no value-pattern masking); you enumerate the
9
- fields to mask via `redact_keys`/`redact_pii_keys`.
7
+ and nothing else. **Redaction is fully opt-in above one always-on exception:
8
+ by default foam captures every value RAW** (no value-pattern masking, no PII
9
+ preset) **except the credential floor** — a fixed list of credential/secret
10
+ header and field NAMES that is always masked to `[REDACTED]` (see "The
11
+ default credential denylist" below); you enumerate any further fields to mask
12
+ via `redact_keys`/`redact_pii_keys`.
10
13
 
11
14
  Built to `docs/BASE_PACKAGE_SPEC.md`. This README is the manual you integrate
12
15
  from.
@@ -75,10 +78,20 @@ drop-in upstream metrics gem, so foam ships its own collector),
75
78
  **session stitching** (inbound `baggage: session.id` from the foam browser
76
79
  package is stamped onto every span and log record), and the **LLM shims**
77
80
  (below). Every inbound request, DB call, HTTP call, job, log line, and model
78
- call now flows to foam. **Redaction is opt-in: by default every value
79
- attributes, URLs/query strings, DB statement text, log bodies, LLM content —
80
- is captured RAW.** Pass `redact_keys`/`redact_pii_keys` to mask or erase
81
- specific fields (see below).
81
+ call now flows to foam. **Redaction is opt-in above the credential floor: by
82
+ default every value — attributes, URLs/query strings, DB statement text, log
83
+ bodies, LLM content — is captured RAW, except values whose NAME is on the
84
+ always-on credential denylist (masked `[REDACTED]`; see "The default
85
+ credential denylist" below).** Pass `redact_keys`/`redact_pii_keys` to mask
86
+ or erase further specific fields (see below).
87
+
88
+ > **Sidekiq trace shape** (GOTCHAS G16): the bundled instrumentation keeps
89
+ > its upstream default `propagation_style: :link` — a performed job runs as
90
+ > its OWN trace whose first span LINKS back to the enqueuing span (context is
91
+ > always propagated across the hop; proven against a real Redis in
92
+ > `spec/floor_sidekiq_wire_spec.rb`). Prefer one continuous web→job trace?
93
+ > Set the standard contrib lever:
94
+ > `OTEL_RUBY_INSTRUMENTATION_SIDEKIQ_CONFIG_OPTS='propagation_style=child'`.
82
95
 
83
96
  ### The LLM surface — OpenAI, Anthropic, Gemini (and ruby_llm)
84
97
 
@@ -87,7 +100,14 @@ presence-checked shims over the SDKs' public call sites, emitting the standard
87
100
  `gen_ai.*` semantic conventions — activity (model, response id, finish
88
101
  reasons, token usage, latency) AND prompt/response **content, RAW**
89
102
  (`gen_ai.input.messages` / `gen_ai.output.messages` /
90
- `gen_ai.system_instructions`). Covered: the official `openai` SDK
103
+ `gen_ai.system_instructions`). Agent tool loops ride in full: tool-call
104
+ turns keep their tool names/arguments and tool-result turns keep their
105
+ `tool_call_id` linkage inside the message content. `gen_ai.operation.name`
106
+ (and the `{operation} {model}` span name) follows the semconv operation for
107
+ each API shape — `chat` for the OpenAI/Anthropic/ruby_llm chat seams,
108
+ `generate_content` for Gemini's generateContent, fleet-identical with the
109
+ js/python Gemini paths (**wire change in 1.2.1**: Gemini spans previously
110
+ said `chat`). Covered: the official `openai` SDK
91
111
  (chat.completions.create + responses.create), the official `anthropic` SDK
92
112
  (messages.create), Gemini via the `gemini-ai` gem (Google ships no official
93
113
  Ruby SDK), and `ruby_llm` (one seam covering all its providers). Absent SDK =
@@ -119,8 +139,8 @@ API):** the allowlist must include BOTH headers —
119
139
  | `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. |
120
140
  | `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. |
121
141
  | `version:` | String | no | nil | `service.version`, verbatim (git SHA recommended). Missing → warns and continues. Never detected at runtime. |
122
- | `redact_keys:` | Array<String> | no | `[]` | The ONLY field names tail-masked (shape-preserving, e.g. `********f456`). Opt-in — empty (the default) means NO masking. Matches by case-insensitive substring, including keys nested in structured values and query-string keys. |
123
- | `redact_pii_keys:` | Array<String> | no | `[]` | Field names fully erased to `[REDACTED]` (no tail). Opt-in — empty (the default) means NO erasure. Foam ships no preset; this is your enumeration. |
142
+ | `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 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). |
143
+ | `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. |
124
144
  | `additional_span_processors:` | Array | no | `[]` | Tenant seam: constructed SpanProcessor instances added to foam's pipeline (additive; never replace foam's export). See coexistence. |
125
145
  | `additional_log_record_processors:` | Array | no | `[]` | Tenant seam, logs. |
126
146
  | `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
@@ -178,6 +198,86 @@ override is the operator-level `OTEL_EXPORTER_OTLP_ENDPOINT` env var (below).
178
198
 
179
199
  ---
180
200
 
201
+ ## The default credential denylist (the always-on floor)
202
+
203
+ **Fleet ruling 2026-07-26** (binding design:
204
+ `docs/decisions/credential-denylist-design.md`; frozen fixture:
205
+ `contract/credential-denylist.json`). This is the ONE exception to foam's
206
+ raw-by-default capture, and it has **no off switch** — no init option, no env
207
+ var, and no door-2 parameter can disable, shrink, or re-spell it.
208
+
209
+ **What is masked.** The VALUE of every header/attribute/field whose NAME is on
210
+ the frozen list, in every signal (spans — events and links included — logs,
211
+ metrics), on door 1, on all three door-2 ingest taps, and in the view handed
212
+ to tenant `additional_*` processors:
213
+
214
+ - **(a) the seven credential headers**, matched as header names wherever
215
+ headers are captured — including the semconv
216
+ `http.request.header.<name>` / `http.response.header.<name>` span-attribute
217
+ forms: `authorization`, `proxy-authorization`, `cookie`, `set-cookie`,
218
+ `x-api-key`, `x-auth-token`, `www-authenticate`;
219
+ - **(b) the frozen 52-entry key list** of
220
+ `contract/credential-denylist.json` (the deduplicated union of those seven,
221
+ sentry-python's default denylists, and foam's documented reference roots —
222
+ `password`, `token`, `secret`, `api_key`, `ssn`, `jwt`, `private_key`,
223
+ `connect.sid`, `phpsessid`, …), matched as attribute/field names at every
224
+ depth the engine walks, and per-key in URL query strings
225
+ (`?token=x&user=bob` → `token=[REDACTED]&user=bob`).
226
+
227
+ **The matching rule.** Case-insensitive, dash/underscore-normalized **EXACT
228
+ name-equality — never substring**: `authorization` masks the header/field
229
+ `authorization` (any casing, `-`≡`_`) and nothing else — `authorization_url`,
230
+ `x_api_key_id`, `secretary`, and `session_count` all ride RAW. Dotted entries
231
+ match verbatim and whole (`connect.sid` masks; `user.session` does not). This
232
+ is deliberately STRICTER matching than your own `redact_keys` lists, which
233
+ keep their documented substring behavior.
234
+
235
+ **The mask.** The full literal `[REDACTED]` — no tail, no length
236
+ preservation. These are credentials, not debug aids. Semconv header
237
+ attributes (string arrays) mask per element, preserving arity.
238
+
239
+ **Precedence.** The floor runs unconditionally BEFORE your
240
+ `redact_keys`/`redact_pii_keys`, which stay purely additive on top. Listing a
241
+ floor name in `redact_keys` does NOT downgrade it to a tail mask — the floor
242
+ is terminal. `enabled: false` stays fully inert exactly as before (nothing
243
+ exports at all — the floor masks data that leaves; inert mode has none).
244
+
245
+ **Everything else stays RAW.** The floor matches NAMES only — it never scans
246
+ values: bodies, query values, SQL text, LLM content, and PII under unlisted
247
+ names export byte-identical raw, exactly as documented everywhere else in
248
+ this README.
249
+
250
+ **Why.** Coverage-over-masking remains the mission, but raw `Authorization`
251
+ headers, cookies, and API keys on the wire are a breach in waiting for every
252
+ customer at once — the fleet ruling carves out exactly this list (and only
253
+ this list) as non-negotiable. The list is byte-identical in every foam core
254
+ and gate-checked in CI against the fleet fixture
255
+ (`spec/credential_floor_spec.rb`).
256
+
257
+ **Fleet param-name canon** (the customer options above the floor, identical
258
+ concept in every core — the floor itself has NO init surface anywhere):
259
+
260
+ | Concept | js/otel | js/browser | python | ruby | java |
261
+ |---|---|---|---|---|---|
262
+ | secret keys (tail-mask) | `redactKeys` | `redactKeys` | `redact_keys` | `redact_keys:` | `.redactKeys(String…)` |
263
+ | PII keys (full `[REDACTED]`) | `redactPiiKeys` | `redactPiiKeys` | `redact_pii_keys` | `redact_pii_keys:` | `.redactPiiKeys(String…)` |
264
+
265
+ > **Migration note — 1.3.0 (minor): always-on credential masking.** As of
266
+ > this version foam masks, by default and in every signal, the VALUES of a
267
+ > fixed list of credential/secret header and field NAMES (`authorization`,
268
+ > `cookie`, `set-cookie`, `proxy-authorization`, `x-api-key`, `x-auth-token`,
269
+ > `www-authenticate`, and the 52-name key list in
270
+ > `contract/credential-denylist.json` — Sentry-parity plus foam's documented
271
+ > roots) to the literal `[REDACTED]`. Matching is exact name-equality,
272
+ > case-insensitive, dash/underscore-insensitive — never substring:
273
+ > `authorization_url` is untouched. Everything else still exports RAW exactly
274
+ > as before; `redact_keys`/`redact_pii_keys` are unchanged and additive.
275
+ > There is no off switch — if a dashboard keyed off a raw credential value
276
+ > (it should not have), it will now see `[REDACTED]`. Customers who
277
+ > previously received these header/field values raw stop receiving them.
278
+
279
+ ---
280
+
181
281
  ## The helpers
182
282
 
183
283
  All helpers never raise, and no-op silently before `init` and when disabled.
@@ -273,10 +373,13 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: FO
273
373
  > Ruby note: when you configure `redact_keys`/`redact_pii_keys`, foam's HELPERS
274
374
  > apply the key pass at capture, so a tenant processor receives foam-helper data
275
375
  > (span/log helper attributes, log bodies, metric labels) ALREADY masked.
276
- > Because the Ruby SDK freezes span attributes at finish, a tenant
277
- > processor sees third-party instrumentation attributes UNMASKED (for those,
278
- > foam's key pass runs at the exporter boundary GOTCHAS F1). With no keys
279
- > configured (the default) nothing is masked on either path.
376
+ > The Ruby SDK freezes span attributes at finish, so historically a tenant
377
+ > processor saw third-party instrumentation attributes UNMASKED as of 1.3.0
378
+ > that gap is closed: tenant span/log processors are handed a READ-ONLY MASKED
379
+ > VIEW (the credential floor plus your configured keys, the same pass foam's
380
+ > own export applies — GOTCHAS F1). A tenant never sees a raw floor value.
381
+ > With no keys configured (the default) only the credential floor is masked
382
+ > on either path.
280
383
  >
281
384
  > Loop-guard note: `ignored_outbound_hosts` suppresses spans for tenant/agent
282
385
  > egress made through **Net::HTTP or Excon**. A tenant exporter using
@@ -314,9 +417,10 @@ end
314
417
 
315
418
  **Outcome**: their pipeline keeps working exactly as before — their data,
316
419
  their resource, their export, byte-identical — and foam ALSO receives the
317
- signal, carrying THEIR resource identity plus foam's export-time stamp and
318
- any per-tap `redact_keys`/`redact_pii_keys` you set (opt-in none by default;
319
- details in the Door 2 section).
420
+ signal, carrying THEIR resource identity plus foam's export-time stamp, the
421
+ always-on credential floor applied to foam's copy, and any per-tap
422
+ `redact_keys`/`redact_pii_keys` you set (opt-in none by default; details in
423
+ the Door 2 section).
320
424
 
321
425
  **Unhappy paths**:
322
426
 
@@ -416,8 +520,10 @@ readers only: they never mutate their data, their resource, or their export.
416
520
  ### The three entries
417
521
 
418
522
  All three share one signature (`token:` and `environment:` required; the
419
- redaction kwargs are the ONLY keys that tap redacts — opt-in, none by default,
420
- scoped to that tap; `diagnostics:` is tap-scoped narration). Construction
523
+ redaction kwargs are the only CUSTOMER keys that tap redacts — opt-in, none by
524
+ default, scoped to that tap, additive above the always-on credential floor,
525
+ which every tap applies with zero configuration; `diagnostics:` is tap-scoped
526
+ narration). Construction
421
527
  validates loudly at boot and NEVER
422
528
  throws afterwards — a failure inside a tap can never break their pipeline.
423
529
  Fleet canon mapping (pinned by `spec/export_surface_spec.rb`):
@@ -438,9 +544,10 @@ their_tracer_provider.add_span_processor(processor)
438
544
  # raises ArgumentError at construction for a blank token/environment
439
545
  ```
440
546
 
441
- **Logs** — a LogRecordProcessor for THEIR LoggerProvider. The tap's
442
- `redact_keys`/`redact_pii_keys` (if any) match over record attributes and keys
443
- nested in structured bodies; free-text bodies ride RAW (no value-pattern pass):
547
+ **Logs** — a LogRecordProcessor for THEIR LoggerProvider. The credential
548
+ floor and the tap's `redact_keys`/`redact_pii_keys` (if any) match over
549
+ record attributes and keys nested in structured bodies; free-text bodies ride
550
+ RAW (no value-pattern pass):
444
551
 
445
552
  ```ruby
446
553
  provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: their_resource)
@@ -484,7 +591,7 @@ in THEIR pipeline. Three layers keep it closed:
484
591
  | | their pipeline | foam's copy |
485
592
  | --- | --- | --- |
486
593
  | `service.name` / `service.version` / `service.instance.id` | theirs | theirs, untouched (the data carries THEIR identity) |
487
- | all attributes / events / bodies | raw, byte-identical | your per-tap `redact_keys`/`redact_pii_keys` applied (default: none identical to theirs) |
594
+ | all attributes / events / bodies | raw, byte-identical | the credential floor + your per-tap `redact_keys`/`redact_pii_keys` applied (default: floor only — everything off the frozen list identical to theirs) |
488
595
  | `deployment.environment.name` | theirs (if any) | the tap's `environment:` argument (stamped at export time) |
489
596
  | `foam.ingest.tier` | absent | `"external"` (the wire-contract marker) |
490
597
  | `telemetry.distro.name` / `telemetry.distro.version` | absent | `"foam"` / the gem version (support reads the tap's presence and version from telemetry alone) |
data/RESEARCH.md CHANGED
@@ -144,13 +144,30 @@ statement posture is **captured RAW, never suppressed** (2026-07-26 ruling —
144
144
  coverage is the mission), and the upstream `-pg`/`-mysql2`/`-redis`/`-mongo`
145
145
  gems all DEFAULT to `db_statement: :obfuscate` — a sanitizer — so foam
146
146
  explicitly activates them with `db_statement: :include` (GOTCHAS F9; pinned
147
- by `spec/floor_spec.rb`). Redaction stays fully opt-in, applied only for keys
148
- the customer lists in `redact_keys`/`redact_pii_keys`. An FDE can still clamp
147
+ by `spec/floor_spec.rb`). Redaction stays fully opt-in above the always-on
148
+ credential floor (the frozen NAME list of
149
+ `contract/credential-denylist.json` — statement TEXT is never scanned),
150
+ applied only for keys the customer lists in
151
+ `redact_keys`/`redact_pii_keys`. An FDE can still clamp
149
152
  `db_statement: :omit`/`:obfuscate` per customer via the standard
150
153
  `OTEL_RUBY_INSTRUMENTATION_<NAME>_CONFIG_OPTS` env var when a compliance
151
154
  context calls for it — per-customer tightening (rule 18 / Area 4), not the
152
155
  default.
153
156
 
157
+ **Sidekiq queue-hop posture (documented, GOTCHAS G16):** `-sidekiq 0.29.0`
158
+ defaults `propagation_style: :link` (installed source `instrumentation.rb:104`)
159
+ — a performed job is a SEPARATE trace whose consumer span LINKS the enqueuing
160
+ producer span. Foam ships the upstream default untouched
161
+ (`FLOOR_INSTRUMENTATION_CONFIG` overrides only the datastore raw-capture
162
+ posture); the continuous web→job shape is one env lever away
163
+ (`OTEL_RUBY_INSTRUMENTATION_SIDEKIQ_CONFIG_OPTS='propagation_style=child'`).
164
+ Context always survives the hop. Wire-proven per rule 8b against a REAL
165
+ redis-server with the REAL sidekiq gem (enqueue → Redis → embedded-server
166
+ perform; producer/consumer spans + the link asserted from the exporter):
167
+ `spec/floor_sidekiq_wire_spec.rb`. The Faraday floor entry carries the same
168
+ rule-8b emission proof against a local listener:
169
+ `spec/floor_wire_spec.rb`.
170
+
154
171
  **Runtime + GC metrics (hand-written — landed,
155
172
  `lib/foam/otel/runtime_metrics.rb`).** Ruby has no drop-in upstream metrics
156
173
  instrumentation gem (unlike Python's `-system-metrics` or the Node
@@ -165,8 +182,19 @@ construction (rule 26). Tests: `spec/runtime_metrics_spec.rb`.
165
182
 
166
183
  **Excluded (with reason):** `-factory_bot`, `-rspec` (test-only, no production
167
184
  value); `-net_ldap` (niche, PII/credential-adjacent — explicit FDE opt-in);
185
+ `-logger 0.4.0` (SUPERSEDED by foam's own stdlib Logger bridge, §4: both
186
+ patch the SAME `Logger#add` seam, and foam's bridge keeps the lazy-block
187
+ contract, both loop guards, the logs-slot gating, and the opt-in redaction
188
+ path the contrib bridge lacks. Two bridges on one seam would export every
189
+ host log line TWICE — rule 12 never-double-attach — so init's bundle-driven
190
+ sweep pins the contrib gem disabled (`SUPERSEDED_INSTRUMENTATION_CONFIG`,
191
+ `lib/foam/otel/init.rb`; proven by `spec/floor_spec.rb`); an FDE who
192
+ deliberately wants the contrib bridge instead passes its instance via
193
+ `additional_instrumentations`, which bypasses the sweep map);
168
194
  `all`/`base` (not instrumentations — foam deliberately does NOT depend on the
169
- `all` meta-gem, which would drag test gems).
195
+ `all` meta-gem, which would drag test gems). With the 17-gem floor, the 27
196
+ when-present gems, and these 4 exclusions, all 48 registry gems have a
197
+ written disposition (rule 6: no silent omissions).
170
198
 
171
199
  **Known-bad / crash history (rule 6 / R3):** action_pack overrides rack config
172
200
  (contrib #88); active_record 0.2.2 failed on Rails 7 (#38); `-que` NoMethodError
@@ -185,7 +213,8 @@ foam ships the logs pipeline + `log()` helper (trace-correlated) — the mechani
185
213
  rule 20 requires. The stdlib **Logger bridge is LANDED as foam's own code**
186
214
  (`lib/foam/otel/logger_bridge.rb`, console-logs ruling 2026-07-26): one
187
215
  prepend on `Logger#add` turns every message a host logger accepts into a
188
- trace-correlated OTel log record — body RAW (opt-in key-masking only),
216
+ trace-correlated OTel log record — body RAW (name-keyed masking only: the
217
+ always-on credential floor plus any customer keys; free text never scanned),
189
218
  severity mapped, loop-guarded (the OTel SDK's internal logger is skipped by
190
219
  identity + a thread-local re-entrancy flag), gated on foam owning the logs
191
220
  slot, and never able to break the host's log call (GOTCHAS F10; tests
@@ -197,6 +226,15 @@ one prepend covers most framework logging in practice;
197
226
  https://github.com/open-telemetry/opentelemetry-ruby/discussions/1789 is
198
227
  open/unresolved) — a documented gap, narrowed to the non-Logger-backed exotics.
199
228
 
229
+ The official contrib `opentelemetry-instrumentation-logger 0.4.0` bridges the
230
+ same `Logger#add` seam and is deliberately NOT bundled — census disposition
231
+ EXCLUDED/superseded (§3): foam's bridge additionally holds the lazy-block
232
+ contract, the OTel-internal-logger identity guard, the re-entrancy flag, the
233
+ logs-slot gate, and the redaction path (credential floor + opt-in keys), and
234
+ shipping/sweeping both
235
+ would double-export every host log line (rule 12). init's sweep pins the
236
+ contrib gem disabled even when an FDE bundles it (`spec/floor_spec.rb`).
237
+
200
238
  ---
201
239
 
202
240
  ## 5. LLM ecosystem (rule 31)
@@ -210,7 +248,16 @@ shims patch the SDKs' public call sites — official `openai`
210
248
  (messages.create), `gemini-ai` (generate_content / stream_generate_content;
211
249
  Google ships no official Ruby SDK), and `ruby_llm` (Provider#complete, one
212
250
  seam covering all its providers) — emitting the same GenAI semconv attribute
213
- set the fleet's js/python gap-fillers emit. **LLM traces are captured in
251
+ set the fleet's js/python gap-fillers emit. `gen_ai.operation.name` matches
252
+ the fleet per API shape: `chat` on the chat seams, `generate_content` on the
253
+ Gemini shim (aligned in 1.2.1 — Ruby previously emitted `chat` for Gemini, a
254
+ cross-language vocabulary split against the js shim and python's official
255
+ google-genai instrumentor). Agent-loop tool activity is captured on the
256
+ ruby_llm seam too: assistant tool-call turns serialize their `tool_calls`
257
+ (id/name/arguments) and tool-result turns their `tool_call_id` into
258
+ `gen_ai.input.messages`/`gen_ai.output.messages`
259
+ (`spec/llm_ruby_llm_spec.rb` drives a real `chat.with_tool` loop on the
260
+ wire); the Anthropic shim carries tool_use blocks verbatim inside content. **LLM traces are captured in
214
261
  full — activity (model, response id, finish reasons, tokens, latency) AND
215
262
  prompt/completion content, raw by default** (2026-07-26 ruling: LLM content
216
263
  is never gated or dropped; the FDE reactive clamp is the only per-customer
@@ -247,38 +294,59 @@ https://opentelemetry.io/docs/specs/semconv/gen-ai/aws-bedrock/ ; traceloop Ruby
247
294
 
248
295
  ## 6. Redaction design record (rule 14, as amended 2026-07-26)
249
296
 
250
- **Redaction is FULLY OPT-IN the default is ZERO redaction** (owner ruling
251
- 2026-07-26, holistic-redesign decision log; `lib/foam/otel/redaction.rb`).
252
- With neither `redact_keys` nor `redact_pii_keys` set (or both empty) every
253
- value is captured RAW — attributes, headers, bodies, URL query params,
254
- `db.statement`, log bodies including secrets and PII. There is NO always-on
255
- secrets floor and NO value-pattern auto-masking; the package auto-masks
256
- nothing and ships no preset. The customer owns the privacy/legal posture; the
257
- FDE reactive clamp is the per-customer valve.
258
-
259
- **The retired floor**: earlier revisions shipped an always-on researched
260
- secrets floor in `constants.rb` (roots + value-pattern pass, sourced from the
261
- OSS-scrubber consensus raven-ruby, sentry, Elastic APM, Rails
262
- filter_parameters, gitleaks et al. modeled on the Node core). It was
263
- REMOVED by the 2026-07-26 ruling; the provenance record lives in this file's
264
- git history and the decision log. Do not reintroduce it without a new ruling.
265
-
266
- **The current engine** (`redaction.rb`): redaction runs ONLY over the keys
267
- the customer enumerates. `redact_keys` matches tail mask, shape-preserving
268
- (`********` + last 4 when 12 chars; the fixed 8-char body never reveals
269
- length; non-scalars redact in full); `redact_pii_keys` matches → full
270
- `[REDACTED]`, no tail. Matching is SUBSTRING, case-insensitive, over the
271
- lowercased key the customer's lists, nothing inferred. The pass runs at the
272
- exporter boundary (`redacting_exporter.rb` — the Ruby SDK freezes span
273
- attributes at finish before any `on_finish` processor runs, so the exporter
274
- boundary is the seam; see GOTCHAS) and is a true pass-through when no keys
275
- are configured. The explicit `redact(value)` helper always masks (calling it
276
- is the opt-in). Fail-closed for listed keys (mask on any error, cycle + depth
277
- guarded).
278
-
279
- Proven by `spec/redaction_spec.rb` (opt-in defaults nothing masked with no
280
- keys; both mask shapes; fail-closed) and `spec/redacting_exporter_spec.rb` +
281
- the conformance app (default-path RAW capture on the wire).
297
+ **Redaction is FULLY OPT-IN above the CREDENTIAL FLOOR** (owner ruling
298
+ 2026-07-26, holistic-redesign decision log, AMENDED the same day by the
299
+ credential-floor fleet ruling `docs/decisions/credential-denylist-design.md`;
300
+ `lib/foam/otel/redaction.rb`). With neither `redact_keys` nor
301
+ `redact_pii_keys` set (or both empty) every value is captured RAW
302
+ attributes, bodies, URL query params, `db.statement`, log bodies — including
303
+ PII, EXCEPT the one always-on exception: the CREDENTIAL FLOOR masks the
304
+ values of the frozen credential/secret header + key NAME list
305
+ (`contract/credential-denylist.json`: the seven headers + 52 keys, constants
306
+ `CREDENTIAL_HEADER_DENYLIST`/`CREDENTIAL_KEY_DENYLIST` in `constants.rb`) to
307
+ the literal `[REDACTED]`, in every signal, on every door, with NO off switch.
308
+ There is still NO value-pattern auto-masking and NO PII preset; above the
309
+ floor the customer owns the privacy/legal posture; the FDE reactive clamp is
310
+ the per-customer valve.
311
+
312
+ **The retired floor vs the credential floor**: earlier revisions shipped an
313
+ always-on researched secrets floor in `constants.rb` (roots + value-pattern
314
+ pass, sourced from the OSS-scrubber consensus raven-ruby, sentry, Elastic
315
+ APM, Rails filter_parameters, gitleaks et al. modeled on the Node core). It
316
+ was REMOVED by the 2026-07-26 zero-default ruling; the provenance record
317
+ lives in this file's git history and the decision log. The SAME-DAY
318
+ credential-floor ruling is the new fleet ruling that reintroduces exactly ONE
319
+ always-on list — the frozen credential denylist (name-match only, never
320
+ value patterns; rule-14 reference roots absorbed as entries). Its content
321
+ changes only by a new fleet ruling and is CI-gated bit-for-bit against the
322
+ fleet fixture (`spec/credential_floor_spec.rb`).
323
+
324
+ **The current engine** (`redaction.rb`): the floor predicate
325
+ (`floor_kind`/`floor_match?`) runs FIRST and is TERMINAL — canon-exact
326
+ matching (trim, ASCII-lowercase, `-`→`_` fold; NEVER substring:
327
+ `authorization` never masks `authorization_url`), including the semconv
328
+ `http.{request,response}.header.<name>` prefix peel. Customer redaction then
329
+ runs ONLY over the keys the customer enumerates. `redact_keys` matches → tail
330
+ mask, shape-preserving (`********` + last 4 when ≥ 12 chars; the fixed 8-char
331
+ body never reveals length; non-scalars redact in full); `redact_pii_keys`
332
+ matches → full `[REDACTED]`, no tail. CUSTOMER matching is SUBSTRING,
333
+ case-insensitive, over the lowercased key — the customer's lists, nothing
334
+ inferred (the FLOOR deliberately does NOT ride the substring matcher — it
335
+ would over-match). The pass runs at the exporter boundary
336
+ (`redacting_exporter.rb` — the Ruby SDK freezes span attributes at finish
337
+ before any `on_finish` processor runs, so the exporter boundary is the seam;
338
+ see GOTCHAS) and runs UNCONDITIONALLY (the floor has no off state); values
339
+ under unmatched names ride through raw, and tenant `additional_*` processors
340
+ receive the masked VIEW (`pipelines.rb`, GOTCHAS F1). The explicit
341
+ `redact(value)` helper always masks (calling it is the opt-in). Fail-closed
342
+ for floor + listed keys (mask on any error, cycle + depth guarded).
343
+
344
+ Proven by `spec/credential_floor_spec.rb` + `spec/credential_floor_wire_spec.rb`
345
+ (the floor: list identity gate, matcher, every signal, doors 1+2, tenant
346
+ view, negative controls), `spec/redaction_spec.rb` (raw-above-the-floor
347
+ defaults — nothing else masked with no keys; both mask shapes; fail-closed)
348
+ and `spec/redacting_exporter_spec.rb` + the conformance app (default-path RAW
349
+ capture on the wire, floor names excepted).
282
350
 
283
351
  ---
284
352
 
data/lib/foam/otel/api.rb CHANGED
@@ -105,13 +105,14 @@ module Foam
105
105
  span = OpenTelemetry::Trace.current_span
106
106
  return false unless span.respond_to?(:recording?) && span.recording?
107
107
 
108
- # Capture-time key pass (rule 18 C.3): when the customer configured
109
- # redact_keys/redact_pii_keys the value lands ALREADY-MASKED, so tenant
110
- # processors and inert-mode providers only ever see the redacted view of
111
- # helper-set data. The exporter pass re-applies (idempotent). With no
112
- # keys configured this is a pass-through the value is captured RAW.
113
- # scrub_utf8 is encoding hygiene, not masking: an invalid-UTF-8 value
114
- # would make the upstream OTLP encoder drop the WHOLE batch (rule 15).
108
+ # Capture-time pass (rule 18 C.3): the always-on credential floor plus
109
+ # any configured redact_keys/redact_pii_keys, so the value lands
110
+ # ALREADY-MASKED — tenant processors and inert-mode providers only ever
111
+ # see the redacted view of helper-set data. The exporter pass
112
+ # re-applies (idempotent). Values under unmatched names are captured
113
+ # RAW. scrub_utf8 is encoding hygiene, not masking: an invalid-UTF-8
114
+ # value would make the upstream OTLP encoder drop the WHOLE batch
115
+ # (rule 15).
115
116
  span.set_attribute(key.to_s, Redaction.mask_one(key.to_s, Redaction.scrub_utf8(value), active_config))
116
117
  true
117
118
  rescue StandardError, SystemStackError
@@ -260,14 +261,14 @@ module Foam
260
261
  LEVEL_TEXT[severity.to_s.downcase.to_sym]
261
262
  end
262
263
 
263
- # Stringify keys, then apply the customer's key lists AT CAPTURE (rule 18
264
- # C.3): when redact_keys/redact_pii_keys are configured, helper-set
265
- # attributes are ALREADY-MASKED when they reach the span/log record, so a
266
- # tenant processor (or a foreign provider in inert mode) only ever sees the
267
- # redacted view of foam-helper data. The exporter-boundary pass re-applies
268
- # — masking is idempotent. With no keys configured this is a pass-through
269
- # (raw capture). NEVER throws (rule 9): non-enumerable garbage degrades to
270
- # nil (attributes dropped, telemetry still flows).
264
+ # Stringify keys, then apply the credential floor + the customer's key
265
+ # lists AT CAPTURE (rule 18 C.3): helper-set attributes are
266
+ # ALREADY-MASKED when they reach the span/log record, so a tenant
267
+ # processor (or a foreign provider in inert mode) only ever sees the
268
+ # redacted view of foam-helper data. The exporter-boundary pass
269
+ # re-applies — masking is idempotent. Values under unmatched names ride
270
+ # RAW. NEVER throws (rule 9): non-enumerable garbage degrades to nil
271
+ # (attributes dropped, telemetry still flows).
271
272
  def stringify(attributes)
272
273
  return nil if attributes.nil?
273
274
 
@@ -279,10 +280,11 @@ module Foam
279
280
  nil
280
281
  end
281
282
 
282
- # The customer key pass over a log BODY at capture (same rationale as
283
- # stringify). When keys are configured mask_body is fail-closed and
284
- # depth-guarded, so a cyclic structure is bounded before it can reach any
285
- # encoder; with no keys configured the body is captured RAW.
283
+ # The floor + customer key pass over a log BODY at capture (same
284
+ # rationale as stringify). mask_body is fail-closed and depth-guarded,
285
+ # so a cyclic structure is bounded before it can reach any encoder; a
286
+ # free-text body (and every structured field under an unmatched name)
287
+ # is captured RAW.
286
288
  def mask_log_body(body)
287
289
  return body if body.nil?
288
290
 
@@ -42,8 +42,10 @@ module Foam
42
42
  environment: environment,
43
43
  version: version,
44
44
  enabled: enabled,
45
- # Opt-in redaction (holistic redesign): the ONLY keys ever redacted.
46
- # Empty (the default) zero redaction, raw capture. Stored lowercased
45
+ # Opt-in CUSTOMER redaction (holistic redesign), additive above the
46
+ # always-on credential floor (which is a module constant, NOT config
47
+ # state — no config shape can alter it). Empty (the default) → no
48
+ # customer redaction, raw capture above the floor. Stored lowercased
47
49
  # for the engine's case-insensitive substring match.
48
50
  redact_keys: downcase_list(redact_keys),
49
51
  redact_pii_keys: downcase_list(redact_pii_keys),