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 +4 -4
- data/GOTCHAS.md +60 -28
- data/README.md +130 -23
- data/RESEARCH.md +105 -37
- data/lib/foam/otel/api.rb +21 -19
- data/lib/foam/otel/config.rb +4 -2
- data/lib/foam/otel/constants.rb +103 -10
- data/lib/foam/otel/init.rb +18 -1
- data/lib/foam/otel/llm/gemini_shim.rb +13 -4
- data/lib/foam/otel/llm/ruby_llm_shim.rb +42 -7
- data/lib/foam/otel/pipelines.rb +89 -12
- data/lib/foam/otel/redacting_exporter.rb +19 -16
- data/lib/foam/otel/redaction.rb +176 -55
- data/lib/foam/otel/version.rb +22 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82f1791c9e7d9929a655760c4e2f67295bb70f55dee75a2738bfcae33f5658f6
|
|
4
|
+
data.tar.gz: 16917b6e7a4e1dc1f0c6160f7026d4fe90c6213a16fa0664807e459359c5ea92
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
- **
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
`
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
`
|
|
259
|
-
`
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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/
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
`spec/
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
627
|
-
redact_keys" — pins that unlisted attributes pass
|
|
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
|
|
8
|
-
value RAW** (no
|
|
9
|
-
|
|
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
|
|
79
|
-
attributes, URLs/query strings, DB statement text, log
|
|
80
|
-
is captured RAW
|
|
81
|
-
|
|
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`).
|
|
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
|
|
123
|
-
| `redact_pii_keys:` | Array<String> | no | `[]` | Field names fully erased to `[REDACTED]` (no tail). Opt-in — empty (the default) means
|
|
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
|
-
>
|
|
277
|
-
> processor
|
|
278
|
-
>
|
|
279
|
-
>
|
|
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
|
|
318
|
-
|
|
319
|
-
|
|
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
|
|
420
|
-
scoped to that tap
|
|
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
|
|
442
|
-
`redact_keys`/`redact_pii_keys` (if any) match over
|
|
443
|
-
nested in structured bodies; free-text bodies ride
|
|
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:
|
|
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
|
|
148
|
-
|
|
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 (
|
|
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.
|
|
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
|
|
251
|
-
2026-07-26, holistic-redesign decision log
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
`
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
109
|
-
# redact_keys/redact_pii_keys the value lands
|
|
110
|
-
# processors and inert-mode providers only ever
|
|
111
|
-
# helper-set data. The exporter pass
|
|
112
|
-
#
|
|
113
|
-
# scrub_utf8 is encoding hygiene, not masking: an invalid-UTF-8
|
|
114
|
-
# would make the upstream OTLP encoder drop the WHOLE batch
|
|
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
|
|
264
|
-
# C.3):
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
# redacted view of foam-helper data. The exporter-boundary pass
|
|
268
|
-
# — masking is idempotent.
|
|
269
|
-
#
|
|
270
|
-
#
|
|
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
|
|
283
|
-
# stringify).
|
|
284
|
-
#
|
|
285
|
-
#
|
|
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
|
|
data/lib/foam/otel/config.rb
CHANGED
|
@@ -42,8 +42,10 @@ module Foam
|
|
|
42
42
|
environment: environment,
|
|
43
43
|
version: version,
|
|
44
44
|
enabled: enabled,
|
|
45
|
-
# Opt-in redaction (holistic redesign)
|
|
46
|
-
#
|
|
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),
|