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.
- checksums.yaml +4 -4
- data/README.md +596 -1029
- data/lib/foam/otel/internal/before_send.rb +75 -0
- data/lib/foam/otel/internal/body_capture.rb +274 -0
- data/lib/foam/otel/internal/body_decoder.rb +116 -0
- data/lib/foam/otel/internal/body_redactor.rb +358 -0
- data/lib/foam/otel/internal/constants.rb +73 -0
- data/lib/foam/otel/internal/content_type.rb +90 -0
- data/lib/foam/otel/internal/diagnostics.rb +61 -0
- data/lib/foam/otel/internal/endpoint.rb +28 -0
- data/lib/foam/otel/internal/excon/capture.rb +99 -0
- data/lib/foam/otel/internal/exporters.rb +193 -0
- data/lib/foam/otel/internal/http_client/capture.rb +103 -0
- data/lib/foam/otel/internal/ignored_hosts.rb +71 -0
- data/lib/foam/otel/internal/ingest.rb +83 -0
- data/lib/foam/otel/internal/init.rb +201 -0
- data/lib/foam/otel/internal/instrumentations.rb +163 -0
- data/lib/foam/otel/internal/lifecycle.rb +40 -0
- data/lib/foam/otel/internal/logs.rb +35 -0
- data/lib/foam/otel/internal/metrics.rb +55 -0
- data/lib/foam/otel/internal/net_http/capture.rb +81 -0
- data/lib/foam/otel/internal/otlp.rb +63 -0
- data/lib/foam/otel/internal/own_request.rb +27 -0
- data/lib/foam/otel/internal/propagation.rb +55 -0
- data/lib/foam/otel/internal/rack/body_tee.rb +68 -0
- data/lib/foam/otel/internal/rack/input_tee.rb +113 -0
- data/lib/foam/otel/internal/redactor/keys.rb +162 -0
- data/lib/foam/otel/internal/redactor.rb +259 -0
- data/lib/foam/otel/internal/report.rb +36 -0
- data/lib/foam/otel/internal/resource.rb +121 -0
- data/lib/foam/otel/internal/safely.rb +20 -0
- data/lib/foam/otel/internal/state.rb +166 -0
- data/lib/foam/otel/internal/traces.rb +22 -0
- data/lib/foam/otel/internal.rb +20 -0
- data/lib/foam/otel/version.rb +1 -267
- data/lib/foam/otel.rb +205 -29
- data/lib/foam/rack/middleware.rb +92 -0
- data/lib/foam-otel.rb +1 -2
- metadata +90 -306
- data/GOTCHAS.md +0 -1185
- data/LICENSE +0 -24
- data/RESEARCH.md +0 -427
- data/THIRD-PARTY-NOTICES +0 -76
- data/lib/foam/otel/api.rb +0 -321
- data/lib/foam/otel/before_send.rb +0 -287
- data/lib/foam/otel/classifier.rb +0 -89
- data/lib/foam/otel/config.rb +0 -296
- data/lib/foam/otel/constants.rb +0 -415
- data/lib/foam/otel/diagnostics.rb +0 -28
- data/lib/foam/otel/errors.rb +0 -58
- data/lib/foam/otel/fork_hooks.rb +0 -54
- data/lib/foam/otel/header_capture.rb +0 -416
- data/lib/foam/otel/ingest.rb +0 -649
- data/lib/foam/otel/ingest_metric_reader.rb +0 -87
- data/lib/foam/otel/init.rb +0 -782
- data/lib/foam/otel/instance.rb +0 -45
- data/lib/foam/otel/isolated_exporters.rb +0 -239
- data/lib/foam/otel/isolated_http_client.rb +0 -586
- data/lib/foam/otel/llm/anthropic_shim.rb +0 -84
- data/lib/foam/otel/llm/gemini_shim.rb +0 -122
- data/lib/foam/otel/llm/http_anthropic_shim.rb +0 -318
- data/lib/foam/otel/llm/openai_shim.rb +0 -123
- data/lib/foam/otel/llm/ruby_llm_shim.rb +0 -166
- data/lib/foam/otel/llm/ruby_openai_shim.rb +0 -138
- data/lib/foam/otel/llm.rb +0 -329
- data/lib/foam/otel/logger_bridge.rb +0 -187
- data/lib/foam/otel/metrics.rb +0 -137
- data/lib/foam/otel/payload_capture.rb +0 -655
- data/lib/foam/otel/pipelines.rb +0 -311
- data/lib/foam/otel/prepend_collision_shield.rb +0 -193
- data/lib/foam/otel/redacting_exporter.rb +0 -135
- data/lib/foam/otel/redaction.rb +0 -1134
- data/lib/foam/otel/resource.rb +0 -47
- data/lib/foam/otel/runtime_metrics.rb +0 -120
- data/lib/foam/otel/session_stitching.rb +0 -112
data/README.md
CHANGED
|
@@ -1,1145 +1,712 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
Foam's OpenTelemetry
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
34
|
+
TODO(jhartquist)
|
|
55
35
|
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
64
|
-
name: "checkout-api",
|
|
65
|
-
environment: ENV
|
|
66
|
-
enabled:
|
|
67
|
-
token: ENV
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
75
|
+
To install an instrumentation outside the bundle, list it in
|
|
76
|
+
`additional_instrumentations`.
|
|
251
77
|
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
651
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
734
|
-
Foam
|
|
735
|
-
|
|
315
|
+
headers = {}
|
|
316
|
+
Foam.inject_trace_context(headers)
|
|
317
|
+
producer.publish(topic, payload, headers: headers)
|
|
736
318
|
```
|
|
737
319
|
|
|
738
|
-
|
|
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
|
-
|
|
742
|
-
|
|
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
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
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
|
|
756
|
-
Foam
|
|
757
|
-
Foam
|
|
758
|
-
Foam
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
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
|
|
770
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
440
|
+
Returns whether Foam initialized, which instrumentations registered, which
|
|
441
|
+
signals have an export path, and the identity `init` was given.
|
|
798
442
|
|
|
799
|
-
|
|
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
|
|
804
|
-
|
|
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
|
-
|
|
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
|
-
|
|
810
|
-
|
|
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
|
-
|
|
813
|
-
|
|
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
|
-
|
|
819
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
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
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
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
|
-
|
|
492
|
+
TODO(jhartquist)
|
|
871
493
|
|
|
872
|
-
|
|
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
|
-
|
|
878
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
506
|
+
TODO(jhartquist): `:basic` headers on client spans.
|
|
914
507
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
508
|
+
Header capture is applied through the Rack instrumentation. An
|
|
509
|
+
`additional_instrumentations` entry naming it replaces this
|
|
510
|
+
configuration.
|
|
918
511
|
|
|
919
|
-
###
|
|
512
|
+
### Body capture
|
|
920
513
|
|
|
921
|
-
|
|
922
|
-
|
|
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
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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
|
-
###
|
|
526
|
+
### Rack bodies
|
|
932
527
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
provider.
|
|
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
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
```
|
|
571
|
+
## Pre-forking servers and threads
|
|
572
|
+
|
|
573
|
+
TODO(jhartquist)
|
|
1015
574
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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.
|