foam-otel 0.1.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/GOTCHAS.md +225 -0
- data/LICENSE +24 -0
- data/README.md +263 -61
- data/RESEARCH.md +233 -0
- data/THIRD-PARTY-NOTICES +38 -0
- data/lib/foam/otel/api.rb +211 -0
- data/lib/foam/otel/classifier.rb +51 -0
- data/lib/foam/otel/config.rb +41 -45
- data/lib/foam/otel/constants.rb +103 -48
- data/lib/foam/otel/diagnostics.rb +28 -0
- data/lib/foam/otel/errors.rb +10 -21
- data/lib/foam/otel/fork_hooks.rb +47 -0
- data/lib/foam/otel/init.rb +248 -282
- data/lib/foam/otel/instance.rb +42 -0
- data/lib/foam/otel/metrics.rb +106 -0
- data/lib/foam/otel/pipelines.rb +137 -0
- data/lib/foam/otel/redacting_exporter.rb +61 -0
- data/lib/foam/otel/redaction.rb +227 -31
- data/lib/foam/otel/resource.rb +38 -0
- data/lib/foam/otel/version.rb +4 -1
- data/lib/foam/otel.rb +14 -13
- metadata +71 -19
- data/lib/foam/otel/logger_bridge.rb +0 -75
- data/lib/foam/otel/rack.rb +0 -243
- data/lib/foam/otel/railtie.rb +0 -66
- data/lib/foam/otel/sidekiq.rb +0 -88
- data/lib/foam/otel/stamps.rb +0 -104
- data/lib/foam/otel/vendor/datadog.rb +0 -35
- data/lib/foam/otel/vendor/rollbar.rb +0 -46
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb0ea8b43b76506cdd72000ba735c36e0bd903b26d83347779cca5fc309b716b
|
|
4
|
+
data.tar.gz: 1a35c9ee39299519c146efb826aa9c02598df6ca23e018b0d8c8e62932a9e234
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e689868d02fc414fa6d05eddbc2cb73dd9b5b13b4819a68c669c1fb183bfdea8086e75bd2468661b44e2677f381369d6c3a815e5a0135e7ed9c22a8c86a7bb95
|
|
7
|
+
data.tar.gz: fc80c105460917ad1c126604cf6c092c61d7a8c4263ce00d9296a16911f9b25412ee0d5f132619307ebb8862ff4af03c28794935f4758a3a19ad7b5894f4bbd0
|
data/GOTCHAS.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# GOTCHAS — foam-otel (Ruby)
|
|
2
|
+
|
|
3
|
+
Language traps that silently break telemetry or the host app, and the
|
|
4
|
+
non-obvious decisions this package made about them (BASE_PACKAGE_SPEC rule 37).
|
|
5
|
+
Every entry carries: the Trap, the SOURCES (links / installed-source refs), the
|
|
6
|
+
Decision & why, the Mitigation, and the NAME OF THE TEST that proves it. CI runs
|
|
7
|
+
those tests.
|
|
8
|
+
|
|
9
|
+
Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
|
|
10
|
+
1.13.0, -logs-sdk 0.6.1, -metrics-sdk 0.15.0, -api 1.11.0, -exporter-otlp 0.34.1,
|
|
11
|
+
-instrumentation-rack 0.31.1, -instrumentation-rails 0.42.0).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## R1: Puma/Unicorn preload fork — exporter threads do not survive fork (CRITICAL)
|
|
16
|
+
|
|
17
|
+
- **Trap**: In cluster-mode servers the SDK boots in the master and `fork`s
|
|
18
|
+
workers. Background exporter/reader threads are not copied into the children;
|
|
19
|
+
the parent exports while workers are dark (or a child inherits a mutex held at
|
|
20
|
+
fork and deadlocks). The default production topology fails while a non-forking
|
|
21
|
+
test app shows green.
|
|
22
|
+
- **Sources**:
|
|
23
|
+
- Upstream: "BatchSpanProcessor should not spawn a thread during boot" —
|
|
24
|
+
https://github.com/open-telemetry/opentelemetry-ruby/issues/462
|
|
25
|
+
- Upstream: SDK fork safety — https://github.com/open-telemetry/opentelemetry-ruby/issues/7
|
|
26
|
+
- Installed source — traces/logs SELF-HEAL: `BatchSpanProcessor#reset_on_fork`
|
|
27
|
+
(opentelemetry-sdk `trace/export/batch_span_processor.rb:174-184`, invoked
|
|
28
|
+
from `on_finish`); `BatchLogRecordProcessor#reset_on_fork`
|
|
29
|
+
(opentelemetry-logs-sdk `logs/export/batch_log_record_processor.rb:174-182`).
|
|
30
|
+
- Installed source — metrics DO NOT self-heal: `PeriodicMetricReader` starts
|
|
31
|
+
its thread in `initialize` and only restarts via `after_fork`
|
|
32
|
+
(opentelemetry-metrics-sdk `metrics/export/periodic_metric_reader.rb:40,82-87`);
|
|
33
|
+
the `ForkHooks` that drive it are attached ONLY inside `SDK.configure`
|
|
34
|
+
(`metrics/configurator_patch.rb:57`), which foam bypasses.
|
|
35
|
+
- **Decision & why**: The trace/log batch processors self-heal on their first
|
|
36
|
+
span/log in the child — foam relies on upstream and never reimplements it. The
|
|
37
|
+
PeriodicMetricReader does not, and foam bypasses `SDK.configure`, so foam
|
|
38
|
+
installs its OWN `Process._fork` hook to restart foam's readers in the child.
|
|
39
|
+
- **Mitigation**: `lib/foam/otel/fork_hooks.rb` prepends `Process._fork` and
|
|
40
|
+
calls `Foam::Otel.after_fork!` in the child (restarts tracked metric readers,
|
|
41
|
+
`lib/foam/otel/api.rb`). Also exposed for `Puma on_worker_boot` /
|
|
42
|
+
`Unicorn after_fork`. The README's cluster-mode recipe calls `init` per worker
|
|
43
|
+
so each worker also mints a fresh `service.instance.id`.
|
|
44
|
+
- **Test**: `spec/fork_spec.rb` — "a real fork does not crash and the child can
|
|
45
|
+
flush", "restarts every tracked metric reader and never raises".
|
|
46
|
+
|
|
47
|
+
## R2: Signal maturity is uneven — logs & metrics SDKs are pre-1.0
|
|
48
|
+
|
|
49
|
+
- **Trap**: Ruby OTel is stable for traces only; the metrics SDK is alpha and the
|
|
50
|
+
logs SDK is "development". APIs and defaults can change under a patch bump.
|
|
51
|
+
- **Sources**: OTel Ruby signal status (Traces stable, Metrics/Logs development)
|
|
52
|
+
— https://opentelemetry.io/docs/languages/ruby/ ; the metrics-sdk README states
|
|
53
|
+
"alpha … things may break and APIs may change". Installed versions:
|
|
54
|
+
logs-sdk 0.6.1, metrics-sdk 0.15.0.
|
|
55
|
+
- **Decision & why**: Pin with pessimistic constraints (`~> 0.6`, `~> 0.15`) and
|
|
56
|
+
wrap both signals behind stable foam helpers (`log`, the four metric helpers)
|
|
57
|
+
so app code never touches the churning API directly.
|
|
58
|
+
- **Mitigation**: gemspec pins + the helper wrappers (`lib/foam/otel/api.rb`,
|
|
59
|
+
`lib/foam/otel/metrics.rb`); the lockfile freezes exact versions.
|
|
60
|
+
- **Test**: `spec/version_spec.rb` — pins the supported SDK lines and proves the
|
|
61
|
+
helpers work over the pinned pre-1.0 SDKs.
|
|
62
|
+
|
|
63
|
+
## R3: Contrib instrumentations can themselves raise
|
|
64
|
+
|
|
65
|
+
- **Trap**: Auto-instrumentation patches third-party libraries; a patched method
|
|
66
|
+
or an install block can raise. The "never-throw" convention is not enforced.
|
|
67
|
+
- **Sources**: Registry rescues each install (`StandardError` only) —
|
|
68
|
+
opentelemetry-registry `instrumentation/registry.rb:62-91`; `Base#install`
|
|
69
|
+
itself does not rescue (`instrumentation-base base.rb:218-227`), so a
|
|
70
|
+
non-StandardError escapes. Runtime-raise discussion:
|
|
71
|
+
https://github.com/open-telemetry/opentelemetry-ruby-contrib/discussions/241
|
|
72
|
+
- **Decision & why**: foam's never-throw wraps FOAM's helpers, not tier-2
|
|
73
|
+
internals. foam installs contrib gems through the official registry (inheriting
|
|
74
|
+
its per-instrumentation rescue) and adds its own guard around activation; a
|
|
75
|
+
contrib patch that raises at request time is the contrib's responsibility and
|
|
76
|
+
is not swallowed (an app-visible request error must not be hidden).
|
|
77
|
+
- **Mitigation**: `lib/foam/otel/init.rb` (`activate_instrumentations` rescues;
|
|
78
|
+
each `require` guards `LoadError`; `install_additional` is fault-isolated per
|
|
79
|
+
instance). Tenant/`additional_*` instances are wrapped by
|
|
80
|
+
`GuardedSpanProcessor` / `GuardedLogRecordProcessor` (`lib/foam/otel/pipelines.rb`).
|
|
81
|
+
- **Test**: `spec/tenant_seam_spec.rb` — "fault-isolates a throwing
|
|
82
|
+
additional_instrumentations instance", "skips a THROWING additional span
|
|
83
|
+
processor without breaking the app or foam".
|
|
84
|
+
|
|
85
|
+
## R4: Rails initializer / Zeitwerk / middleware ordering
|
|
86
|
+
|
|
87
|
+
- **Trap**: `init` racing Zeitwerk autoload, and the official rack middleware not
|
|
88
|
+
being inserted, silently misorder or omit hooks. A known contrib bug: the
|
|
89
|
+
action_pack railtie installs `-rack` with an EMPTY config, overriding rack
|
|
90
|
+
options.
|
|
91
|
+
- **Sources**: contrib #88 (action_pack overrides rack config) —
|
|
92
|
+
https://github.com/open-telemetry/opentelemetry-ruby-contrib/issues/88 ;
|
|
93
|
+
action_pack railtie inserts the rack middleware
|
|
94
|
+
(`opentelemetry-instrumentation-action_pack .../railtie.rb`).
|
|
95
|
+
- **Decision & why**: foam ships NO middleware — the official
|
|
96
|
+
`-rack`/`-action_pack` own the inbound span and the middleware insertion. foam
|
|
97
|
+
is insulated from contrib #88 because its redaction is at the EXPORTER boundary,
|
|
98
|
+
not rack config (so an overridden rack config cannot defeat masking). The
|
|
99
|
+
integration calls `init` before `Rails.application.initialize!` so
|
|
100
|
+
`install_all` registers the instrumentation before the middleware stack
|
|
101
|
+
finalizes.
|
|
102
|
+
- **Mitigation**: the README recipe places `init` before `initialize!`; the
|
|
103
|
+
conformance app demonstrates it.
|
|
104
|
+
- **Test**: `test-apps/ruby-rails` conformance gate (`--dialect official`) — a
|
|
105
|
+
real Rails app boots with foam and the official rack span carries the inbound
|
|
106
|
+
telemetry (`/ok`, `/fail`, `/reject` assertions).
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## F1: Span attributes freeze at finish — the redaction floor cannot be a processor
|
|
111
|
+
|
|
112
|
+
- **Trap**: The obvious place to mask attributes is a SpanProcessor, but the Ruby
|
|
113
|
+
SDK freezes a span's attributes at `finish` BEFORE any `on_finish` processor
|
|
114
|
+
runs — so a processor cannot mutate them, and neither can a tenant processor
|
|
115
|
+
see a masked view.
|
|
116
|
+
- **Sources**: installed source — `Span#finish` does
|
|
117
|
+
`@attributes = validated_attributes(@attributes).freeze` then sets `@ended`
|
|
118
|
+
(opentelemetry-sdk `trace/span.rb:277,280`); `to_span_data` returns the frozen
|
|
119
|
+
`@attributes` by reference (`trace/span.rb:296-307`).
|
|
120
|
+
- **Decision & why**: foam runs the always-on floor (rule 14) at the EXPORTER
|
|
121
|
+
boundary — a `RedactingSpanExporter`/`RedactingLogRecordExporter` that rebuilds
|
|
122
|
+
the mutable `SpanData`/`LogRecordData` Structs with masked attributes/events/
|
|
123
|
+
body before serialization. This guarantees no raw secret leaves the process in
|
|
124
|
+
foam's own export.
|
|
125
|
+
- **Divergence (TODO pcga11)**: because attributes freeze at finish, a tenant
|
|
126
|
+
`additional_span_processor` receives span attributes UNMASKED. Masking is
|
|
127
|
+
EXPORT-only in Ruby, so the rule-18 C "tenant sees already-masked" guarantee
|
|
128
|
+
holds ONLY for values the caller pre-masks with `redact()` — NOT for
|
|
129
|
+
third-party instrumentation attributes AND NOT for values passed to
|
|
130
|
+
`set_attribute`/`set_attributes`. Foam's own export is always masked. Recorded
|
|
131
|
+
honestly, not papered over; surfaced to pcga11 as spec-gap material (Ruby
|
|
132
|
+
offers no safe pre-freeze hook — the `on_finishing` hook runs inside the span
|
|
133
|
+
mutex, so a processor calling `set_attribute` there would deadlock).
|
|
134
|
+
- **Mitigation**: `lib/foam/otel/redacting_exporter.rb` + `lib/foam/otel/redaction.rb`.
|
|
135
|
+
- **Test**: `spec/redacting_exporter_spec.rb` (masking on the wire),
|
|
136
|
+
`spec/redaction_spec.rb` (the floor, tested exhaustively).
|
|
137
|
+
|
|
138
|
+
## F2: OTEL_SDK_DISABLED and OTEL_PROPAGATORS are only honored by SDK.configure
|
|
139
|
+
|
|
140
|
+
- **Trap**: Operators expect `OTEL_SDK_DISABLED=true` and `OTEL_PROPAGATORS=none`
|
|
141
|
+
to work. The Ruby SDK reads them ONLY inside `SDK.configure`, which foam
|
|
142
|
+
bypasses — so foam would silently ignore both.
|
|
143
|
+
- **Sources**: `OTEL_SDK_DISABLED` checked only in `SDK.configure`
|
|
144
|
+
(opentelemetry-sdk `sdk.rb:63-67`, exact string `'true'`); `OTEL_PROPAGATORS`
|
|
145
|
+
read only by `Configurator#configure_propagation`
|
|
146
|
+
(`sdk/configurator.rb:209-225`, `none` → NoopTextMapPropagator). Spec:
|
|
147
|
+
https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/
|
|
148
|
+
- **Decision & why**: foam implements both itself. `OTEL_SDK_DISABLED=true`
|
|
149
|
+
(exact string) supersedes `enabled: true` and goes fully inert.
|
|
150
|
+
`OTEL_PROPAGATORS=none` installs a no-op propagator (injection off, telemetry
|
|
151
|
+
keeps flowing); any other value warns-and-ignores (foam's propagator set is
|
|
152
|
+
fixed).
|
|
153
|
+
- **Mitigation**: `lib/foam/otel/init.rb` (`killed = ENV["OTEL_SDK_DISABLED"] == "true"`,
|
|
154
|
+
`configure_propagation`).
|
|
155
|
+
- **Test**: `spec/init_spec.rb` (OTEL_SDK_DISABLED precedence + exact-string),
|
|
156
|
+
`spec/env_vars_spec.rb` (OTEL_PROPAGATORS none / default / ignored).
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## F3: Last-write-wins globals — foam cannot defend a slot claimed AFTER init
|
|
161
|
+
|
|
162
|
+
- **Trap**: Ruby's OTel global setters are last-write-wins — a foreign SDK that
|
|
163
|
+
calls `OpenTelemetry.tracer_provider = …` AFTER foam's init silently
|
|
164
|
+
overwrites foam's provider, and foam's helpers then emit through the foreign
|
|
165
|
+
provider instead of to foam, with no error.
|
|
166
|
+
- **Sources**: the setter assigns unconditionally —
|
|
167
|
+
`opentelemetry-api opentelemetry.rb:52-60` (`@tracer_provider = provider` runs
|
|
168
|
+
regardless of the current occupant); same shape for meter/logger.
|
|
169
|
+
- **Decision & why**: there is no setter callback to intercept the displacement,
|
|
170
|
+
so foam re-checks LAZILY: whenever a passthrough/helper reads the live provider
|
|
171
|
+
(`get_tracer`/`get_meter`/`get_logger`, and the metric cache rebind), if the
|
|
172
|
+
slot no longer holds the provider foam registered, foam warns ONCE per signal
|
|
173
|
+
(rule 11 / 18 step 5). Foam cannot *defend* the slot — this is a genuine Ruby
|
|
174
|
+
divergence from the spec's "foam-first → not displaced" test-case (surfaced to
|
|
175
|
+
pcga11). The durable answer is load-order discipline: init per worker, and load
|
|
176
|
+
foam last.
|
|
177
|
+
- **Mitigation**: `Foam::Otel.warn_if_displaced` (`lib/foam/otel/init.rb`), called
|
|
178
|
+
from the passthroughs (`lib/foam/otel/api.rb`) and the metric path
|
|
179
|
+
(`lib/foam/otel/metrics.rb`).
|
|
180
|
+
- **Test**: `spec/coexistence_spec.rb` — "warns ONCE when a foreign SDK displaces
|
|
181
|
+
a slot after init".
|
|
182
|
+
|
|
183
|
+
## General gotchas (applicable to Ruby)
|
|
184
|
+
|
|
185
|
+
- **G1 — init after target import / pre-init no-op**: the API's proxy providers
|
|
186
|
+
make every helper a silent no-op before `init`, and instruments rebind on the
|
|
187
|
+
provider swap; `init` installs instrumentation before app code runs.
|
|
188
|
+
*Test*: `spec/public_api_spec.rb` ("before init — silent no-ops").
|
|
189
|
+
- **G2 — process ends before flush**: batch processors buffer; `flush` is public
|
|
190
|
+
and the README wires it at handler end / worker exit.
|
|
191
|
+
*Test*: `spec/fork_spec.rb` (child flush).
|
|
192
|
+
- **G4 — exporter self-tracing loop**: the OTLP exporter sends inside an
|
|
193
|
+
`OpenTelemetry::Common::Utilities.untraced` context the official
|
|
194
|
+
instrumentation respects (opentelemetry-exporter-otlp `exporter.rb:147`), so
|
|
195
|
+
export never traces itself; `ignored_outbound_hosts` + the endpoint host also
|
|
196
|
+
feed the http-client instrumentations' `untraced_hosts`
|
|
197
|
+
(`lib/foam/otel/init.rb` `configure_loop_guard`).
|
|
198
|
+
*Test*: `test-apps/ruby-rails` gate (no feedback spans appear).
|
|
199
|
+
- **G5 — context across async boundaries**: `Context.current` is fiber-local; a
|
|
200
|
+
raw `Thread.new` loses the span. foam relies on the official
|
|
201
|
+
`-concurrent_ruby` and `-active_job` instrumentations (bundled via `-rails`) to
|
|
202
|
+
carry context across Rails' pools and jobs; a hand-rolled thread is the FDE's
|
|
203
|
+
to wrap. *Documented*; conformance covers the rack-context path.
|
|
204
|
+
- **G7 — metric cardinality explosion**: the metric helpers forward
|
|
205
|
+
caller-supplied `attributes` verbatim; an unbounded attribute value (user id,
|
|
206
|
+
raw path) explodes the series count. The README carries the warning with the
|
|
207
|
+
classic bad example. *Test*: `spec/metrics_spec.rb` (attribute passthrough).
|
|
208
|
+
- **G9 — a second SDK / injected agent**: the classifier detects a foreign
|
|
209
|
+
provider per signal and warns by name; foam registers only into free slots.
|
|
210
|
+
*Test*: `spec/coexistence_spec.rb` (8-permutation matrix), `spec/classifier_spec.rb`.
|
|
211
|
+
- **G12 — two copies of the OTel API**: two gem copies → two global registries →
|
|
212
|
+
silent no-op or doubled telemetry. Cannot be seen at build time; the README
|
|
213
|
+
troubleshooting entry says to check `bundle list | grep opentelemetry-api` for
|
|
214
|
+
one copy first. *Documented* (README).
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## Signatures
|
|
219
|
+
|
|
220
|
+
A human review appends a row here (name, date, package version). A missing or
|
|
221
|
+
stale signature blocks review (rule 37).
|
|
222
|
+
|
|
223
|
+
| Reviewer | Date | Package version | Notes |
|
|
224
|
+
| --- | --- | --- | --- |
|
|
225
|
+
| @telaviv | 2026-07-24 | 1.0.0 | initial BASE_PACKAGE_SPEC pass — reviewed |
|
data/LICENSE
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Foam Proprietary License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Foam AI, Inc. All rights reserved.
|
|
4
|
+
|
|
5
|
+
This software is licensed, not sold. Use is permitted only by Foam
|
|
6
|
+
and by customers with an active Foam agreement, solely to instrument
|
|
7
|
+
their own services for the Foam platform. No other person or entity
|
|
8
|
+
may use, copy, modify, redistribute, or sublicense this software.
|
|
9
|
+
This license terminates automatically when the agreement ends.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
12
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
13
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
14
|
+
NONINFRINGEMENT. IN NO EVENT SHALL FOAM BE LIABLE FOR ANY CLAIM,
|
|
15
|
+
DAMAGES OR OTHER LIABILITY ARISING FROM, OUT OF OR IN CONNECTION
|
|
16
|
+
WITH THE SOFTWARE OR ITS USE, EXCEPT AS EXPRESSLY PROVIDED IN THE
|
|
17
|
+
APPLICABLE FOAM AGREEMENT.
|
|
18
|
+
|
|
19
|
+
If you have a separate written agreement with Foam covering this
|
|
20
|
+
software, that agreement governs over this file.
|
|
21
|
+
|
|
22
|
+
Third-party open-source components this software depends on are
|
|
23
|
+
listed in THIRD-PARTY-NOTICES and remain under their own licenses;
|
|
24
|
+
nothing in this file restricts the rights those licenses grant.
|
data/README.md
CHANGED
|
@@ -1,76 +1,278 @@
|
|
|
1
|
-
# foam-otel
|
|
1
|
+
# foam-otel (Ruby)
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
Foam's OpenTelemetry core for Ruby services. A thin, safe wrapper over the
|
|
4
|
+
official OpenTelemetry libraries: foam owns the pipeline (providers, batch
|
|
5
|
+
processors, OTLP export to the foam fleet endpoint, an always-on redaction
|
|
6
|
+
floor), turns on automatic tier-1/2 instrumentation, and hands you a small set
|
|
7
|
+
of never-throw helpers — and nothing else.
|
|
8
|
+
|
|
9
|
+
Built to `docs/BASE_PACKAGE_SPEC.md`. This README is the manual you integrate
|
|
10
|
+
from.
|
|
11
|
+
|
|
12
|
+
Foam builds on the OpenTelemetry authors' work (https://opentelemetry.io) — see
|
|
13
|
+
`THIRD-PARTY-NOTICES`. Licensed, not sold — see `LICENSE`.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Install
|
|
7
18
|
|
|
8
19
|
```ruby
|
|
9
20
|
# Gemfile
|
|
10
21
|
gem "foam-otel"
|
|
22
|
+
# Bundle the official instrumentation for whatever your app uses; foam
|
|
23
|
+
# auto-activates each when present (presence-checked). Rails + Rack come with
|
|
24
|
+
# foam-otel; add others as needed:
|
|
25
|
+
gem "opentelemetry-instrumentation-pg" # e.g. Postgres
|
|
26
|
+
gem "opentelemetry-instrumentation-redis" # e.g. Redis
|
|
27
|
+
gem "opentelemetry-instrumentation-sidekiq" # e.g. Sidekiq
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Supported versions** (tested in CI, `spec/version_spec.rb`): Ruby **>= 3.1**;
|
|
31
|
+
OpenTelemetry API **`opentelemetry-api` ~> 1.1** (1.x). The metrics and logs
|
|
32
|
+
SDKs are pre-1.0 upstream (metrics alpha, logs development) and are pinned with
|
|
33
|
+
pessimistic constraints; foam wraps them behind stable helpers so app code never
|
|
34
|
+
touches the churning API (see GOTCHAS R2).
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Quick start — scenario 1: a clean service (the common case)
|
|
39
|
+
|
|
40
|
+
Put `init` FIRST, before your app boots. In Rails, that means an initializer
|
|
41
|
+
(and, for cluster mode, per worker — see "Puma / Unicorn" below).
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
# config/initializers/foam.rb (the one config module you own)
|
|
45
|
+
require "foam/otel"
|
|
46
|
+
|
|
47
|
+
Foam::Otel.init(
|
|
48
|
+
name: "checkout-api", # → service.name
|
|
49
|
+
environment: ENV.fetch("APP_ENV", "development"), # → deployment.environment.name (verbatim)
|
|
50
|
+
enabled: !%w[test ci].include?(ENV["APP_ENV"]), # silence tests/CI; export everywhere else
|
|
51
|
+
token: ENV.fetch("FOAM_TOKEN"), # required WHEN enabled — wire it explicitly
|
|
52
|
+
version: ENV["GIT_SHA"] # → service.version (recommended; optional)
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Outcome**: foam registers the tracer/meter/logger providers, exports OTLP to
|
|
57
|
+
the foam fleet endpoint, and auto-activates the official rack/rails
|
|
58
|
+
instrumentation (and any other bundled `opentelemetry-instrumentation-*`). Every
|
|
59
|
+
inbound request, DB call, HTTP call, and job the official instrumentations cover
|
|
60
|
+
now flows to foam, with the redaction floor applied on the wire.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## The `init` options
|
|
65
|
+
|
|
66
|
+
| Option | Type | Required | Default | What it does |
|
|
67
|
+
| --- | --- | --- | --- | --- |
|
|
68
|
+
| `name:` | String | yes | — | `service.name`. Blank → raises at boot. |
|
|
69
|
+
| `environment:` | String | yes | — | `deployment.environment.name`, exported verbatim. A value outside `{production, staging, development, test}` warns but is never rewritten. Blank → raises. |
|
|
70
|
+
| `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. |
|
|
71
|
+
| `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. Blank while enabled → raises at boot. |
|
|
72
|
+
| `version:` | String | no | nil | `service.version`, verbatim (git SHA recommended). Missing → warns and continues. Never detected at runtime. |
|
|
73
|
+
| `redact_keys:` | Array<String> | no | `[]` | Extra field names treated as SECRETS (tail-masked). EXTENDS the always-on floor. |
|
|
74
|
+
| `redact_pii_keys:` | Array<String> | no | `[]` | Field names treated as PII (full `[REDACTED]`, no tail). Foam ships no PII preset — this is your enumeration. |
|
|
75
|
+
| `additional_span_processors:` | Array | no | `[]` | Tenant seam: constructed SpanProcessor instances added to foam's pipeline (additive; never replace foam's export). See coexistence. |
|
|
76
|
+
| `additional_log_record_processors:` | Array | no | `[]` | Tenant seam, logs. |
|
|
77
|
+
| `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
|
|
78
|
+
| `additional_instrumentations:` | Array | no | `[]` | Constructed tier-2 instrumentation instances to register (fault-isolated: one that throws is skipped with a `[foam]` warning). |
|
|
79
|
+
| `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. |
|
|
80
|
+
| `diagnostics:` | Boolean | no | false | Verbose `[foam]` self-reporting of init/health. Warnings and errors are always loud regardless. |
|
|
81
|
+
|
|
82
|
+
**Deliberately absent** (and why): `endpoint`, `cadence`/`sampling`,
|
|
83
|
+
`is_production`, `force_export`, `disabled_environments`, and any
|
|
84
|
+
processor/exporter injection beyond the tenant seam. Customers identify their
|
|
85
|
+
service and turn foam on or off; they can never drop a share of the data, inject
|
|
86
|
+
their own pipeline pieces, or redirect export from code. The only endpoint
|
|
87
|
+
override is the operator-level `OTEL_EXPORTER_OTLP_ENDPOINT` env var (below).
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## The helpers
|
|
92
|
+
|
|
93
|
+
All helpers never raise, and no-op silently before `init` and when disabled.
|
|
11
94
|
|
|
12
|
-
|
|
13
|
-
|
|
95
|
+
**Lifecycle**
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
Foam::Otel.flush # force-flush buffered pipelines (call at handler/worker exit)
|
|
99
|
+
Foam::Otel.shutdown # flush + shut down providers (normal exit is auto via at_exit)
|
|
100
|
+
Foam::Otel.after_fork! # restart metric-reader threads after a fork (see cluster mode)
|
|
14
101
|
```
|
|
15
102
|
|
|
16
|
-
|
|
17
|
-
detection (zero customer effort):
|
|
18
|
-
|
|
19
|
-
- **Rails**: railtie inserts the Rack middleware at position 0, captures
|
|
20
|
-
controller exceptions that `rescue_from` swallows (via
|
|
21
|
-
`process_action.action_controller`), and broadcasts `Rails.logger` lines to
|
|
22
|
-
foam as `log.rails` records — the app's own logger chain is never touched.
|
|
23
|
-
- **Sidekiq**: server middleware auto-registers; one `sidekiq.consume <queue>`
|
|
24
|
-
consumer span per job, failures re-raised identically (retries untouched).
|
|
25
|
-
- **Datadog** (SPEC §7.3): foam spans and logs carry `dd.trace_id`/`dd.span_id`
|
|
26
|
-
verbatim from the DD correlation API when a DD trace is active. Foam never
|
|
27
|
-
patches, parents from, or reconfigures the DD tracer.
|
|
28
|
-
- **Rollbar** (SPEC §12): exceptions the app reports to Rollbar (e.g. inside
|
|
29
|
-
`rescue_from` handlers) are also recorded on the active foam span — Rollbar
|
|
30
|
-
itself is untouched, and each exception records at most once per span.
|
|
31
|
-
|
|
32
|
-
| option | default | |
|
|
33
|
-
|---|---|---|
|
|
34
|
-
| `service_name:` | (required) | resource `service.name` |
|
|
35
|
-
| `token:` | `FOAM_OTEL_TOKEN` env | missing → warn + inert |
|
|
36
|
-
| `endpoint:` | `OTEL_EXPORTER_OTLP_ENDPOINT` env → `https://otel.api.foam.ai` | |
|
|
37
|
-
| `force_export:` | `false` | export outside production |
|
|
38
|
-
| `redact_contact_info:` | `false` | append email/phone/telefono/celular to redaction |
|
|
39
|
-
| `redact_keys:` | `[]` | extra redacted keys |
|
|
40
|
-
| `health_routes:` | `[]` | routes exempted from request-context capture (none by default — health checks are captured) |
|
|
41
|
-
| `sidekiq:` / `rollbar:` / `rails:` | `true` | opt out of auto-wiring that adapter |
|
|
42
|
-
|
|
43
|
-
Export is production-gated (`RAILS_ENV`/`RACK_ENV`/`ENVIRONMENT`/`ENV`/
|
|
44
|
-
`NODE_ENV` ∈ {production, prod}) unless `force_export`. Batch cadence comes
|
|
45
|
-
from the standard OTel env vars (`OTEL_BSP_SCHEDULE_DELAY`,
|
|
46
|
-
`OTEL_BLRP_SCHEDULE_DELAY`, `OTEL_METRIC_EXPORT_INTERVAL`).
|
|
47
|
-
|
|
48
|
-
**Route names**: span names and `http.route` use Rails' route template
|
|
49
|
-
(`action_dispatch.route_uri_pattern`, e.g. `/users/:id`) when available. On
|
|
50
|
-
older Rails or bare Rack where it isn't populated, the raw path is used —
|
|
51
|
-
which raises span-name cardinality for routes with dynamic segments; prefer
|
|
52
|
-
Rails 7.1+ for automatic low-cardinality route names.
|
|
53
|
-
|
|
54
|
-
**Forked servers** (Puma workers, Sidekiq): span/log pipelines self-heal
|
|
55
|
-
after fork; the metrics reader is re-armed automatically by the first
|
|
56
|
-
request/job in the child via a pid check. Belt-and-braces:
|
|
57
|
-
`on_worker_boot { Foam::Otel.after_fork! }`.
|
|
58
|
-
|
|
59
|
-
Custom telemetry passthrough (stamped `app.custom` automatically). Create
|
|
60
|
-
each instrument ONCE and reuse it — creating an instrument per call
|
|
61
|
-
re-registers it under a mutex and logs a duplicate-registration warning:
|
|
103
|
+
**Traces**
|
|
62
104
|
|
|
63
105
|
```ruby
|
|
64
|
-
Foam::Otel.
|
|
106
|
+
result = Foam::Otel.span("nightly-sync") do |span|
|
|
107
|
+
# ... work ... the span always ends; a raised error is recorded + re-raised
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
Foam::Otel.set_attribute("user.tier", "pro") # on the active span; false if none recording
|
|
111
|
+
Foam::Otel.set_attributes("a" => 1, "b" => 2)
|
|
112
|
+
Foam::Otel.add_event("cache.miss")
|
|
113
|
+
Foam::Otel.record_exception(e) # records on active span; never notifies a tracker
|
|
114
|
+
ctx = Foam::Otel.get_trace_context # {trace_id:, span_id:} hex, or nil — never fabricated
|
|
115
|
+
```
|
|
65
116
|
|
|
66
|
-
|
|
67
|
-
JOBS_DONE.add(1) # per event
|
|
117
|
+
**Metrics** (one helper per synchronous instrument; names pass through verbatim)
|
|
68
118
|
|
|
69
|
-
|
|
70
|
-
Foam::Otel.
|
|
119
|
+
```ruby
|
|
120
|
+
Foam::Otel.increment_counter("checkout.completed", by: 1, attributes: { "plan" => "pro" })
|
|
121
|
+
Foam::Otel.record_histogram("checkout.duration_ms", 428)
|
|
122
|
+
Foam::Otel.add_up_down_counter("queue.depth", by: -1)
|
|
123
|
+
Foam::Otel.set_metric("cache.size_bytes", 10_485_760) # gauge (last value wins)
|
|
71
124
|
```
|
|
72
125
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
126
|
+
> ⚠️ Metric attributes are a loaded gun: an UNBOUNDED value (user id, raw path,
|
|
127
|
+
> full URL) creates one metric stream per value — a memory leak that the SDK's
|
|
128
|
+
> cardinality cap then turns into silently flat dashboards. Use bounded,
|
|
129
|
+
> low-cardinality attribute values only.
|
|
130
|
+
|
|
131
|
+
**Logs**
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
Foam::Otel.log(:info, "checkout finished", attributes: { "order.id" => id })
|
|
135
|
+
# severity is a level symbol (:trace :debug :info :warn :error :fatal) or an
|
|
136
|
+
# OTel severity number (1..24). Trace-correlated when a span is active.
|
|
76
137
|
```
|
|
138
|
+
|
|
139
|
+
**Redaction**
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
Foam::Otel.redact(user_supplied_token) # tail-masks a scalar you know is sensitive; [REDACTED] for structures
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
**Passthroughs** (the real OTel objects on foam's pipeline — links, span kinds,
|
|
146
|
+
observable instruments, baggage all reachable here)
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
Foam::Otel.get_tracer("my.lib")
|
|
150
|
+
Foam::Otel.get_meter("my.lib")
|
|
151
|
+
Foam::Otel.get_logger("my.lib")
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Coexistence — foam arriving in a monitored service
|
|
157
|
+
|
|
158
|
+
`init` classifies each signal's global slot independently and does the right,
|
|
159
|
+
non-destructive thing. The classifier's output is information for you, never a
|
|
160
|
+
decision it makes for you.
|
|
161
|
+
|
|
162
|
+
### Scenario 2 — beside a proprietary APM agent (e.g. Datadog)
|
|
163
|
+
|
|
164
|
+
Disjoint pipelines: foam runs its own beside the agent, touching nothing. Add the
|
|
165
|
+
agent's intake host so foam's outbound instrumentation doesn't shadow its egress.
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: FOAM_TOKEN,
|
|
169
|
+
ignored_outbound_hosts: ["trace.agent.datadoghq.com"])
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Scenario 3 — a scoped tenant rides foam's pipeline (LLM-eval, AI-observability)
|
|
173
|
+
|
|
174
|
+
Attach the tenant's processor via the seam, and add its exporter host to the
|
|
175
|
+
ignore list (its exports would otherwise be shadow-spanned into a loop).
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: FOAM_TOKEN,
|
|
179
|
+
additional_span_processors: [EvalTool::SpanProcessor.new(project: "prod")],
|
|
180
|
+
ignored_outbound_hosts: ["ingest.eval-tool.example"])
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
> Ruby note: because the Ruby SDK freezes span attributes at finish, a tenant
|
|
184
|
+
> processor sees third-party instrumentation attributes UNMASKED (foam's floor
|
|
185
|
+
> runs at the exporter boundary). Foam's own export is always masked.
|
|
186
|
+
|
|
187
|
+
### Scenario 4 — a foreign OTel SDK already owns a signal
|
|
188
|
+
|
|
189
|
+
If another OTel SDK registered (say) the tracer before foam, foam does NOT
|
|
190
|
+
displace it. It warns once, naming the owner, and is DARK for that signal — your
|
|
191
|
+
tier-3 telemetry for it flows through that SDK, not to foam. Foam ingest for a
|
|
192
|
+
claimed signal is not yet available; feed foam server-side from that pipeline, or
|
|
193
|
+
file the need.
|
|
194
|
+
|
|
195
|
+
### Scenario 5 — per-signal composition
|
|
196
|
+
|
|
197
|
+
Signals are independent. A foreign SDK owning only traces still gets foam for
|
|
198
|
+
metrics and logs on the full contract:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: FOAM_TOKEN)
|
|
202
|
+
# → warns: traces foreign-owned (foam dark for traces); metrics + logs → foam, full contract.
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### Scenario 6 — tests / CI (fully inert, no token needed)
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
Foam::Otel.init(name: "checkout-api", environment: "test", enabled: false)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Scenario 7 — serverless / short-lived (flush at the end)
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
Foam::Otel.init(name: "orders-fn", environment: "production", enabled: true, token: FOAM_TOKEN)
|
|
215
|
+
|
|
216
|
+
def handler(event:, context:)
|
|
217
|
+
run(event)
|
|
218
|
+
ensure
|
|
219
|
+
Foam::Otel.flush # the platform freezes the process before the batch timer fires
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Puma / Unicorn (cluster mode) — CRITICAL
|
|
224
|
+
|
|
225
|
+
Exporter threads do not survive `fork`. Foam auto-recovers metric readers via a
|
|
226
|
+
`Process._fork` hook, but the cleanest pattern is to `init` PER WORKER so each
|
|
227
|
+
worker gets its own pipeline and a unique `service.instance.id`:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
# config/puma.rb
|
|
231
|
+
on_worker_boot do
|
|
232
|
+
Foam::Otel.init(name: "checkout-api", environment: ENV["APP_ENV"], enabled: true, token: ENV["FOAM_TOKEN"])
|
|
233
|
+
# or, if you init in an initializer: Foam::Otel.after_fork!
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Failure modes — what happens and where the warning appears
|
|
240
|
+
|
|
241
|
+
| Situation | Behavior |
|
|
242
|
+
| --- | --- |
|
|
243
|
+
| blank `name`/`environment`, or `enabled` omitted | raises `ArgumentError` at boot (on your machine) |
|
|
244
|
+
| `enabled: true` but blank `token` | raises `ArgumentError` at boot |
|
|
245
|
+
| `enabled: false` | fully inert; helpers no-op silently |
|
|
246
|
+
| missing `version` | `[foam]` warning, continues |
|
|
247
|
+
| a foreign OTel SDK owns a signal | `[foam]` warning once, naming the owner; foam dark for that signal |
|
|
248
|
+
| `init` called twice | `[foam]` warning; returns the first instance (providers are process-global) |
|
|
249
|
+
|
|
250
|
+
---
|
|
251
|
+
|
|
252
|
+
## Environment variables
|
|
253
|
+
|
|
254
|
+
| Var | Posture |
|
|
255
|
+
| --- | --- |
|
|
256
|
+
| `OTEL_SDK_DISABLED=true` | HONORED — full kill switch, supersedes `enabled: true` (exact string `true`). |
|
|
257
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | HONORED — the one operator-level override of the pinned fleet endpoint (for foam's own conformance rig / enterprise egress). Active → loud `[foam]` warning naming the destination. |
|
|
258
|
+
| `OTEL_PROPAGATORS=none` | HONORED — turns trace propagation OFF (links lost) while telemetry keeps flowing; warns. Any other value warns and is ignored (foam's propagator set is fixed: W3C tracecontext + baggage). |
|
|
259
|
+
| `OTEL_BSP_SCHEDULE_DELAY` / `OTEL_BLRP_SCHEDULE_DELAY` / `OTEL_METRIC_EXPORT_INTERVAL` | HONORED — batch cadence, read natively by the upstream SDK. |
|
|
260
|
+
| every other `OTEL_*` | INERT — `OTEL_SERVICE_NAME` / `OTEL_RESOURCE_ATTRIBUTES` never override the init-declared identity; sampler/exporter vars are ignored. |
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Troubleshooting
|
|
265
|
+
|
|
266
|
+
- **Telemetry halved or doubled after a dependency bump** → check for a second
|
|
267
|
+
copy of the OTel API first: `bundle list | grep opentelemetry-api` must show
|
|
268
|
+
ONE. Two copies mean two global registries (silent no-op or doubled data).
|
|
269
|
+
- **No data at all** → set `diagnostics: true` and read the `[foam]` lines; check
|
|
270
|
+
`init` runs before your app code, that `enabled` is true, and that a token is
|
|
271
|
+
wired.
|
|
272
|
+
- **A downstream rejects `traceparent`** → set `OTEL_PROPAGATORS=none` to stop
|
|
273
|
+
injection while keeping telemetry flowing.
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
See `GOTCHAS.md` (the traps and how foam handles them) and `RESEARCH.md` (the
|
|
278
|
+
primitive audit, instrumentation census, and redaction provenance).
|