foam-otel 2.1.1 → 3.0.0.alpha.2

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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +596 -1029
  3. data/lib/foam/otel/internal/before_send.rb +75 -0
  4. data/lib/foam/otel/internal/body_capture.rb +274 -0
  5. data/lib/foam/otel/internal/body_decoder.rb +116 -0
  6. data/lib/foam/otel/internal/body_redactor.rb +358 -0
  7. data/lib/foam/otel/internal/constants.rb +73 -0
  8. data/lib/foam/otel/internal/content_type.rb +90 -0
  9. data/lib/foam/otel/internal/diagnostics.rb +61 -0
  10. data/lib/foam/otel/internal/endpoint.rb +28 -0
  11. data/lib/foam/otel/internal/excon/capture.rb +99 -0
  12. data/lib/foam/otel/internal/exporters.rb +193 -0
  13. data/lib/foam/otel/internal/http_client/capture.rb +103 -0
  14. data/lib/foam/otel/internal/ignored_hosts.rb +71 -0
  15. data/lib/foam/otel/internal/ingest.rb +83 -0
  16. data/lib/foam/otel/internal/init.rb +201 -0
  17. data/lib/foam/otel/internal/instrumentations.rb +163 -0
  18. data/lib/foam/otel/internal/lifecycle.rb +40 -0
  19. data/lib/foam/otel/internal/logs.rb +35 -0
  20. data/lib/foam/otel/internal/metrics.rb +55 -0
  21. data/lib/foam/otel/internal/net_http/capture.rb +81 -0
  22. data/lib/foam/otel/internal/otlp.rb +63 -0
  23. data/lib/foam/otel/internal/own_request.rb +27 -0
  24. data/lib/foam/otel/internal/propagation.rb +55 -0
  25. data/lib/foam/otel/internal/rack/body_tee.rb +68 -0
  26. data/lib/foam/otel/internal/rack/input_tee.rb +113 -0
  27. data/lib/foam/otel/internal/redactor/keys.rb +162 -0
  28. data/lib/foam/otel/internal/redactor.rb +259 -0
  29. data/lib/foam/otel/internal/report.rb +36 -0
  30. data/lib/foam/otel/internal/resource.rb +121 -0
  31. data/lib/foam/otel/internal/safely.rb +20 -0
  32. data/lib/foam/otel/internal/state.rb +166 -0
  33. data/lib/foam/otel/internal/traces.rb +22 -0
  34. data/lib/foam/otel/internal.rb +20 -0
  35. data/lib/foam/otel/version.rb +1 -267
  36. data/lib/foam/otel.rb +205 -29
  37. data/lib/foam/rack/middleware.rb +92 -0
  38. data/lib/foam-otel.rb +1 -2
  39. metadata +90 -306
  40. data/GOTCHAS.md +0 -1185
  41. data/LICENSE +0 -24
  42. data/RESEARCH.md +0 -427
  43. data/THIRD-PARTY-NOTICES +0 -76
  44. data/lib/foam/otel/api.rb +0 -321
  45. data/lib/foam/otel/before_send.rb +0 -287
  46. data/lib/foam/otel/classifier.rb +0 -89
  47. data/lib/foam/otel/config.rb +0 -296
  48. data/lib/foam/otel/constants.rb +0 -415
  49. data/lib/foam/otel/diagnostics.rb +0 -28
  50. data/lib/foam/otel/errors.rb +0 -58
  51. data/lib/foam/otel/fork_hooks.rb +0 -54
  52. data/lib/foam/otel/header_capture.rb +0 -416
  53. data/lib/foam/otel/ingest.rb +0 -649
  54. data/lib/foam/otel/ingest_metric_reader.rb +0 -87
  55. data/lib/foam/otel/init.rb +0 -782
  56. data/lib/foam/otel/instance.rb +0 -45
  57. data/lib/foam/otel/isolated_exporters.rb +0 -239
  58. data/lib/foam/otel/isolated_http_client.rb +0 -586
  59. data/lib/foam/otel/llm/anthropic_shim.rb +0 -84
  60. data/lib/foam/otel/llm/gemini_shim.rb +0 -122
  61. data/lib/foam/otel/llm/http_anthropic_shim.rb +0 -318
  62. data/lib/foam/otel/llm/openai_shim.rb +0 -123
  63. data/lib/foam/otel/llm/ruby_llm_shim.rb +0 -166
  64. data/lib/foam/otel/llm/ruby_openai_shim.rb +0 -138
  65. data/lib/foam/otel/llm.rb +0 -329
  66. data/lib/foam/otel/logger_bridge.rb +0 -187
  67. data/lib/foam/otel/metrics.rb +0 -137
  68. data/lib/foam/otel/payload_capture.rb +0 -655
  69. data/lib/foam/otel/pipelines.rb +0 -311
  70. data/lib/foam/otel/prepend_collision_shield.rb +0 -193
  71. data/lib/foam/otel/redacting_exporter.rb +0 -135
  72. data/lib/foam/otel/redaction.rb +0 -1134
  73. data/lib/foam/otel/resource.rb +0 -47
  74. data/lib/foam/otel/runtime_metrics.rb +0 -120
  75. data/lib/foam/otel/session_stitching.rb +0 -112
data/README.md CHANGED
@@ -1,1145 +1,712 @@
1
- # foam-otel (Ruby)
2
-
3
- Foam's OpenTelemetry core for Ruby services. A thin, safe wrapper over the
4
- official OpenTelemetry libraries: foam owns the pipeline (providers, batch
5
- processors, OTLP export to the foam endpoint), turns on automatic
6
- tier-1/2 instrumentation, and hands you a small set of never-throw helpers —
7
- and nothing else. **Redaction is fully opt-in above two always-on exceptions:
8
- by default foam captures every value RAW** (no PII preset) **except (1) the
9
- credential floor** — a fixed list of credential/secret header and field NAMES
10
- always masked to `[REDACTED]` (see "The default credential denylist" below) —
11
- **and (2) the value-pattern secret layer** — credential-SHAPED value spans
12
- (cloud keys, tokens, JWTs, private-key blocks, `user:pass@` URLs…) masked to
13
- `[REDACTED]` regardless of field name (see "The value-pattern secret layer"
14
- below); you enumerate any further fields to mask via
15
- `redact_keys`/`redact_pii_keys`.
16
-
17
- Built to `docs/BASE_PACKAGE_SPEC.md`. This README is the manual you integrate
18
- from.
19
-
20
- Foam builds on the OpenTelemetry authors' work (https://opentelemetry.io) — see
21
- `THIRD-PARTY-NOTICES`. Licensed, not sold — see `LICENSE`.
22
-
23
- ---
1
+ # Foam OpenTelemetry SDK for Ruby
2
+
3
+ `foam-otel` is Foam's OpenTelemetry distribution for Ruby.
4
+
5
+ ## Requirements
6
+
7
+ - Ruby 3.3 or newer
8
+ - Foam ingest token, stored as `FOAM_OTEL_TOKEN`
24
9
 
25
10
  ## Install
26
11
 
12
+ Add the gem to the `Gemfile`:
13
+
27
14
  ```ruby
28
- # Gemfile
29
- gem "foam-otel"
30
- # foam-otel bundles the UNIVERSAL FLOOR and auto-activates each piece when its
31
- # target library is present (presence-checked, and gated on foam owning the
32
- # slot it produces into): Rack + the Rails family, the HTTP clients (Net::HTTP,
33
- # Faraday), the primary datastores (pg, mysql2, redis, mongo) with RAW
34
- # db.statement capture, Sidekiq, the stdlib Logger bridge, hand-written Ruby
35
- # runtime + GC metrics, session stitching (browser session.id via baggage),
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:
41
- gem "opentelemetry-instrumentation-graphql" # e.g. GraphQL
42
- gem "opentelemetry-instrumentation-resque" # e.g. Resque
15
+ gem "foam-otel", "~> 3.0.0.alpha"
16
+ ```
17
+
18
+ Then run:
19
+
20
+ ```sh
21
+ bundle install
43
22
  ```
44
23
 
45
- **Supported versions** (tested in CI, `spec/version_spec.rb`): Ruby **>= 3.4**
46
- (the version the CI matrix runs — the floor follows the tested matrix);
47
- OpenTelemetry API **`opentelemetry-api` ~> 1.1** (1.x). The metrics and logs
48
- SDKs are pre-1.0 upstream (metrics alpha, logs development) and are pinned with
49
- pessimistic constraints; foam wraps them behind stable helpers so app code never
50
- touches the churning API (see GOTCHAS R2).
24
+ ## Initialize
25
+
26
+ Call `init` once, before the app loads, so OpenTelemetry can instrument
27
+ libraries as they are required. Set `enabled: false` to turn Foam off.
28
+
29
+ Store the ingest token as `FOAM_OTEL_TOKEN` and pass `ENV["FOAM_OTEL_TOKEN"]`
30
+ to `init`.
51
31
 
52
- ---
32
+ ### Rails
53
33
 
54
- ## Quick start — scenario 1: a clean service (the common case)
34
+ TODO(jhartquist)
55
35
 
56
- Put `init` FIRST, before your app boots. In Rails, that means an initializer
57
- (and, for cluster mode, per worker — see "Puma / Unicorn" below).
36
+ ### Plain Ruby and Rack
37
+
38
+ Call `init` at the top of `config.ru`, then add the OpenTelemetry Rack
39
+ middleware and Foam's:
58
40
 
59
41
  ```ruby
60
- # config/initializers/foam.rb (the one config module you own)
61
42
  require "foam/otel"
62
43
 
63
- Foam::Otel.init(
64
- name: "checkout-api", # → service.name
65
- environment: ENV.fetch("APP_ENV", "development"), # → deployment.environment.name (verbatim)
66
- enabled: !%w[test ci].include?(ENV["APP_ENV"]), # silence tests/CI; export everywhere else
67
- token: ENV.fetch("FOAM_OTEL_TOKEN"), # required WHEN enabled — wire it explicitly
68
- version: ENV["GIT_SHA"] # → service.version (recommended; optional)
44
+ Foam.init(
45
+ name: "checkout-api",
46
+ environment: ENV["RACK_ENV"],
47
+ enabled: true,
48
+ token: ENV["FOAM_OTEL_TOKEN"]
69
49
  )
70
- ```
71
50
 
72
- **Outcome**: foam registers the tracer/meter/logger providers, exports OTLP to
73
- the foam endpoint, and auto-activates the **universal floor** — the
74
- official rack/rails instrumentation plus the bundled HTTP (Net::HTTP/Faraday),
75
- datastore (pg/mysql2/redis/mongo), and Sidekiq instrumentations — each
76
- presence-gated, and gated on foam owning the traces slot (a foreign-owned
77
- tracer stands the activation down). The datastore set is activated with
78
- **`db_statement: :include`** — foam explicitly overrides the upstream
79
- `:obfuscate` default so RAW SQL / redis args / mongo commands land on the wire.
80
- On top of the contrib set the floor adds foam's own pieces: the **stdlib
81
- Logger bridge** (every host `Logger` line also lands as a trace-correlated
82
- OTel log record, body raw), hand-written Ruby **runtime + GC metrics**
83
- (GC counts + timing, heap slots, allocation, thread count, RSS — Ruby has no
84
- drop-in upstream metrics gem, so foam ships its own collector),
85
- **session stitching** (inbound `baggage: session.id` from the foam browser
86
- package is stamped onto every span and log record), and the **LLM shims**
87
- (below). Every inbound request, DB call, HTTP call, job, log line, and model
88
- call now flows to foam. **Redaction is opt-in above the credential floor: by
89
- default every value — attributes, URLs/query strings, DB statement text, log
90
- bodies, LLM content — is captured RAW, except values whose NAME is on the
91
- always-on credential denylist (masked `[REDACTED]`; see "The default
92
- credential denylist" below).** Pass `redact_keys`/`redact_pii_keys` to mask
93
- or erase further specific fields (see below).
94
-
95
- > **Sidekiq trace shape** (GOTCHAS G16): the bundled instrumentation keeps
96
- > its upstream default `propagation_style: :link` — a performed job runs as
97
- > its OWN trace whose first span LINKS back to the enqueuing span (context is
98
- > always propagated across the hop; proven against a real Redis in
99
- > `spec/floor_sidekiq_wire_spec.rb`). Prefer one continuous web→job trace?
100
- > Set the standard contrib lever:
101
- > `OTEL_RUBY_INSTRUMENTATION_SIDEKIQ_CONFIG_OPTS='propagation_style=child'`.
102
-
103
- ### The LLM surface — OpenAI, Anthropic, Gemini (plus ruby_llm, ruby-openai, and hand-rolled HTTP.rb Anthropic)
104
-
105
- The Ruby contrib registry ships no LLM instrumentation, so foam does: thin,
106
- presence-checked shims over the SDKs' public call sites, emitting the standard
107
- `gen_ai.*` semantic conventions — activity (model, response id, finish
108
- reasons, token usage, latency) AND prompt/response **content, RAW**
109
- (`gen_ai.input.messages` / `gen_ai.output.messages` /
110
- `gen_ai.system_instructions`). Agent tool loops ride in full: tool-call
111
- turns keep their tool names/arguments and tool-result turns keep their
112
- `tool_call_id` linkage inside the message content. `gen_ai.operation.name`
113
- (and the `{operation} {model}` span name) follows the semconv operation for
114
- each API shape — `chat` for the OpenAI/Anthropic/ruby_llm chat seams,
115
- `generate_content` for Gemini's generateContent, identical to foam's
116
- js/python Gemini paths (**wire change in 1.2.1**: Gemini spans previously
117
- said `chat`). Covered: the official `openai` SDK
118
- (chat.completions.create + responses.create), the official `anthropic` SDK
119
- (messages.create), Gemini via the `gemini-ai` gem (Google ships no official
120
- Ruby SDK), `ruby_llm` (one seam covering all its providers), the community
121
- `ruby-openai` gem (`client.chat` / `client.embeddings` — presence is
122
- discriminated from the official gem by call shape, since both reopen the
123
- `OpenAI` namespace; embeddings emit tokens + models, never the input
124
- documents), and hand-rolled HTTP.rb Anthropic clients (`HTTP.post` /
125
- `HTTP.persistent(...).post` to `api.anthropic.com/v1/messages` — exactly
126
- that endpoint; HTTP.rb's non-raising 4xx/5xx become ERROR spans with the
127
- observed status, and foam never consumes a response stream: SSE, chunked,
128
- and oversized bodies pass through untouched, and buffered JSON bodies are
129
- handed back readable). Absent SDK =
130
- inert; version-guarded; fail-to-dark — a shim failure can NEVER break the
131
- model call, and the SDK's own error re-raises identically. One model call =
132
- one `gen_ai` client span (the SDK's HTTP send rides the same suppression
133
- context foam's exporters use; transport gems that ignore that context, like
134
- the contrib `http` instrumentation, nest beneath it instead). Streaming
135
- call sites (`#stream`/`#stream_raw`
136
- on the official SDKs) are a recorded follow-up.
137
-
138
- ### Session stitching — browser `session.id` on backend telemetry
139
-
140
- The foam browser package stamps `session.id` on every browser signal and
141
- sends it to your API as W3C Baggage beside `traceparent`. This gem's floor
142
- copies `session.id` (and `session.previous_id` across a rotation) from the
143
- inbound baggage onto every span and log record foam emits — browser session →
144
- backend traces/logs, stitched with no ingest-side join. No-op when the
145
- baggage is absent. **CORS note (the FDE wires this on every frontend-called
146
- API):** the allowlist must include BOTH headers —
147
- `Access-Control-Allow-Headers: traceparent, baggage`.
148
-
149
- ### Header capture — request + response headers on the official spans
150
-
151
- **Inbound (default-on, no init option):** foam configures the OFFICIAL
152
- `opentelemetry-instrumentation-rack` gem's own header options
153
- (`allowed_request_headers:` / `allowed_response_headers:`) with a
154
- documented **default list** at init, so the rack SERVER span carries the
155
- listed request and response headers as
156
- `http.request.header.<name>` / `http.response.header.<name>` attributes
157
- (the gem's form: lowercase with `-` folded to `_`, e.g.
158
- `http.request.header.x_request_id`), on error requests too.
159
-
160
- > **Honest limit (deliberate divergence from foam's js/python cores):**
161
- > Ruby inbound captures the **named default list below, NOT all headers** —
162
- > the official gem's options are an enumerated allowlist with no
163
- > capture-all form, and that official option is the chosen seam.
164
- > A custom header outside the list is **not captured** unless
165
- > you extend the list (recipe below).
166
-
167
- The default lists (`Foam::Otel::HeaderCapture::DEFAULT_REQUEST_HEADERS` /
168
- `DEFAULT_RESPONSE_HEADERS` — frozen, documented constants):
169
-
170
- - **request** — `content-type`, `content-length`, `content-encoding`,
171
- `accept`, `accept-charset`, `accept-encoding`, `accept-language`,
172
- `user-agent`, `referer`, `origin`, `host`, `cache-control`, `pragma`,
173
- `if-none-match`, `if-modified-since`, `range`, `via`, `forwarded`,
174
- `x-forwarded-for`, `x-forwarded-proto`, `x-forwarded-host`,
175
- `x-forwarded-port`, `x-real-ip`, `x-request-id`, `x-correlation-id`,
176
- plus the request half of the credential floor — `authorization`,
177
- `proxy-authorization`, `cookie`, `x-api-key`, `x-auth-token`;
178
- - **response** — `content-type`, `content-length`, `content-encoding`,
179
- `content-language`, `content-range`, `cache-control`, `pragma`,
180
- `expires`, `age`, `etag`, `last-modified`, `vary`, `location`,
181
- `retry-after`, `x-request-id`, `x-correlation-id`, `x-runtime`,
182
- `server-timing`, plus the response half of the floor — `set-cookie`,
183
- `www-authenticate`.
184
-
185
- Propagation headers (`traceparent`, `tracestate`, `baggage`) are
186
- deliberately not listed — they are extracted as span context, not captured
187
- as attributes; hop-by-hop plumbing (`connection`, `keep-alive`, …) is
188
- skipped too.
189
-
190
- **Extending (or narrowing) the list — the gem's own standard env var,
191
- always respected, never overridden by foam:**
192
-
193
- ```bash
194
- # The value REPLACES the list for that option — name every header you want:
195
- export OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS='allowed_request_headers=content-type,accept,x-request-id,x-tenant-id'
51
+ use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args)
52
+ use Foam::Rack::Middleware
53
+
54
+ run App
196
55
  ```
197
56
 
198
- Precedence: (1) an explicit rack instrumentation instance you pass via
199
- `additional_instrumentations:` installs first and its header config wins
200
- entirely; (2) any header option you set in
201
- `OTEL_RUBY_INSTRUMENTATION_RACK_CONFIG_OPTS` governs that option (foam's
202
- default fills only the option you did not touch — a narrower operator list
203
- is never widened, even the narrow-to-empty `allowed_request_headers=`
204
- spelling); (3) otherwise foam's defaults apply. Foam never installs the
205
- rack instrumentation (or its header config) when a foreign SDK owns the
206
- traces slot.
207
-
208
- **Floor masking, zero config:** the always-on credential floor masks the
209
- seven credential headers (`authorization`, `proxy-authorization`, `cookie`,
210
- `set-cookie`, `x-api-key`, `x-auth-token`, `www-authenticate`) to
211
- `[REDACTED]` in the emitted attribute forms (dash/underscore-normalized
212
- match) — they ride the default lists deliberately so their PRESENCE is
213
- visible, value never (see "The default credential denylist";
214
- `x-forwarded-for` / `x-real-ip` are on the 52-entry key floor and arrive
215
- `[REDACTED]` too). **Masking further captured headers is redaction's job,
216
- not a capture switch:** list the emitted name form in `redact_keys` /
217
- `redact: { secrets: [...] }` (tail mask) or `redact_pii_keys` /
218
- `redact: { pii: [...] }` (full `[REDACTED]`) — the substring key match
219
- reaches the attribute names (inbound names are underscored by the rack gem,
220
- so list e.g. `x_runtime`; Faraday's outbound names keep dashes).
221
-
222
- **Outbound (Faraday):** the official faraday instrumentation has no header
223
- option at all, so outbound stays foam's own Faraday middleware (its
224
- first-class public extension API), enriching the official CLIENT span with
225
- **every** request header actually sent (the injected `traceparent`
226
- included) and every response header received — string-array attributes,
227
- dash-form names. It never creates a span and stands down when foam is
228
- disabled/killed, when a foreign SDK owns traces, or when the current span
229
- is not the official faraday instrumentation's own (a mis-ordered stack
230
- captures nothing rather than writing onto the wrong span). One line per
231
- connection (Faraday has no public add-to-every-connection hook — the
232
- middleware name is registered by init, placement is yours; ORDER MATTERS,
233
- the official middleware first):
57
+ ## Instrumentation
234
58
 
235
- ```ruby
236
- conn = Faraday.new(url: "https://partner-api.example") do |f|
237
- f.use :open_telemetry # the official client span (explicit, so it sits OUTSIDE)
238
- f.use :foam_otel_headers # foam's header capture, directly inside it
239
- end
240
- ```
59
+ `init` installs OpenTelemetry's Ruby instrumentation bundle,
60
+ `opentelemetry-instrumentation-all`, and the standard library logger
61
+ bridge, `opentelemetry-instrumentation-logger`. An instrumentation
62
+ installs only when its library is loaded before `init`, so call `init`
63
+ after `Bundler.require` or after the app's own requires. A library loaded after
64
+ `init` is not instrumented, and a warning is written to stderr once when
65
+ `state` finds one. `state[:instrumentations]` lists what installed.
241
66
 
242
- **Deliberately out of scope** (no standard extension point exists —
243
- skipped, not monkey-patched): outbound **Net::HTTP / Excon / HTTP (httprb)
244
- / HTTPX** headers (their official instrumentations expose no
245
- request/response hook or header option) and gRPC metadata. Outbound header
246
- coverage today is Faraday. **Bodies/payloads are not captured by header
247
- capture** — bodies are the separate, opt-in `capture_payloads:` capability
248
- below.
67
+ The bundle covers: action_mailer, action_pack, action_view, active_job,
68
+ active_model_serializers, active_record, active_storage, active_support,
69
+ anthropic, aws_lambda, aws_sdk, bunny, concurrent_ruby, dalli, delayed_job,
70
+ ethon, excon, faraday, grape, graphql, grpc, gruf, http, http_client, httpx,
71
+ koala, lmdb, logger, mongo, mysql2, net_http, pg, que, racecar, rack, rails,
72
+ rake, rdkafka, redis, resque, restclient, ruby_kafka, sidekiq, sinatra,
73
+ trilogy.
249
74
 
250
- ### Payload (body) capture — opt-in, `capture_payloads:`
75
+ To install an instrumentation outside the bundle, list it in
76
+ `additional_instrumentations`.
251
77
 
252
- **Off by default.** One init option turns on inbound HTTP **body** capture
253
- on the official rack SERVER span (headers stay the header-capture seam
254
- above — this never duplicates them):
78
+ TODO(jhartquist): Revisit and review this compatibility guidance.
255
79
 
256
- ```ruby
257
- Foam::Otel.init(
258
- name: "checkout-api", environment: ENV.fetch("APP_ENV"),
259
- enabled: ENV.fetch("APP_ENV") == "production", token: ENV.fetch("FOAM_OTEL_TOKEN"),
260
- capture_payloads: :errors # :off (default) | :errors | :always — equivalent strings accepted
261
- )
262
- ```
80
+ ### Instrumentation versions
263
81
 
264
- - **`:off` (default)** — zero body teeing, zero per-request allocation, and
265
- **zero middleware shipped**: with the default, foam's Rails wiring
266
- installs nothing at all.
267
- - **`:errors`** — bodies are teed per request but attributes attach to the
268
- span **only when the request errored**: `Foam::Otel.record_exception`
269
- called while the rack server span is current, an exception raised through
270
- the middleware, or a response status **>= 500**. A clean 2xx/4xx
271
- attaches **nothing**.
272
- - **`:always`** — attach on every request.
273
-
274
- An invalid option value raises `ArgumentError` at boot. The operator env
275
- clamp **`FOAM_CAPTURE_PAYLOADS=off|errors|always` overrides the option in
276
- both directions** (force payloads off on a misbehaving deploy, or force
277
- them on without a code change — one loud `[foam]` line when it changes the
278
- mode); an invalid env value warns and falls back to the option, never a
279
- crashed boot.
280
-
281
- **What lands on the span** (in `:always`, or in `:errors` on an errored
282
- request): `http.request.body` / `http.response.body` for **textual**
283
- payloads (`text/*`, JSON/`+json`, urlencoded forms, XML/`+xml`, GraphQL;
284
- skipped when `Content-Encoding` is not identity), capped at **8192 chars**
285
- with an `…[truncated]` marker, plus `http.request.body.size` /
286
- `http.response.body.size` — **true byte sizes** when known (declared
287
- `Content-Length`, else the bytes actually observed). Non-textual payloads
288
- contribute sizes only.
289
-
290
- **Stream safety (GOTCHAS F15):** bodies are TEED, never consumed — foam
291
- records exactly what your app reads from `rack.input` (an app that never
292
- reads its body captures nothing; a re-read form body — rewound, or
293
- repositioned via `seek`/`pos=` — is captured and counted once), and
294
- streaming/SSE responses pass through
295
- unbuffered chunk-by-chunk with `close` preserved. Rack 3 `#call`-only
296
- streaming bodies are never wrapped. The request and response are never
297
- altered, and an app exception re-raises identically.
298
-
299
- **Redaction stays central, on export:** a JSON-shaped body is
300
- **deep-redacted by field name** at the exporter boundary — the credential
301
- floor (`password`, `authorization`, …) and your `redact_keys` /
302
- `redact: {secrets:/pii:}` lists reach *inside* the body JSON; urlencoded
303
- bodies get their `k=v` pair names masked by the same tokenizer that covers
304
- query strings; and the value-pattern secret layer scans all captured body
305
- text. (A body truncated at the cap no longer parses as JSON, so field-name
306
- deep-redaction cannot apply to it — the value-shape scans still run.)
307
-
308
- **Wiring:** in **Rails**, automatic when the mode is not `:off` (foam's
309
- railtie inserts `Foam::Otel::PayloadCapture` directly inside the official
310
- rack middleware after your initializers run; when foam-otel loads before
311
- rails, `init` wires it instead, as long as init runs during boot — the
312
- standard `config/initializers/foam.rb` recipe). **Plain Rack / Sinatra**
313
- apps add one line under the official middleware:
82
+ Bundler keeps one version of each gem in the application. By the time Foam
83
+ starts, those versions are already fixed. `additional_instrumentations` only
84
+ configures an instrumentation that Bundler installed and Ruby loaded; it
85
+ cannot choose or install another version.
314
86
 
315
- ```ruby
316
- # config.ru
317
- use(*OpenTelemetry::Instrumentation::Rack::Instrumentation.instance.middleware_args)
318
- use Foam::Otel::PayloadCapture # opt-in body capture (inert while capture_payloads is :off)
319
- run MyApp
320
- ```
87
+ If a bundled instrumentation does not support the library version your app
88
+ uses, pin a compatible `opentelemetry-instrumentation-*` gem in the `Gemfile`
89
+ and run `bundle install` again.
321
90
 
322
- The middleware enriches only: it never creates a span, and it stands down
323
- per request when foam is disabled/killed, when the span is not recording,
324
- or when a foreign SDK owns the traces slot. Outbound (client) bodies are
325
- not captured — inbound only.
326
-
327
- ---
328
-
329
- ## The `init` options
330
-
331
- | Option | Type | Required | Default | What it does |
332
- | --- | --- | --- | --- | --- |
333
- | `name:` | String | yes | — | `service.name`. Blank → raises at boot. |
334
- | `environment:` | String | yes | — | `deployment.environment.name`, exported verbatim. A value outside `{production, staging, development, test}` warns but is never rewritten. Blank → raises. |
335
- | `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. |
336
- | `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. **The convention across all foam SDKs: read it from the `FOAM_OTEL_TOKEN` env var.** Blank while enabled → raises at boot. |
337
- | `version:` | String | no | nil | `service.version`, verbatim (git SHA recommended). Missing → warns and continues. Never detected at runtime. |
338
- | `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. |
339
- | `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). |
340
- | `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. |
341
- | `additional_span_processors:` | Array | no | `[]` | Tenant seam: constructed SpanProcessor instances added to foam's pipeline (additive; never replace foam's export). See coexistence. |
342
- | `additional_log_record_processors:` | Array | no | `[]` | Tenant seam, logs. |
343
- | `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
344
- | `additional_instrumentations:` | Array | no | `[]` | Constructed tier-2 instrumentation instances to register (fault-isolated: one that throws is skipped with a `[foam]` warning). |
345
- | `ignored_outbound_hosts:` | Array<String> | no | `[]` | Hosts whose outbound calls produce no spans — EXTENDS the built-in export-loop guard. For a co-resident agent's intake or a tenant exporter's endpoint. Applies to the clients whose official instrumentation supports host suppression: **Net::HTTP and Excon**. Faraday/HTTP (httprb)/HTTPX have no upstream `untraced_hosts` option — foam warns loudly at init when one of those is bundled (GOTCHAS G13); foam's own export loop is guarded for every client regardless. |
346
- | `before_send:` | Proc/Array | no | `[]` | Export hook (1.8.0): one callable — or an Array run as a pipeline — called with every **span and log record** at the export boundary, after batching and BEFORE foam's redaction pass and serialization, so nothing a hook drops ever leaves the process. Return the record (or a same-type replacement) to export it — in-place mutation supported; return `nil` to drop it; a raise or foreign return drops **that record** loudly, fail-closed, never a raise into your threads. Runs BEFORE redaction — a hook can never widen what ships. Spans and logs only; door 1 only. A non-callable (or a one-arg-incompatible lambda) raises at boot. Process-global. Hooks run on the export thread — keep them non-blocking. Full contract: "The `before_send` hook" below. |
347
- | `capture_payloads:` | Symbol/String | no | `:off` | Inbound HTTP **body** capture on the rack server span (see "Payload (body) capture"). `:off` = zero teeing, zero middleware (the default); `:errors` = tee per request, attach only on exception/5xx; `:always` = attach on every request. Equivalent strings accepted; anything else raises at boot. Overridden in both directions by the `FOAM_CAPTURE_PAYLOADS` env clamp (env table below). Textual payloads only, 8192-char cap + `…[truncated]`, true `.size` attributes; sizes-only for binary/compressed. |
348
- | `diagnostics:` | Boolean | no | false | Verbose `[foam]` self-reporting of init/health. Warnings and errors are always loud regardless. |
349
- | `secret_heuristics:` | Boolean | no | true | The value-pattern secret layer's HEURISTIC tier (generic keyword+entropy detection, see "The value-pattern secret layer"). `false` disables ONLY the heuristics — the named provider patterns, the credential floor and the redaction-coverage contract have no off switch. Disabling logs one loud `[foam]` line at init (an explicit, audited decision for telemetry whose legitimate values collide with the heuristics). |
350
-
351
- Examples for the remaining options (each is runnable as-is):
91
+ If Bundler accepts the pin, restart the app and the whole bundle will use that
92
+ instrumentation version. If it conflicts with `foam-otel` or
93
+ `opentelemetry-instrumentation-all`, those versions cannot live in the same
94
+ bundle. At that point, change the application library, use a compatible Foam
95
+ release, or accept the telemetry gap: the app can keep running, but Foam will
96
+ not receive the spans that instrumentation would have produced.
352
97
 
353
- ```ruby
354
- # Redaction is OPT-IN (default: nothing is masked). List the fields to redact:
355
- Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
356
- redact_keys: %w[password api_key internal_ref voucher_code], # tail-masked
357
- redact_pii_keys: %w[customer_email full_name]) # fully [REDACTED]
358
- ```
98
+ Even a successful `bundle install` only means the declared gem dependencies
99
+ agree. The instrumentation checks the target library when `init` runs. If the
100
+ target is unsupported, Foam leaves that instrumentation out of
101
+ `state[:instrumentations]`. `additional_instrumentations` cannot bypass the
102
+ check or load a second version of the gem.
359
103
 
360
- ```ruby
361
- # The grouped redact object (1.5.0, preferred — the flat options above are
362
- # aliases and UNION with it when both are set), plus the opt-in PII
363
- # detection tier (typed placeholders; see "PII detection (opt-in)"):
364
- Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
365
- redact: {
366
- secrets: %w[internal_ref voucher_code], # == redact_keys (tail mask)
367
- pii: %w[customer_email full_name], # == redact_pii_keys (full [REDACTED])
368
- detect: %w[email phone ssn credit_card ip], # value-shape detection → [EMAIL], [PHONE], …
369
- })
370
- # An unknown field, a non-Array value, or an unknown detect entity raises at boot:
371
- # Foam::Otel.init(..., redact: { detect: ["name"] })
372
- # => raises ArgumentError: Foam::Otel.init redact: unknown detect entity "name" — the valid
373
- # entity names are exactly {email, phone, ssn, credit_card, ip} (contract/pii-detect.json)
374
- ```
104
+ The `pg`, `mysql2`, and `trilogy` instrumentations send the full SQL
105
+ statement (`db_statement: :include`); the others keep their upstream
106
+ defaults. To disable one instrumentation or change its options, pass it
107
+ in `additional_instrumentations` or use the upstream environment
108
+ variables, which take precedence:
109
+ `OTEL_RUBY_INSTRUMENTATION_<NAME>_ENABLED=false` and
110
+ `OTEL_RUBY_INSTRUMENTATION_<NAME>_CONFIG_OPTS`, where `<NAME>` is the
111
+ instrumentation's class name after `OpenTelemetry::Instrumentation::`,
112
+ upper-cased, with `::` replaced by `_`: `PG`, `NET_HTTP`, `ACTIVERECORD`.
375
113
 
376
- ```ruby
377
- # Only if your legitimate telemetry collides with the generic secret
378
- # heuristics (e.g. base64 content-addressed ids masked as high-entropy
379
- # tokens): disable the HEURISTIC tier only. The named provider patterns and
380
- # the credential floor stay on — they have no off switch. Logs one loud
381
- # [foam] line.
382
- Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
383
- secret_heuristics: false)
384
- ```
114
+ ## Public API
385
115
 
386
- ```ruby
387
- # Constructed tier-2 instrumentation instances (fault-isolated; one that
388
- # throws is skipped with a [foam] warning, never a crashed boot):
389
- Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
390
- additional_instrumentations: [
391
- OpenTelemetry::Instrumentation::Sidekiq::Instrumentation.instance,
392
- [OpenTelemetry::Instrumentation::PG::Instrumentation.instance, { db_statement: :omit }],
393
- ])
394
- ```
116
+ Every method lives on the `Foam` module. Every helper is a no-op
117
+ before `init` and never raises.
118
+
119
+ ### `init`
120
+
121
+ Sets up tracing, metrics, logs, automatic instrumentation, W3C
122
+ `tracecontext` and `baggage` propagation, and OTLP export to Foam.
123
+
124
+ Call this once. Use it when Foam should run OpenTelemetry for the process.
395
125
 
396
126
  ```ruby
397
- # Tenant seam, logs + metrics (spans are shown in scenario 3 below):
398
- Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
399
- additional_log_record_processors: [EvalTool::LogRecordProcessor.new],
400
- additional_metric_readers: [EvalTool::MetricReader.new],
401
- ignored_outbound_hosts: ["ingest.eval-tool.example"]) # the tenant loop guard — required
127
+ require "foam/otel"
128
+
129
+ Foam.init(
130
+ name: "checkout-api",
131
+ environment: "production",
132
+ enabled: true,
133
+ token: ENV["FOAM_OTEL_TOKEN"],
134
+ version: "1.0.0"
135
+ )
402
136
  ```
403
137
 
404
138
  ```ruby
405
- # The before_send export hook (1.8.0): transform or drop spans/logs right
406
- # before they are exported to foam — after batching, BEFORE redaction and
407
- # serialization (a dropped record never leaves the process). Return the
408
- # record (mutate in place freely) or nil to drop; a raising hook drops ONLY
409
- # its record, loudly, and never raises into your threads (fail-closed).
410
- Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
411
- before_send: lambda { |record|
412
- next nil if record.respond_to?(:name) && record.name == "GET /healthz" # drop noise
413
-
414
- record.attributes.delete("internal.debug.blob") # trim before it ships
415
- record
416
- })
417
- # Or an Array pipeline — hooks run in order, each receiving the previous
418
- # hook's return; the first nil drops the record and short-circuits:
419
- # Foam::Otel.init(..., before_send: [DropHealthchecks, TrimDebugAttrs, StampTeam])
139
+ Foam.init(
140
+ name:,
141
+ environment:,
142
+ enabled: true,
143
+ token: nil,
144
+ version: nil,
145
+ sample_rate: 1.0,
146
+ additional_instrumentations: [],
147
+ additional_span_processors: [],
148
+ additional_log_record_processors: [],
149
+ additional_metric_readers: [],
150
+ additional_resource_attributes: {},
151
+ disable_log_sending: false,
152
+ network_capture: :basic,
153
+ ignored_outbound_hosts: [],
154
+ redact: nil,
155
+ before_send: nil
156
+ )
420
157
  ```
421
158
 
422
- ```ruby
423
- # Verbose self-reporting while wiring foam up (warnings are loud regardless):
424
- Foam::Otel.init(name: "checkout-api", environment: "development", enabled: true,
425
- token: ENV.fetch("FOAM_OTEL_TOKEN"), diagnostics: true)
159
+ `name` and `environment` are required. `token` is required only when Foam
160
+ is enabled; store it as `FOAM_OTEL_TOKEN` and pass it in.
161
+
162
+ - `name` - the service name, sent as `service.name`.
163
+ - `environment` - the deployment environment, sent as
164
+ `deployment.environment.name`.
165
+ - `enabled` - `false` turns Foam off; `init` returns without configuring
166
+ OpenTelemetry.
167
+ - `token` - the Foam ingest token, sent as a bearer token on every export.
168
+ Required; a missing token is reported on stderr and leaves Foam
169
+ uninitialized.
170
+ - `version` - the service version, sent as `service.version` when given.
171
+ - `sample_rate` - a number from `0.0` to `1.0`. Rarely if ever updated.
172
+ Only change it for services sending telemetry in the petabytes range.
173
+ `OTEL_TRACES_SAMPLER` is not consulted.
174
+ - `additional_span_processors`, `additional_log_record_processors`,
175
+ `additional_metric_readers` - the app's own OpenTelemetry components, added
176
+ to Foam's providers after Foam's own exporters.
177
+ - `additional_resource_attributes` - attributes merged into the resource
178
+ of every signal. `service.name`, `service.version`, and
179
+ `deployment.environment.name` come from the options above; a value for
180
+ one of them here is reported on stderr and ignored.
181
+ - `additional_instrumentations` - instrumentations to install next to the
182
+ bundle. An entry is an instrumentation's registry name, its class name
183
+ without the trailing `::Instrumentation`
184
+ (`"OpenTelemetry::Instrumentation::Logger"`), or a `[name, config]` pair
185
+ to install it with options
186
+ (`["OpenTelemetry::Instrumentation::PG", {db_statement: :obfuscate}]`).
187
+ Require the instrumentation's gem before `init`; an entry whose
188
+ instrumentation is not loaded fails `init`. An entry naming a bundled
189
+ instrumentation replaces Foam's configuration for it.
190
+ - `ignored_outbound_hosts` - skips tracing of outbound HTTP requests to
191
+ these hostnames. The endpoint host is always skipped. Use it for hosts
192
+ whose traffic would drown the traces, such as a sidecar health check on
193
+ `localhost` or another vendor's OTLP ingest host. Hosts are compared as
194
+ written; `localhost` and `127.0.0.1` are different hosts. A request to a
195
+ skipped host still carries a `traceparent` marked not sampled, so an
196
+ OpenTelemetry service behind it records nothing for that request.
197
+ - `disable_log_sending` - `true` stops shipping the app's `Logger` lines to
198
+ Foam. `Foam.log` is unaffected. See Loggers.
199
+ - `network_capture` - `:off`, `:basic`, or `:advanced`. See Network
200
+ capture.
201
+ - `redact` - keys to mask beyond the built-in list, as
202
+ `{secrets: [...], pii: [...]}`. A value under a `secrets` key keeps its
203
+ last four characters when it is 12 characters or longer; a value under
204
+ a `pii` key is fully masked. See Redaction.
205
+ - `before_send` - a callable that receives each span and log record before
206
+ export and returns it, edited or not, to send it, or `nil` to drop it.
207
+ See Before send.
208
+
209
+ Every signal carries a resource with the service identity from the options
210
+ above, `telemetry.distro.name` and `telemetry.distro.version` naming this
211
+ gem, `host.name`, `host.arch`, `host.id`, `os.type`, and `os.version`, and
212
+ the process and SDK attributes the OpenTelemetry SDK adds.
213
+ `OTEL_RESOURCE_ATTRIBUTES` can add further attributes. It cannot override
214
+ any of the attributes above, and `OTEL_SERVICE_NAME` cannot override
215
+ `name`.
216
+
217
+ `init` never raises. A failure or an option holding an invalid value is
218
+ reported on stderr with a `[foam-otel]` prefix and leaves `state`
219
+ uninitialized.
220
+
221
+ `init` also reports its outcome to Foam: one log record with the message
222
+ and the `state`, sent straight to the endpoint with the token. It is sent
223
+ in the background, never blocks the app, and is dropped when it cannot be
224
+ delivered. Nothing is sent without a token.
225
+
226
+ ### `set_endpoint(url)`
227
+
228
+ For tests and local development only. Sets the OTLP/HTTP base URL,
229
+ without a signal path, that every export and report goes to, such as a
230
+ local collector. Production apps do not call it: the default is Foam's
231
+ ingest endpoint, `https://otel.api.foam.ai`.
232
+
233
+ Call it before `init` or before creating an ingest component. Exporters
234
+ keep the endpoint they were built with: a call after `init` is reported on
235
+ stderr, and only later reports follow the new value.
236
+ `OTEL_EXPORTER_OTLP_ENDPOINT` and the per-signal endpoint variables are not
237
+ consulted.
238
+
239
+ ### `create_foam_ingest_span_processor(name, environment, token, redact: nil, before_send: nil)`
240
+
241
+ Returns a span processor that exports traces to Foam without taking over
242
+ the global tracer provider.
243
+
244
+ Use it when another OpenTelemetry SDK already owns traces and Foam is
245
+ only an extra export destination. Attach it to that provider.
246
+
247
+ ```ruby
248
+ processor = Foam.create_foam_ingest_span_processor(
249
+ "checkout-api",
250
+ "production",
251
+ ENV["FOAM_OTEL_TOKEN"]
252
+ )
253
+ tracer_provider.add_span_processor(processor)
426
254
  ```
427
255
 
428
- And the unhappy path — a blank required argument fails AT BOOT, on your
429
- machine, never silently:
256
+ ### `create_foam_ingest_log_record_processor(name, environment, token, redact: nil, before_send: nil)`
257
+
258
+ Returns a log record processor that exports logs to Foam without taking
259
+ over the global logger provider. It also gives `log` a provider of its
260
+ own, so `log` delivers without `init`.
430
261
 
431
262
  ```ruby
432
- Foam::Otel.init(name: "", environment: "production", enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"))
433
- # => raises ArgumentError: Foam::Otel.init requires a non-empty name
263
+ processor = Foam.create_foam_ingest_log_record_processor(
264
+ "checkout-api",
265
+ "production",
266
+ ENV["FOAM_OTEL_TOKEN"]
267
+ )
268
+ logger_provider.add_log_record_processor(processor)
434
269
  ```
435
270
 
436
- **Deliberately absent** (and why): `endpoint`, `cadence`/`sampling`,
437
- `is_production`, `force_export`, `disabled_environments`, and any
438
- processor/exporter injection beyond the tenant seam. Customers identify their
439
- service and turn foam on or off; they can never drop a share of the data, inject
440
- their own pipeline pieces, or redirect export from code. The only endpoint
441
- override is the operator-level `OTEL_EXPORTER_OTLP_ENDPOINT` env var (below).
442
-
443
- ---
444
-
445
- ## The default credential denylist (the always-on floor)
446
-
447
- Always on in every foam SDK, identical everywhere (frozen fixture:
448
- `contract/credential-denylist.json`). This is the ONE exception to foam's
449
- raw-by-default capture, and it has **no off switch** — no init option, no env
450
- var, and no door-2 parameter can disable, shrink, or re-spell it.
451
-
452
- **What is masked.** The VALUE of every header/attribute/field whose NAME is on
453
- the frozen list, in every signal (spans — events and links included — logs,
454
- metrics), on door 1, on all three door-2 ingest taps, and in the view handed
455
- to tenant `additional_*` processors:
456
-
457
- - **(a) the seven credential headers**, matched as header names wherever
458
- headers are captured — including the semconv
459
- `http.request.header.<name>` / `http.response.header.<name>` span-attribute
460
- forms: `authorization`, `proxy-authorization`, `cookie`, `set-cookie`,
461
- `x-api-key`, `x-auth-token`, `www-authenticate`;
462
- - **(b) the frozen 52-entry key list** of
463
- `contract/credential-denylist.json` (the deduplicated union of those seven,
464
- sentry-python's default denylists, and foam's documented reference roots —
465
- `password`, `token`, `secret`, `api_key`, `ssn`, `jwt`, `private_key`,
466
- `connect.sid`, `phpsessid`, …), matched as attribute/field names at every
467
- depth the engine walks, and per-key in URL query strings
468
- (`?token=x&user=bob` → `token=[REDACTED]&user=bob`).
469
-
470
- **The matching rule.** Case-insensitive, dash/underscore-normalized **EXACT
471
- name-equality — never substring**: `authorization` masks the header/field
472
- `authorization` (any casing, `-`≡`_`) and nothing else — `authorization_url`,
473
- `x_api_key_id`, `secretary`, and `session_count` all ride RAW. Dotted entries
474
- match verbatim and whole (`connect.sid` masks; `user.session` does not). This
475
- is deliberately STRICTER matching than your own `redact_keys` lists, which
476
- keep their documented substring behavior.
477
-
478
- **The mask.** The full literal `[REDACTED]` — no tail, no length
479
- preservation. These are credentials, not debug aids. Semconv header
480
- attributes (string arrays) mask per element, preserving arity.
481
-
482
- **Precedence.** The floor runs unconditionally BEFORE your
483
- `redact_keys`/`redact_pii_keys`, which stay purely additive on top. Listing a
484
- floor name in `redact_keys` does NOT downgrade it to a tail mask — the floor
485
- is terminal. `enabled: false` stays fully inert exactly as before (nothing
486
- exports at all — the floor masks data that leaves; inert mode has none).
487
-
488
- **Everything else stays RAW — above the floor and the value layer.** The
489
- floor matches NAMES only, never values. Credential-SHAPED value spans are the
490
- value-pattern secret layer's job (next section); UNSHAPED values — bodies,
491
- query values, SQL text, LLM content, and PII under unlisted names — export
492
- byte-identical raw, exactly as documented everywhere else in this README.
493
-
494
- **Why.** Coverage-over-masking remains the mission, but raw `Authorization`
495
- headers, cookies, and API keys on the wire are a breach in waiting for every
496
- customer at once — foam carves out exactly this list (and only
497
- this list) as non-negotiable. The list is byte-identical in every foam SDK
498
- and gate-checked in CI against the shipped fixture
499
- (`spec/credential_floor_spec.rb`).
500
-
501
- **Param names across the foam SDKs** (the customer options above the floor,
502
- identical concept in every SDK — the floor itself has NO init surface anywhere):
503
-
504
- | Concept | js/otel | js/browser | python | ruby | java |
505
- |---|---|---|---|---|---|
506
- | secret keys (tail-mask) | `redactKeys` | `redactKeys` | `redact_keys` | `redact_keys:` | `.redactKeys(String…)` |
507
- | PII keys (full `[REDACTED]`) | `redactPiiKeys` | `redactPiiKeys` | `redact_pii_keys` | `redact_pii_keys:` | `.redactPiiKeys(String…)` |
508
-
509
- > **Migration note — 1.3.0 (minor): always-on credential masking.** As of
510
- > this version foam masks, by default and in every signal, the VALUES of a
511
- > fixed list of credential/secret header and field NAMES (`authorization`,
512
- > `cookie`, `set-cookie`, `proxy-authorization`, `x-api-key`, `x-auth-token`,
513
- > `www-authenticate`, and the 52-name key list in
514
- > `contract/credential-denylist.json` — Sentry-parity plus foam's documented
515
- > roots) to the literal `[REDACTED]`. Matching is exact name-equality,
516
- > case-insensitive, dash/underscore-insensitive — never substring:
517
- > `authorization_url` is untouched. Everything else still exports RAW exactly
518
- > as before; `redact_keys`/`redact_pii_keys` are unchanged and additive.
519
- > There is no off switch — if a dashboard keyed off a raw credential value
520
- > (it should not have), it will now see `[REDACTED]`. Customers who
521
- > previously received these header/field values raw stop receiving them.
522
-
523
- ---
524
-
525
- ## The value-pattern secret layer (always-on)
526
-
527
- The credential floor masks by NAME and is deliberately value-shape-blind — a
528
- live AWS key under the field name `note` would sail past it. Foam telemetry is
529
- read downstream by LLMs, so a leaked credential is exfiltratable by prompt
530
- injection; "no leakage at all" is the bar. The value-pattern secret layer is
531
- the second always-on control:
532
-
533
- **What it masks.** Credential-SHAPED value spans, regardless of the field
534
- name they hide under: AWS access-key ids and keyword-anchored AWS secrets,
535
- GCP API keys, Azure client/storage secrets, GitHub tokens and fine-grained
536
- PATs, GitLab tokens, Slack tokens + webhooks, Stripe secret keys
537
- (publishable `pk_` keys are deliberately NOT masked), JWTs, PEM private-key
538
- blocks (`-----BEGIN … PRIVATE KEY` — a truncated block masks to the end of
539
- the value, fail-closed; `PUBLIC KEY`/`CERTIFICATE` blocks never match) and
540
- PuTTY PPK keys, `scheme://user:pass@` URI credentials (the whole userinfo,
541
- username included), `Bearer`/`Basic` auth values, and Anthropic/OpenAI API
542
- keys. The matched span becomes the literal `[REDACTED]` — no tail, no
543
- prefix, zero reconstructable bytes; surrounding text keeps its diagnostic
544
- value (`conn to postgres://[REDACTED]@db:5432 refused`).
545
-
546
- **The heuristic tier** additionally catches the long tail: a
547
- keyword-anchored generic pass (`password = hunter2SecretXyz99` inside a log
548
- body masks its value — gitleaks-derived, entropy-gated, with placeholder/
549
- stopword/key-context false-positive suppression) and a whole-value
550
- high-entropy base64 token pass (detect-secrets-derived; pure hex and UUIDs
551
- are deliberately exempt — trace ids and digests are telemetry's ambient
552
- vocabulary). Only this tier is disableable: `secret_heuristics: false`
553
- (one loud `[foam]` line; the named patterns above are not configurable).
554
-
555
- **Where it runs.** Every value-bearing string on every export path, both
556
- doors: span/event/link attribute values, `status.message`, exception
557
- messages, log bodies (string and structured leaves), SQL (`db.statement`),
558
- LLM content (BEFORE serialization and again at the boundary), URL query AND
559
- fragment pair values, baggage-derived attributes, exemplar
560
- `filtered_attributes`, and resource identity strings (once, at init).
561
- Metric datapoint attributes (metric labels) are the one exempt surface —
562
- low-cardinality by construction; a value mask there would break your
563
- aggregation dimensions.
564
-
565
- **Bounded by design.** Compile-once rules, a literal-anchor pre-filter (no
566
- anchor in the value → zero regexes run), a 256 KiB value cap (an oversized
567
- value with an anchor hit anywhere masks WHOLE, fail-closed; without one it
568
- passes untouched), a match-flood cap, a 256-pair query/fragment tokenizer
569
- cap, and a per-Regexp timeout on Ruby ≥ 3.2. ANY scanner fault masks the
570
- value rather than shipping it raw.
571
-
572
- **Provenance.** The ruleset derives from MIT/Apache-licensed OSS secret
573
- scanners (gitleaks, detect-secrets, secretlint) — see `THIRD-PARTY-NOTICES`.
574
- Rules are frozen module constants, byte-consistent across all foam SDKs —
575
- not configurable per app.
576
-
577
- > **Migration note — 1.4.0 (minor): value-pattern secret masking + redaction
578
- > coverage fix + TLS pin.** foam now masks credential-SHAPED values
579
- > (AWS/GCP/Azure keys, GitHub/GitLab tokens, Slack, Stripe, Anthropic/OpenAI
580
- > keys, JWTs, private-key blocks, `user:pass@` URI credentials, bearer/basic
581
- > tokens) to `[REDACTED]` in every exported string value — regardless of
582
- > field name — plus a generic keyword+entropy heuristic you can disable with
583
- > `secret_heuristics: false` (the named patterns and the credential floor
584
- > cannot be disabled). Redaction now also reaches URL FRAGMENTS (OAuth
585
- > implicit-flow tokens, hash-router params), bare `k=v` pair lists, nested
586
- > attribute containers (fail-closed), and structured LLM content BEFORE
587
- > serialization. Query/fragment tokenizing is capped at 256 pairs (the
588
- > overflow remainder masks whole). Metric labels are not value-scanned. TLS
589
- > peer verification is now PINNED on every exporter:
590
- > `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE` is INERT and loudly warned about.
591
- > If a dashboard keyed off a raw token value (it should not have), it will
592
- > now see `[REDACTED]`.
593
-
594
- ---
595
-
596
- ## PII detection (opt-in)
597
-
598
- The floor and the secret layer protect credentials. PII in VALUES — an email
599
- address inside a log line, a card number inside an exception message — still
600
- exports RAW by default, because coverage-over-masking is the mission and only
601
- you know your privacy posture. As of 1.5.0 you can opt into value-shape PII
602
- detection per entity, via `redact: { detect: [...] }` (frozen fixture:
603
- `contract/pii-detect.json` — the entity names and
604
- placeholders are byte-identical in every foam SDK and gate-checked in CI by
605
- `spec/pii_detect_spec.rb`). Nothing detects unless you list the entity —
606
- an empty/absent `detect` list is exactly today's behavior.
607
-
608
- **The exact entity set** (these five, nothing else — an unknown name raises
609
- at boot):
610
-
611
- | Entity | Placeholder | Catches |
612
- | --- | --- | --- |
613
- | `email` | `[EMAIL]` | `john.smith+test@example.co.uk` — requires a real TLD (`user@localhost` rides raw) |
614
- | `phone` | `[PHONE]` | `+1 (415) 555-0142`, `415-555-0199` — separated groups; bare digit runs, dotted versions (`2024.10.05`) and clock times ride raw |
615
- | `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 |
616
- | `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 |
617
- | `ip` | `[IP]` | IPv4 (octet-validated — `999.1.1.1` and `10.4.1.2000` ride raw) and IPv6, `::`-compressed included |
618
-
619
- **How it masks.** Only the matched character span is replaced with the
620
- entity's typed placeholder — surrounding text keeps its diagnostic value
621
- (`login from [IP] flagged`). It runs everywhere the value-pattern secret
622
- layer runs (span/event/link attributes, `status.message`, log bodies, URL
623
- query and fragment leaves, nested structures, both doors), immediately AFTER
624
- the secret layer (a shaped credential still becomes `[REDACTED]` — detect
625
- never weakens it) and BEFORE your key lists; metric datapoint attributes stay
626
- exempt exactly like the secret layer. Detection is idempotent (placeholders
627
- never re-match), rides the same execution caps and fail-closed discipline
628
- (any detector fault masks the whole value to `[REDACTED]`, loudly), and the
629
- patterns follow the same bounded-quantifier authoring rules.
630
-
631
- **What it can NOT do — read this before relying on it.** This is value-SHAPE
632
- detection, nothing more: **person names and free-text prose are NOT
633
- detectable** — recognizing "the patient, John Smith, reported…" takes
634
- server-side NER, which is platform scope, not an SDK regex. Field-NAME-keyed
635
- PII (`patient_name`) is what `redact: { pii: [...] }` is for; enumerate those
636
- keys yourself. Foam still ships no PII preset — the detect list is your
637
- explicit, audited enumeration.
271
+ ### `create_foam_ingest_metric_reader(name, environment, token, redact: nil)`
272
+
273
+ Returns a metric reader that exports metrics to Foam without taking over
274
+ the global meter provider.
638
275
 
639
276
  ```ruby
640
- # Door-2 taps take the same redact object, scoped to that tap:
641
- processor = Foam::Otel.create_ingest_span_processor(
642
- token: ENV.fetch("FOAM_OTEL_TOKEN"), environment: ENV.fetch("APP_ENV"),
643
- redact: { pii: %w[customer_email], detect: %w[email credit_card] })
277
+ reader = Foam.create_foam_ingest_metric_reader(
278
+ "checkout-api",
279
+ "production",
280
+ ENV["FOAM_OTEL_TOKEN"]
281
+ )
282
+ meter_provider.add_metric_reader(reader)
644
283
  ```
645
284
 
646
- ---
285
+ All three export to the endpoint set by `set_endpoint` and take the same
286
+ `redact` option as `init`. The span processor and the log record processor
287
+ also take the same `before_send` option; the metric reader does not. Each
288
+ one records its signal as `ingest` in `state` and reports the attachment
289
+ to Foam.
647
290
 
648
- ## The `before_send` hook
291
+ Foam identifies the service by the `name` and `environment` passed in: on
292
+ the copy sent to Foam they overwrite the provider's `service.name` and
293
+ `deployment.environment.name`, and `foam.ingest.host` names the host of
294
+ the endpoint the copy goes to. The provider's own telemetry is left
295
+ unchanged. Pass the same values to all three.
649
296
 
650
- `before_send:` is the last word your code gets on every span and log record
651
- before it is exported to foam. It runs at the **export boundary** — after the
652
- batch processor hands foam the finished records, immediately **before**
653
- foam's redaction pass and the OTLP serialization — so a record your hook
654
- drops is never serialized and never leaves the process. (This is the same
655
- seam redaction lives at, and for the same Ruby SDK reason: a span's
656
- attributes freeze at finish before any `on_finish` processor runs, so a
657
- `SpanProcessor` could neither mutate nor drop. The export boundary is the
658
- first point where the record is a mutable struct.)
297
+ Unlike every other public method, these raise `ArgumentError` when `name`,
298
+ `environment`, or `token` is blank or `redact` or `before_send` is invalid.
659
299
 
660
- The pipeline order is pinned:
300
+ Do not combine `init` and an ingest component for the same signal. The
301
+ ingest components do not install instrumentations; register the ones the
302
+ app needs with the OpenTelemetry SDK directly.
661
303
 
662
- ```
663
- BatchProcessor → before_send hooks → redaction (floor + your lists) → OTLP wire
664
- ```
304
+ ### `inject_trace_context(carrier)`
305
+
306
+ Writes the W3C `traceparent`, `tracestate`, and `baggage` headers for the
307
+ current span into `carrier`, a hash of string keys.
665
308
 
666
- Your hooks run **first**, on the raw record (it is your own data, still
667
- in-process), and redaction runs on whatever they return — so `before_send`
668
- can never widen what ships: an attribute a hook adds is masked by the
669
- credential floor and your redact lists exactly like one an instrumentation
670
- set.
671
-
672
- The per-record contract (Sentry `beforeSend` semantics):
673
-
674
- * **return the record — or a replacement of the same type** → it continues
675
- to the next hook / to export. Mutating it in place is supported —
676
- `record.attributes` arrives as your own unfrozen copy (values included),
677
- so `record.attributes["k"] = v`, `record.attributes.delete("k")`,
678
- `record.attributes["k"].gsub!(...)` and (for logs) editing `record.body`
679
- all just work without bleeding into the span your app still holds or the
680
- view a tenant processor saw. Events, links and status are the exception:
681
- **replace them wholesale** (`record.events = [...]`) — never mutate them
682
- in place, they are shared structures. Foam re-normalizes the record's
683
- `total_recorded_*` bookkeeping after your hooks run, so adding/deleting
684
- attributes never corrupts the wire's dropped-count accounting.
685
- `record.resource` and `record.instrumentation_scope` are **identity, not
686
- payload**: read them freely, but edits and replacements are discarded —
687
- the original identity objects always ship (they are process-global and
688
- the encoder groups by them; a writable resource would also bypass
689
- redaction, which never masks resource attributes).
690
- * **return `nil`** → the record is dropped, silently (that is the filter
691
- mechanism, not an error).
692
- * **raise, or return a foreign object** → that record is dropped
693
- **loudly** — one aggregated `[foam]` warning per batch, error classes
694
- only (messages can carry your data) — and the batch reports failure:
695
- fail-closed, a faulted hook never ships a record you may have meant to
696
- scrub, and never raises into your application or export threads. Healthy
697
- records in the same batch still export. Because a fault marks the whole
698
- batch failed, a hook that faults **deterministically** also makes the
699
- at-exit flush report failure — fix faulting hooks promptly (GOTCHAS F16).
700
-
701
- **Hooks run on foam's export thread, inside the batch processor's export
702
- lock.** Keep them fast and non-blocking: no network calls, no DB lookups,
703
- no sleeping — a blocked hook stalls the export pipeline and can wedge the
704
- at-exit flush. Anything a hook needs from the request/job thread
705
- (tenant context, user identity) must be stamped onto the record as an
706
- attribute at capture time (see the `additional_span_processors` stamper
707
- pattern); thread-locals are gone by the time the hook runs.
708
-
709
- Boot validation is strict: a non-callable — or a **lambda/Method
710
- whose signature cannot accept one positional argument** (e.g. a Sentry-port
711
- `->(event, hint) { ... }` with two required params, or a required keyword)
712
- — raises `ArgumentError` at `init`, because it would otherwise fault on
713
- every record and ship zero telemetry from a green boot. Make the extra
714
- Sentry `hint` param optional (`->(event, hint = nil)`) and it is accepted.
715
-
716
- Scope: **spans and log records** (the two record-shaped signals — the hook
717
- receives `SpanData` or `LogRecordData`; distinguish them with `is_a?` or
718
- `respond_to?`). Metrics are aggregated state, not discrete records, and are
719
- deliberately not routed through `before_send`. Door-2 ingest taps are
720
- unchanged. Like `enabled:` and the endpoint, `before_send` is process-global:
721
- the exporter chain is built at the first `init`, and a second `init` never
722
- changes it.
723
-
724
- ---
725
-
726
- ## The helpers
727
-
728
- All helpers never raise, and no-op silently before `init` and when disabled.
729
-
730
- **Lifecycle**
309
+ Use it when the app sends a message itself (a Kafka record, a queue payload,
310
+ a custom socket). The HTTP instrumentations already inject these headers;
311
+ do not call it on ordinary HTTP requests. Writes nothing when no span is
312
+ active.
731
313
 
732
314
  ```ruby
733
- Foam::Otel.flush # force-flush buffered pipelines (call at handler/worker exit)
734
- Foam::Otel.shutdown # flush + shut down providers (normal exit is auto via at_exit)
735
- Foam::Otel.after_fork! # restart metric-reader threads after a fork (see cluster mode)
315
+ headers = {}
316
+ Foam.inject_trace_context(headers)
317
+ producer.publish(topic, payload, headers: headers)
736
318
  ```
737
319
 
738
- **Traces**
320
+ ### `extract_trace_context(carrier)`
321
+
322
+ Reads those headers from `carrier` and returns an OpenTelemetry context.
323
+ Run the receiving work inside it so its spans continue the sender's trace.
324
+ Returns the current context when the carrier has no trace headers.
739
325
 
740
326
  ```ruby
741
- result = Foam::Otel.span("nightly-sync") do |span|
742
- # ... work ... the span always ends; a raised error is recorded + re-raised
327
+ context = Foam.extract_trace_context(message.headers)
328
+ OpenTelemetry::Context.with_current(context) do
329
+ tracer.in_span("consume") { handle(message) }
743
330
  end
331
+ ```
332
+
333
+ Foam's helpers always speak W3C, whichever propagator the SDK configured
334
+ globally (see `OTEL_PROPAGATORS`).
744
335
 
745
- Foam::Otel.set_attribute("user.tier", "pro") # on the active span; false if none recording
746
- Foam::Otel.set_attributes("a" => 1, "b" => 2)
747
- Foam::Otel.add_event("cache.miss")
748
- Foam::Otel.record_exception(e) # records on active span; never notifies a tracker
749
- ctx = Foam::Otel.get_trace_context # {trace_id:, span_id:} hex, or nil — never fabricated
336
+ ### `set_baggage(key, value)`
337
+
338
+ Adds a key-value pair to the current context's baggage. Baggage travels
339
+ with the trace context to every downstream service, where `baggage(key)`
340
+ reads it; it is not written onto spans. The value lasts for the rest of
341
+ the current scope, for example the rest of the request when called from a
342
+ Rails controller, and is dropped when that scope ends. Outside any span or
343
+ context scope the call is ignored and warned about once, so a value can
344
+ never outlive the work it belongs to. No-op before `init`.
345
+
346
+ ```ruby
347
+ Foam.set_baggage("tenant", current_tenant.id)
750
348
  ```
751
349
 
752
- **Metrics** (one helper per synchronous instrument; names pass through verbatim)
350
+ ### `baggage(key)`
351
+
352
+ Returns the baggage value for `key`, whether set in this process or
353
+ received from an upstream service, or `nil`.
354
+
355
+ ### Metric helpers
753
356
 
754
357
  ```ruby
755
- Foam::Otel.increment_counter("checkout.completed", by: 1, attributes: { "plan" => "pro" })
756
- Foam::Otel.record_histogram("checkout.duration_ms", 428)
757
- Foam::Otel.add_up_down_counter("queue.depth", by: -1)
758
- Foam::Otel.set_metric("cache.size_bytes", 10_485_760) # gauge (last value wins)
358
+ Foam.increment_counter("orders.placed", attributes: {"plan" => "pro"})
359
+ Foam.record_histogram("checkout.duration", 0.42, unit: "s")
360
+ Foam.add_up_down_counter("queue.depth", -1)
361
+ Foam.set_metric("cache.size", 1024, unit: "By", description: "Entries in the cache")
759
362
  ```
760
363
 
761
- > ⚠️ Metric attributes are a loaded gun: an UNBOUNDED value (user id, raw path,
762
- > full URL) creates one metric stream per value — a memory leak that the SDK's
763
- > cardinality cap then turns into silently flat dashboards. Use bounded,
764
- > low-cardinality attribute values only.
364
+ - `increment_counter(name, value = 1, ...)` adds to a counter that only
365
+ goes up.
366
+ - `record_histogram(name, value, ...)` records one value in a histogram.
367
+ - `add_up_down_counter(name, value, ...)` adds to a counter that can go
368
+ down; pass a negative value to decrement.
369
+ - `set_metric(name, value, ...)` sets a gauge to its latest value.
370
+
371
+ Every helper takes `attributes: {}`, `unit: nil`, and `description: nil`.
372
+ The instrument is created on the first call for a name, and `unit` and
373
+ `description` apply then; later calls reuse it. Values are exported on
374
+ Foam's metrics pipeline under the `foam-otel` scope. Calls before `init`,
375
+ or when Foam is disabled, are dropped.
376
+
377
+ ### `log(body, severity: :info, attributes: {})`
378
+
379
+ Sends a log record to Foam on Foam's own export path. The record goes only
380
+ to Foam: nothing is written to the app's log output, and nothing is emitted
381
+ through a logger provider Foam does not own. A record emitted inside a
382
+ request carries the trace and span ids of the current span.
383
+
384
+ Use it to send a log directly, for example when the app's logger is not
385
+ captured automatically (see Loggers).
765
386
 
766
- **Logs**
387
+ `severity` is one of `:trace`, `:debug`, `:info`, `:warn`, `:error`,
388
+ `:fatal`. Calls before `init`, or when Foam is disabled, are dropped.
767
389
 
768
390
  ```ruby
769
- Foam::Otel.log(:info, "checkout finished", attributes: { "order.id" => id })
770
- # severity is a level symbol (:trace :debug :info :warn :error :fatal) or an
771
- # OTel severity number (1..24). Trace-correlated when a span is active.
391
+ Foam.log("checkout failed")
392
+ Foam.log("checkout failed", severity: :error, attributes: {"order.id" => order.id})
772
393
  ```
773
394
 
774
- **Redaction**
395
+ ### `record_exception(exception, attributes: nil)`
396
+
397
+ Records an exception on the active span and marks the span as errored,
398
+ for exceptions the app rescues and handles itself. Unhandled exceptions
399
+ are recorded by the instrumentation already.
775
400
 
776
401
  ```ruby
777
- Foam::Otel.redact(user_supplied_token) # tail-masks a scalar you know is sensitive; [REDACTED] for structures
402
+ begin
403
+ charge(order)
404
+ rescue PaymentError => e
405
+ Foam.record_exception(e, attributes: {"order.id" => order.id})
406
+ render_retry_page
407
+ end
778
408
  ```
779
409
 
780
- **Passthroughs** (the real OTel objects on foam's pipeline — links, span kinds,
781
- observable instruments, baggage all reachable here)
410
+ The span gets an `exception` event with the type, message, and stack
411
+ trace, plus any `attributes`, and its status becomes error with the
412
+ exception's message. The active span is used whichever SDK started it.
413
+ Without an active span nothing is recorded.
414
+
415
+ ### `flush(timeout: 30)`
416
+
417
+ Exports everything Foam's providers still hold. Call it before a point
418
+ where the process may be killed without a normal exit, for example at the
419
+ end of a job on a runner that stops the process.
782
420
 
783
421
  ```ruby
784
- Foam::Otel.get_tracer("my.lib")
785
- Foam::Otel.get_meter("my.lib")
786
- Foam::Otel.get_logger("my.lib")
422
+ Foam.flush(timeout: 5)
787
423
  ```
788
424
 
789
- ---
425
+ `timeout` is in seconds and bounds the whole call. A provider that runs
426
+ out of time is reported on stderr.
427
+
428
+ ### `shutdown(timeout: 30)`
429
+
430
+ Flushes and shuts down Foam's providers, including the one behind `log`
431
+ when an ingest component created it. Both `init` and the ingest log
432
+ factory register it with `at_exit`, so most apps never call it. Call it
433
+ yourself before a process is killed without a normal exit.
790
434
 
791
- ## Coexistence — foam arriving in a monitored service
435
+ `timeout` is in seconds and bounds the whole call. A provider that runs
436
+ out of time is reported on stderr.
792
437
 
793
- `init` classifies each signal's global slot independently and does the right,
794
- non-destructive thing. The classifier's output is information for you, never a
795
- decision it makes for you.
438
+ ### `state`
796
439
 
797
- ### Scenario 2 — beside a proprietary APM agent
440
+ Returns whether Foam initialized, which instrumentations registered, which
441
+ signals have an export path, and the identity `init` was given.
798
442
 
799
- Disjoint pipelines: foam runs its own beside the agent, touching nothing. Add the
800
- agent's intake host so foam's outbound instrumentation doesn't shadow its egress.
443
+ Use it in health checks, tests, or diagnostics to confirm Foam is running.
801
444
 
802
445
  ```ruby
803
- Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: FOAM_OTEL_TOKEN,
804
- ignored_outbound_hosts: ["agent-intake.vendor.example"])
446
+ Foam.state
447
+ # {
448
+ # initialized: true,
449
+ # instrumentations: ["net_http@3.4.2", "pg@1.5.9", "rack@3.1.8", "rails@8.0.2", ...],
450
+ # signals: {
451
+ # traces: :global,
452
+ # metrics: :global,
453
+ # logs: :global,
454
+ # baggage: :global,
455
+ # profile: :none
456
+ # },
457
+ # params: {name: "checkout", environment: "production"}
458
+ # }
805
459
  ```
806
460
 
807
- ### Scenario 3 — a scoped tenant rides foam's pipeline (LLM-eval, AI-observability)
461
+ `instrumentations` lists the instrumentations that installed, by gem name
462
+ without the `opentelemetry-instrumentation-` prefix, with the version of
463
+ the library each one instruments (`net_http` reports the Ruby version;
464
+ `aws_lambda` has no version). An instrumentation whose library is absent
465
+ or unsupported is left out.
808
466
 
809
- Attach the tenant's processor via the seam, and add its exporter host to the
810
- ignore list (its exports would otherwise be shadow-spanned into a loop).
467
+ A library loaded after `init` is not instrumented and stays out of the
468
+ list; a warning is written to stderr the first time a `state` call finds
469
+ one. See Instrumentation for the bundle.
811
470
 
812
- ```ruby
813
- Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: FOAM_OTEL_TOKEN,
814
- additional_span_processors: [EvalTool::SpanProcessor.new(project: "prod")],
815
- ignored_outbound_hosts: ["ingest.eval-tool.example"])
816
- ```
471
+ `params` holds the `name` and `environment` passed to `init`, never the
472
+ token, and is empty before `init`.
817
473
 
818
- > Ruby note: when you configure `redact_keys`/`redact_pii_keys`, foam's HELPERS
819
- > apply the key pass at capture, so a tenant processor receives foam-helper data
820
- > (span/log helper attributes, log bodies, metric labels) ALREADY masked.
821
- > The Ruby SDK freezes span attributes at finish, so historically a tenant
822
- > processor saw third-party instrumentation attributes UNMASKED — as of 1.3.0
823
- > that gap is closed: tenant span/log processors are handed a READ-ONLY MASKED
824
- > VIEW (the credential floor plus your configured keys, the same pass foam's
825
- > own export applies — GOTCHAS F1). A tenant never sees a raw floor value.
826
- > With no keys configured (the default) only the credential floor is masked
827
- > on either path.
828
- >
829
- > Loop-guard note: `ignored_outbound_hosts` suppresses spans for tenant/agent
830
- > egress made through **Net::HTTP or Excon**. A tenant exporter using
831
- > Faraday/HTTP (httprb)/HTTPX cannot be host-suppressed today (no upstream
832
- > `untraced_hosts` option — foam warns at init; GOTCHAS G13).
833
-
834
- ### Scenario 4 — a foreign OTel SDK already owns a signal (door 2)
835
-
836
- If another OTel SDK registered (say) the tracer before foam, foam does NOT
837
- displace it. `init` warns once, naming the owner, and foam is dark for that
838
- signal — your tier-3 telemetry for it rides the foreign SDK to ITS backend.
839
- To ALSO send that pipeline to foam, add ONE LINE per claimed signal in THEIR
840
- setup — the ingest entries (see "Door 2 — the ingest entries" below). YOU
841
- place the tap: foam never attaches itself to a pipeline it does not own
842
- (their data moves only when a human says so), and the placement line doubles
843
- as the off-switch — gate it on the same condition you would give `enabled:`.
474
+ Each `signals` value names the source of the Foam export path for that
475
+ signal:
844
476
 
845
- ```ruby
846
- # config/initializers/opentelemetry.rb — THEIR setup, plus the foam lines
847
- require "opentelemetry/sdk"
848
- require "foam/otel"
477
+ - `:global` - `init` registered Foam's provider as the global provider.
478
+ - `:ingest` - a `create_foam_ingest_*` object was built for another SDK's
479
+ pipeline.
480
+ - `:local` - logs only: another SDK owns the global provider and Foam keeps
481
+ its own so `log` still delivers.
482
+ - `:none` - no Foam export path.
849
483
 
850
- OpenTelemetry::SDK.configure do |c|
851
- c.service_name = "their-service"
852
- c.use_all
853
- # REQUIRED loop step (see "Door 2 — the required loop step" below):
854
- c.use "OpenTelemetry::Instrumentation::Net::HTTP", { untraced_hosts: ["otel.api.foam.ai"] }
855
- c.add_span_processor(their_vendor_processor) # their export, untouched
856
- if ENV["APP_ENV"] == "production" # your consent AND your off-switch
857
- c.add_span_processor(Foam::Otel.create_ingest_span_processor(
858
- token: ENV.fetch("FOAM_OTEL_TOKEN"), environment: ENV.fetch("APP_ENV")))
859
- end
860
- end
861
- ```
484
+ `:global` is checked against the live OpenTelemetry global at each call.
485
+ When another SDK takes a slot after `init`, that signal reports `:none`
486
+ (`:local` for logs) and a warning is written to stderr once. Only the slot
487
+ is affected: the Foam helpers keep their own references and keep working.
488
+ `profile` is not supported and always reports `:none`.
862
489
 
863
- **Outcome**: their pipeline keeps working exactly as before — their data,
864
- their resource, their export, byte-identical — and foam ALSO receives the
865
- signal, carrying THEIR resource identity plus foam's export-time stamp, the
866
- always-on credential floor applied to foam's copy, and any per-tap
867
- `redact_keys`/`redact_pii_keys` you set (opt-in — none by default; details in
868
- the Door 2 section).
490
+ ## Custom spans and other OpenTelemetry APIs
869
491
 
870
- **Unhappy paths**:
492
+ TODO(jhartquist)
871
493
 
872
- ```ruby
873
- Foam::Otel.create_ingest_span_processor(token: "", environment: "production")
874
- # => raises ArgumentError: [foam] Foam::Otel.create_ingest_span_processor requires a non-empty token
875
- ```
494
+ ## Network capture
876
495
 
877
- - `OTEL_SDK_DISABLED=true` at construction → the factory returns an inert
878
- no-op instance (their setup line keeps working, nothing is sent) and warns:
879
- `[foam] OTEL_SDK_DISABLED=true — this foam ingest entry is disabled by the
880
- operator kill switch and will send nothing.`
881
- - Tap wired onto FOAM'S OWN pipeline (self-ingest wiring bug) → one loud
882
- warning and the tap STANDS DOWN — door 1 already exports that data, so
883
- nothing is lost and nothing is doubled.
884
- - `init()` already exports a signal AND a tap sits on a separate foreign
885
- pipeline for the same signal → one loud warning; BOTH keep flowing
886
- (different pipelines; `foam.ingest.tier` distinguishes the copies).
887
-
888
- When the TRACES slot is foreign-owned, foam also skips its automatic
889
- tier-1/2 instrumentation activation (it never adds span producers or Rack
890
- middleware to a pipeline it does not own) and warns once;
891
- `additional_instrumentations` you pass explicitly are still installed. The
892
- same holds for the process-wide propagator: a propagator the incumbent
893
- installed first is KEPT (foam installs its W3C set only over the API default),
894
- with one warning.
895
-
896
- ### Scenario 5 — per-signal composition
897
-
898
- Signals are independent. A foreign SDK owning only traces still gets foam for
899
- metrics and logs on the full contract:
496
+ `network_capture` defaults to `:basic` and controls extra HTTP detail on
497
+ server and client spans.
900
498
 
901
- ```ruby
902
- Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: FOAM_OTEL_TOKEN)
903
- # → warns: traces foreign-owned (foam dark for traces); metrics + logs → foam, full contract.
904
-
905
- # The doors COMPOSE: add the traces tap in THEIR setup and foam receives
906
- # traces too — the tap rides THEIR tracer, and trace ids correlate with
907
- # foam-exported logs/metrics through the shared context:
908
- their_provider.add_span_processor(Foam::Otel.create_ingest_span_processor(
909
- token: ENV.fetch("FOAM_OTEL_TOKEN"), environment: ENV.fetch("APP_ENV")))
910
- # (no double-claim warning: init() serves metrics+logs, the tap serves traces)
911
- ```
499
+ - `:off` keeps the standard OpenTelemetry HTTP telemetry, with no extra
500
+ capture.
501
+ - `:basic` adds the `content-type`, `content-length`, and
502
+ `content-encoding` headers of the request and the response as span
503
+ attributes. No other header attribute is added.
504
+ - `:advanced` adds the request and response bodies as well.
912
505
 
913
- ### Scenario 6 — tests / CI (fully inert, no token needed)
506
+ TODO(jhartquist): `:basic` headers on client spans.
914
507
 
915
- ```ruby
916
- Foam::Otel.init(name: "checkout-api", environment: "test", enabled: false)
917
- ```
508
+ Header capture is applied through the Rack instrumentation. An
509
+ `additional_instrumentations` entry naming it replaces this
510
+ configuration.
918
511
 
919
- ### Scenario 7 — serverless / short-lived (flush at the end)
512
+ ### Body capture
920
513
 
921
- ```ruby
922
- Foam::Otel.init(name: "orders-fn", environment: "production", enabled: true, token: FOAM_OTEL_TOKEN)
514
+ In `:advanced` mode each captured body arrives as `foam.http.body.chunk`
515
+ log records correlated to the span, and the span carries the body's size
516
+ and content.
923
517
 
924
- def handler(event:, context:)
925
- run(event)
926
- ensure
927
- Foam::Otel.flush # the platform freezes the process before the batch timer fires
928
- end
929
- ```
518
+ - Bodies with a textual content type are captured. gzip and deflate are
519
+ decoded. Other encodings and binary types yield one record with the size
520
+ only.
521
+ - At most 1 MiB of a body is kept, in chunks of at most 64 KiB. The rest
522
+ counts toward the size.
523
+ - Bodies are redacted by content type with the same keys as span
524
+ attributes.
930
525
 
931
- ### Puma / Unicorn (cluster mode) — CRITICAL
526
+ ### Rack bodies
932
527
 
933
- Exporter threads do not survive `fork`. Foam auto-recovers metric readers via a
934
- `Process._fork` hook, but the cleanest pattern is to `init` PER WORKER so each
935
- worker gets its own pipeline and a unique `service.instance.id`:
528
+ Server bodies need `Foam::Rack::Middleware` directly after the
529
+ OpenTelemetry Rack middleware, as shown under "Plain Ruby and Rack". A
530
+ Rails app is not covered yet.
936
531
 
937
- ```ruby
938
- # config/puma.rb
939
- on_worker_boot do
940
- Foam::Otel.init(name: "checkout-api", environment: ENV["APP_ENV"], enabled: true, token: ENV["FOAM_OTEL_TOKEN"])
941
- # or, if you init in an initializer: Foam::Otel.after_fork!
942
- end
943
- ```
532
+ - The request body is captured whether or not the app reads it when the
533
+ server buffers request bodies, as Puma does. Otherwise what the app reads
534
+ is captured.
535
+ - A streamed response is captured chunk by chunk as the server sends it.
536
+ - A streaming body (`call`), a file sent by path (`to_path`), and a hijacked
537
+ connection are not captured.
944
538
 
945
- ---
946
-
947
- ## Door 2 — the ingest entries
948
-
949
- When a foreign OTel SDK owns a signal (scenario 4), the ingest entries are
950
- how that pipeline's data ALSO reaches foam: constructed instances you add —
951
- one line per claimed signal — to the CUSTOMER'S OWN OTel setup. Additive
952
- readers only: they never mutate their data, their resource, or their export.
953
-
954
- > **All three entries are SHIPPED.** The traces entry rides the stable 1.x SDK;
955
- > the log and metric entries ride upstream's pre-1.0 SDKs (logs "development",
956
- > metrics alpha), pinned to this gem's capped ranges (GOTCHAS R2/R5) so a 0.x
957
- > drift is a deliberate re-prove, never a silent change. The pre-1.0 SDK
958
- > maturity is a version-pin caveat, not a deferral — the entries are available
959
- > and supported today.
960
- >
961
- > **Version-pin posture:** because the log and metric taps ride those pre-1.0
962
- > upstream SDKs, they are version-pinned and re-proven on every 0.x bump
963
- > rather than assumed stable — shipped and supported either way.
964
-
965
- ### The three entries
966
-
967
- All three share one signature (`token:` and `environment:` required; the
968
- redaction kwargs — the grouped `redact:` object and its legacy aliases
969
- `redact_keys:`/`redact_pii_keys:`, union semantics exactly as on `init` —
970
- are the only CUSTOMER redaction that tap applies — opt-in, none by
971
- default, scoped to that tap, additive above the always-on credential floor,
972
- which every tap applies with zero configuration; `diagnostics:` is tap-scoped
973
- narration). Construction
974
- validates loudly at boot and NEVER
975
- throws afterwards — a failure inside a tap can never break their pipeline.
976
- Naming across the foam SDKs (pinned by `spec/export_surface_spec.rb`):
977
- `create_ingest_span_processor` = `createFoamIngestSpanProcessor`,
978
- `create_ingest_log_record_processor` = `createFoamIngestLogRecordProcessor`,
979
- `create_ingest_metric_reader` = `createFoamIngestMetricReader`.
980
-
981
- **Traces** — a SpanProcessor for THEIR TracerProvider (scenario 4 snippet):
539
+ ### Client bodies
540
+
541
+ Client bodies are captured on the client span of `Net::HTTP`, Faraday,
542
+ Excon, and HTTPClient. Each client is patched in `:advanced` mode only, and
543
+ only when its OpenTelemetry instrumentation installed. Other clients are
544
+ not captured.
545
+
546
+ - `Net::HTTP`: a request sent through `body_stream` and a response read in
547
+ a block or into an IO are not captured.
548
+ - Faraday: bodies are captured on the Faraday span. A request sent from an
549
+ IO and a response streamed with `on_data` are not captured.
550
+ - Excon: a request sent with `request_block` and a response received with
551
+ `response_block` are not captured.
552
+ - HTTPClient: a request sent from an IO or as multipart parts is not
553
+ captured. A response read in a block is captured.
982
554
 
983
- ```ruby
984
- processor = Foam::Otel.create_ingest_span_processor(
985
- token: ENV.fetch("FOAM_OTEL_TOKEN"), environment: ENV.fetch("APP_ENV"),
986
- redact_keys: %w[voucher_code], # optional: extra secret keys, this tap only
987
- redact_pii_keys: %w[customer_email], # optional: your PII enumeration, this tap only
988
- diagnostics: false) # optional: per-tap [foam] narration
989
- their_tracer_provider.add_span_processor(processor)
990
- # returns a new instance per call; no-op inert instance under OTEL_SDK_DISABLED=true;
991
- # raises ArgumentError at construction for a blank token/environment
992
- ```
555
+ ## Loggers
993
556
 
994
- **Logs** — a LogRecordProcessor for THEIR LoggerProvider. The credential
995
- floor and the tap's `redact_keys`/`redact_pii_keys` (if any) match over
996
- record attributes and keys nested in structured bodies; free-text bodies ride
997
- RAW (no value-pattern pass):
557
+ Every line written through the standard library `Logger` is sent to Foam
558
+ as a log record carrying the current trace and span IDs. The record's body
559
+ is the formatted line and its severity follows the `Logger` level. The
560
+ app's log output is unchanged. A `Logger` created before `init` is
561
+ covered. `Rails.logger` lines are sent once each. `puts` and direct
562
+ writes to `$stdout` are not captured.
998
563
 
999
- ```ruby
1000
- provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: their_resource)
1001
- provider.add_log_record_processor(their_log_processor)
1002
- provider.add_log_record_processor(Foam::Otel.create_ingest_log_record_processor(
1003
- token: ENV.fetch("FOAM_OTEL_TOKEN"), environment: ENV.fetch("APP_ENV")))
1004
- OpenTelemetry.logger_provider = provider
1005
- ```
564
+ If the app already sends its logs through its own OpenTelemetry pipeline,
565
+ set `disable_log_sending: true` so records do not arrive twice. If another
566
+ SDK owns logs, set it and attach `create_foam_ingest_log_record_processor`
567
+ to that SDK's provider. `Foam.log` delivers either way.
1006
568
 
1007
- **Metrics** — a PeriodicMetricReader for THEIR MeterProvider. Attach order is
1008
- forgiving: `add_metric_reader` back-fills instruments created before the tap
1009
- attached. Foam's reader collects on its own schedule beside theirs:
569
+ TODO(jhartquist): working alongside other popular Ruby loggers.
1010
570
 
1011
- ```ruby
1012
- OpenTelemetry.meter_provider.add_metric_reader(Foam::Otel.create_ingest_metric_reader(
1013
- token: ENV.fetch("FOAM_OTEL_TOKEN"), environment: ENV.fetch("APP_ENV")))
1014
- ```
571
+ ## Pre-forking servers and threads
572
+
573
+ TODO(jhartquist)
1015
574
 
1016
- ### Door 2 — the required loop step
1017
-
1018
- The tap's exports to foam are outbound HTTP calls THEIR instrumentation will
1019
- span, and their pipeline hands those spans back to the tap — a feedback loop
1020
- in THEIR pipeline. Three layers keep it closed:
1021
-
1022
- 1. **The SDK suppression context** (automatic): foam's exporters send inside
1023
- `untraced`, which the official http instrumentations respect.
1024
- 2. **REQUIRED — suppress foam's endpoint host in THEIR http instrumentation**
1025
- (belt for non-contrib patches and older contrib versions): add
1026
- `untraced_hosts: ["otel.api.foam.ai"]` to their Net::HTTP/Excon config
1027
- (scenario 4 snippet). When the `OTEL_EXPORTER_OTLP_ENDPOINT` override is
1028
- active, list THAT host instead. Faraday/HTTP (httprb)/HTTPX cannot take
1029
- the option, but they also never see the tap's exports — foam's exporters
1030
- use Net::HTTP only.
1031
- 3. **The echo filter** (structural backstop): a client-kind span naming the
1032
- ACTIVE foam export host is telemetry about foam's own export call — the
1033
- tap refuses to re-export its own echo, with one loud warning naming the
1034
- missing step. Their pipeline keeps that span untouched.
1035
-
1036
- ### Identity on the wire
1037
-
1038
- | | their pipeline | foam's copy |
1039
- | --- | --- | --- |
1040
- | `service.name` / `service.version` / `service.instance.id` | theirs | theirs, untouched (the data carries THEIR identity) |
1041
- | 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) |
1042
- | `deployment.environment.name` | theirs (if any) | the tap's `environment:` argument (stamped at export time) |
1043
- | `foam.ingest.tier` | absent | `"external"` (the wire-contract marker) |
1044
- | `telemetry.distro.name` / `telemetry.distro.version` | absent | `"foam"` / the gem version (support reads the tap's presence and version from telemetry alone) |
1045
-
1046
- The stamp is merged onto FOAM'S COPY at export serialization — never onto
1047
- the shared objects; it changes exactly those four keys and nothing else.
1048
-
1049
- ### External-tier caveats (recorded, never fought)
1050
-
1051
- - THEIR sampler gates what the traces tap sees (unsampled spans never reach
1052
- processors).
1053
- - THEIR Views shape what the metrics tap receives; per-reader temporality is
1054
- not a Ruby knob — aggregations default cumulative (or the operator's
1055
- `OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE`), and cumulative totals
1056
- start at attach time.
1057
- - Lifecycle rides THEIR pipeline: their provider's `shutdown`/`force_flush`
1058
- drives the tap's. Serverless mirror: flush THEIR provider at handler end
1059
- (the platform freezes the process before batch timers fire).
1060
- - Kill switch and endpoint override apply to taps exactly as to `init`:
1061
- `OTEL_SDK_DISABLED=true` at construction → inert instance + one warning;
1062
- `OTEL_EXPORTER_OTLP_ENDPOINT` honored with the loud warning; per-signal
1063
- OTLP endpoint variants stay INERT + warned.
1064
-
1065
- ### Puma / Unicorn note (door 2)
1066
-
1067
- Construct taps where THEIR setup runs — with `SDK.configure` in an
1068
- initializer under cluster preload, taps are inherited by workers. The span
1069
- and log taps self-heal in the child (the stock batch processors reset on
1070
- first use after a fork); the metrics tap is restarted by foam's own
1071
- `Process._fork` hook, which the factory installs (a separate registry from
1072
- `init`'s own readers, so a per-worker re-init never orphans a tap — GOTCHAS
1073
- R7). `Foam::Otel.after_fork!` covers both if you wire it manually.
1074
-
1075
- ### Tests / CI
1076
-
1077
- Gate the add-line on the same condition you would give `enabled:` — the
1078
- placement line IS the switch (no foam-invented env var):
575
+ The current span and baggage are stored per fiber. A `Thread.new` or
576
+ `Fiber.new` starts with none, so spans started inside it begin a new trace.
577
+ To continue the trace, capture the context first and make it current
578
+ inside the thread:
1079
579
 
1080
580
  ```ruby
1081
- if ENV["APP_ENV"] == "production"
1082
- c.add_span_processor(Foam::Otel.create_ingest_span_processor(
1083
- token: ENV.fetch("FOAM_OTEL_TOKEN"), environment: ENV.fetch("APP_ENV")))
581
+ context = OpenTelemetry::Context.current
582
+ Thread.new do
583
+ OpenTelemetry::Context.with_current(context) { work }
1084
584
  end
1085
585
  ```
1086
586
 
1087
- ---
1088
-
1089
- ## Failure modes — what happens and where the warning appears
1090
-
1091
- | Situation | Behavior |
1092
- | --- | --- |
1093
- | blank `name`/`environment`, or `enabled` omitted | raises `ArgumentError` at boot (on your machine) |
1094
- | `enabled: true` but blank `token` | raises `ArgumentError` at boot |
1095
- | `enabled: false` | fully inert; helpers no-op silently |
1096
- | missing `version` | `[foam]` warning, continues |
1097
- | a foreign OTel SDK owns a signal | `[foam]` warning once, naming the owner AND the matching door-2 ingest entry; foam dark for that signal |
1098
- | `init` called twice | `[foam]` warning; returns the first instance (providers are process-global) |
1099
- | ingest entry with blank `token:`/`environment:` | raises `ArgumentError` at construction (on your machine) |
1100
- | ingest entry under `OTEL_SDK_DISABLED=true` | inert no-op instance + one `[foam]` warning; their setup line never crashes |
1101
- | ingest tap wired onto foam's OWN pipeline | `[foam]` wiring-bug warning once; the tap STANDS DOWN (zero duplicates) |
1102
- | `init()` exports a signal AND a tap on a separate foreign pipeline | `[foam]` wiring-bug warning once; both keep flowing (`foam.ingest.tier` distinguishes them) |
1103
- | their instrumentation traces foam's export calls (missing loop step) | echo filter refuses foam's own echo + one `[foam]` warning naming the required `untraced_hosts` step |
1104
-
1105
- ---
1106
-
1107
- ## Environment variables
1108
-
1109
- | Var | Posture |
1110
- | --- | --- |
1111
- | `OTEL_SDK_DISABLED=true` | HONORED — full kill switch, supersedes `enabled: true` (matched trimmed + case-insensitive, so `TRUE` / ` true ` also kill — this one emergency switch behaves the same in every foam SDK). Door-2 ingest entries honor it too: construction returns an inert instance (read once at construction; changing it implies a restart). |
1112
- | `OTEL_EXPORTER_OTLP_ENDPOINT` | HONORED — the one operator-level override of the pinned foam 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). |
1113
- | `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). |
1114
- | `OTEL_BSP_SCHEDULE_DELAY` / `OTEL_BLRP_SCHEDULE_DELAY` / `OTEL_METRIC_EXPORT_INTERVAL` | HONORED — batch cadence, read natively by the upstream SDK. |
1115
- | `FOAM_CAPTURE_PAYLOADS` | HONORED — the operator clamp over the `capture_payloads:` init option (`off`/`errors`/`always`, case-insensitive), overriding it in BOTH directions with one loud `[foam]` line when it changes the mode. An invalid value warns and falls back to the init option (never crashes a boot). This is the ONE foam-named env var the gem reads — an override valve over an init-declared option, never an on/off switch, token, or config fallback (those still arrive only through `init`'s explicit arguments). Read once at init; changing it implies a restart. |
1116
- | `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). |
1117
- | `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. |
1118
- | `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | INERT — as above (warns when set). |
1119
- | `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | INERT — as above (warns when set). |
1120
- | `OTEL_RUBY_EXPORTER_OTLP_SSL_VERIFY_NONE` | INERT — foam pins TLS peer verification (`ssl_verify_mode: VERIFY_PEER`) on every exporter constructor, both doors, so this vendored-exporter knob can never strip certificate verification off the credential-bearing export connection (1.4.0 security fix, CWE-295). Set → loud `[foam]` warning that it is inert; fix the trust chain (CA bundle) instead. |
1121
- | every other `OTEL_*` | INERT — `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` never override the init-declared identity; sampler/exporter/span-limit vars are ignored. |
1122
-
1123
- ---
1124
-
1125
- ## Troubleshooting
1126
-
1127
- - **Telemetry halved or doubled after a dependency bump** → check for a second
1128
- copy of the OTel API first: `bundle list | grep opentelemetry-api` must show
1129
- ONE. Two copies mean two global registries (silent no-op or doubled data).
1130
- - **No data at all** → set `diagnostics: true` and read the `[foam]` lines; check
1131
- `init` runs before your app code, that `enabled` is true, and that a token is
1132
- wired.
1133
- - **No door-2 data** → check, in order: `OTEL_SDK_DISABLED` unset (a killed tap
1134
- is inert and warned once at boot); the tap is on the RIGHT provider (the one
1135
- their setup actually registers — a wiring-bug warning at first traffic means
1136
- self-ingest stand-down); the `[foam]` wiring-bug/echo warnings in the boot
1137
- log; construct with `diagnostics: true` for per-export narration; for
1138
- metrics under Puma, see the Door 2 Puma note.
1139
- - **A downstream rejects `traceparent`** → set `OTEL_PROPAGATORS=none` to stop
1140
- injection while keeping telemetry flowing.
1141
-
1142
- ---
1143
-
1144
- See `GOTCHAS.md` (the traps and how foam handles them) and `RESEARCH.md` (the
1145
- primitive audit, instrumentation census, and redaction provenance).
587
+ ## Redaction
588
+
589
+ Foam masks the values of sensitive keys in everything it exports: span,
590
+ span event, and span link attributes; log bodies and attributes; metric
591
+ data point attributes; and resource attributes. A built-in list of
592
+ credential and personal-data keys always applies, matched by exact name
593
+ or by a sensitive segment of the key (`legacy_api_key_2`, `stripeToken`).
594
+ Keys match after normalization: case, `-`, `.`, and camelCase boundaries
595
+ are folded, so `apiKey`, `api-key`, and `API_KEY` are one key. Header
596
+ attributes match on the header name. The sensitive pairs of a query
597
+ string are masked in `url.query` and in any string value holding one.
598
+ Captured HTTP bodies are masked by content type with the same keys, with
599
+ their framing kept; a body the masking fails on is replaced whole.
600
+
601
+ The `redact` option adds the app's own keys, matched exactly after the
602
+ same normalization. A value under a built-in or `pii` key becomes
603
+ `[REDACTED]`. A value under a `secrets` key becomes `********`, followed
604
+ by its last four characters when it is 12 characters or longer.
605
+
606
+ Masking changes values only: keys stay present, and span names, event
607
+ names, and severity are untouched. It is applied after `before_send`, to
608
+ the copy sent to Foam, through `init` and through the ingest components
609
+ alike. The app's own span and log objects, and the copies handed to other
610
+ processors and exporters, are unchanged.
611
+
612
+ ## Before send
613
+
614
+ `before_send` is the app's last look at each span and log record before
615
+ it is sent to Foam. The hook receives an event and returns it, edited or
616
+ not, to send it, or `nil` to drop it. Metrics and Foam's own reports never
617
+ pass through it.
618
+
619
+ ```ruby
620
+ Foam.init(
621
+ # ...
622
+ before_send: ->(event) {
623
+ event unless event[:type] == :span && event[:attributes]["url.path"] == "/health"
624
+ }
625
+ )
626
+ ```
627
+
628
+ For a span, the event is a Hash with these keys:
629
+
630
+ ```ruby
631
+ {
632
+ type: :span,
633
+ name: "GET /users",
634
+ attributes: {"url.path" => "/users"},
635
+ events: [{name: "exception", attributes: {"exception.type" => "..."}, timestamp: 1788538831415926535}],
636
+ links: [{span_context: span_context, attributes: {}}],
637
+ # Read-only
638
+ kind: :server,
639
+ status: status,
640
+ start_timestamp: 1788538831415926535,
641
+ end_timestamp: 1788538831417126534,
642
+ trace_id: trace_id,
643
+ span_id: span_id,
644
+ parent_span_id: parent_span_id,
645
+ instrumentation_scope: instrumentation_scope
646
+ }
647
+ ```
648
+
649
+ For a log record:
650
+
651
+ ```ruby
652
+ {
653
+ type: :log,
654
+ body: "signed in",
655
+ severity_text: "INFO",
656
+ severity_number: 9,
657
+ attributes: {"user.id" => "42"},
658
+ # Read-only
659
+ timestamp: 1788538831415926535,
660
+ observed_timestamp: 1788538831415940102,
661
+ trace_id: trace_id,
662
+ span_id: span_id,
663
+ trace_flags: trace_flags,
664
+ instrumentation_scope: instrumentation_scope
665
+ }
666
+ ```
667
+
668
+ `name`, `attributes`, `events`, and `links` of a span and `body`,
669
+ `severity_text`, `severity_number`, and `attributes` of a log record are
670
+ sent as returned. The read-only fields hold what the SDK recorded, as the
671
+ SDK holds it; edits to them are ignored. Timestamps are Integer
672
+ nanoseconds since the epoch. An event added without a
673
+ `timestamp` gets the span's start time. A link added needs a
674
+ `span_context`.
675
+
676
+ The hook receives a copy: edits reach Foam only through the return value,
677
+ and the app's own span and log objects are unchanged. Redaction runs after
678
+ the hook, so the hook sees values before masking and what it returns is
679
+ masked before export. A hook that raises, or returns anything other than
680
+ an event of the same type, leaves the event as it was, without a
681
+ diagnostic. An event holding a value that cannot be copied is sent as it
682
+ was without calling the hook. A span or log record emitted inside the
683
+ hook passes through the hook itself.
684
+
685
+ ## OpenTelemetry configuration and compliance
686
+
687
+ TODO(jhartquist)
688
+
689
+ ## OpenTelemetry Profiling
690
+
691
+ Not supported. `state` reports `profile: :none`.
692
+
693
+ ## Development
694
+
695
+ ```sh
696
+ bundle install
697
+ bundle exec rspec # unit tests
698
+ bundle exec rake e2e # end-to-end tests
699
+ bundle exec standardrb --fix # lint and autoformat
700
+ bundle exec rake # unit, e2e, then lint (what CI checks)
701
+ ```
702
+
703
+ The end-to-end tests run a real OTLP collector and the fixture apps under
704
+ `e2e/apps/` with puma, then assert on what the collector received.
705
+ `rake e2e` downloads the pinned `otelcol-contrib` release to
706
+ `~/.cache/foam-otel` the first time (about 350 MB; set `FOAM_OTELCOL_DIR`
707
+ to cache it elsewhere) and installs the fixture apps' bundles. Run one file
708
+ with `bundle exec rspec e2e/spec/rack_init_spec.rb`.
709
+
710
+ Each run writes the collector config, the received telemetry, and the
711
+ output of both processes to `tmp/e2e/`. A failure prints the tail of both
712
+ stderr files.