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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8650474eaaa3dec06f5500839b073d5c2b211750da4ad2a068459cbbb5feb3f1
4
- data.tar.gz: 6c7b40fe1a9297f0c81547222f4ea7a36c716a9bc1eb6a2f97c5e44d49f6b9eb
3
+ metadata.gz: bb0ea8b43b76506cdd72000ba735c36e0bd903b26d83347779cca5fc309b716b
4
+ data.tar.gz: 1a35c9ee39299519c146efb826aa9c02598df6ca23e018b0d8c8e62932a9e234
5
5
  SHA512:
6
- metadata.gz: 39fd5531d0478acdff0ae4e36ae901ee3975a2b2c55e84319bba959683ac3fa69dd226639f2c1c7f4682eec9d064843071209f7ed56bf149d30591cecc732ba8
7
- data.tar.gz: b32886c34c0b60fd1f7751a7cb7e1a81152804c7c495078217ef7e63a83025b15b5336acffad32ebc23e730c42120aa8e32519cd3a9d1154f049130b75ad8160
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
- The shared foam OpenTelemetry core for Ruby services traces, logs, and
4
- metrics over OTLP HTTP, implementing the fleet wire contract
5
- ([`contract/SPEC.md`](../../contract/SPEC.md)). Verified end-to-end by the
6
- `test-apps/ruby-rails` conformance gate on every PR.
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
- # config/initializers/foam.rb (or before Rails loads)
13
- Foam::Otel.init(service_name: "my-service")
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
- That's the whole integration. Everything else self-wires by presence
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.tracer.in_span("work") { |span| ... }
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
- JOBS_DONE = Foam::Otel.meter.create_counter("jobs_done") # once, at load
67
- JOBS_DONE.add(1) # per event
117
+ **Metrics** (one helper per synchronous instrument; names pass through verbatim)
68
118
 
69
- Foam::Otel.logger.on_emit(severity_number: 9, body: "hi", context: OpenTelemetry::Context.current)
70
- Foam::Otel.record_exception(error) # foam span only; never notifies a tracker
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
- ```sh
74
- bundle exec rspec # unit suite
75
- bash ../../test-apps/ruby-rails/run-verify.sh # full conformance gate
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).