foam-otel 1.1.0 → 1.2.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 +151 -40
- data/README.md +93 -35
- data/RESEARCH.md +128 -68
- data/THIRD-PARTY-NOTICES +18 -10
- data/lib/foam/otel/api.rb +28 -21
- data/lib/foam/otel/config.rb +3 -2
- data/lib/foam/otel/constants.rb +15 -89
- data/lib/foam/otel/init.rb +53 -1
- data/lib/foam/otel/llm/anthropic_shim.rb +84 -0
- data/lib/foam/otel/llm/gemini_shim.rb +113 -0
- data/lib/foam/otel/llm/openai_shim.rb +123 -0
- data/lib/foam/otel/llm/ruby_llm_shim.rb +131 -0
- data/lib/foam/otel/llm.rb +272 -0
- data/lib/foam/otel/logger_bridge.rb +187 -0
- data/lib/foam/otel/metrics.rb +4 -1
- data/lib/foam/otel/pipelines.rb +24 -8
- data/lib/foam/otel/redacting_exporter.rb +22 -22
- data/lib/foam/otel/redaction.rb +131 -137
- data/lib/foam/otel/runtime_metrics.rb +120 -0
- data/lib/foam/otel/session_stitching.rb +112 -0
- data/lib/foam/otel/version.rb +1 -1
- metadata +151 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb41e8c47c944ac8f252f47bededb07f8c73e152411f3a7069a8fcab3d1185a2
|
|
4
|
+
data.tar.gz: ab93229a7820e4b60439d8c30f903fd3d1be93a8d2dd723b032862813ac4278f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 291f81494a360ee3ce94a09b6b98dafde0574e4078d61ec01bb3e3e3b7803a4f7034bc36ef17a33db79cbb886ad5c0d337c2ffa75762c5a9d04f52e89cdf6669
|
|
7
|
+
data.tar.gz: e81c1c075606402562ee74a8dc6752dd4d96f4b73235303a43cd0d20569ca7238a031b3c6c6d6726f5cf3f3c32858c2452cc5cbfac8ffd61f834f549738c6c1b
|
data/GOTCHAS.md
CHANGED
|
@@ -8,7 +8,10 @@ those tests.
|
|
|
8
8
|
|
|
9
9
|
Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
|
|
10
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
|
|
11
|
+
-instrumentation-rack 0.31.1, -instrumentation-rails 0.42.0, and the floor
|
|
12
|
+
contrib pins: -net_http 0.29.0, -faraday 0.33.0, -pg 0.36.0, -mysql2 0.34.0,
|
|
13
|
+
-redis 0.29.0, -mongo 0.26.0, -sidekiq 0.29.0; LLM shim refs at openai 0.72.0,
|
|
14
|
+
anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
|
|
12
15
|
|
|
13
16
|
---
|
|
14
17
|
|
|
@@ -119,7 +122,7 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
|
|
|
119
122
|
real Rails app boots with foam and the official rack span carries the inbound
|
|
120
123
|
telemetry (`/ok`, `/fail`, `/reject` assertions).
|
|
121
124
|
|
|
122
|
-
## R5: Door-2 ingest taps ride the alpha logs/metrics SDKs — every behavioral reliance pinned (
|
|
125
|
+
## R5: Door-2 ingest taps ride the alpha logs/metrics SDKs — SHIPPED, every behavioral reliance pinned (fleet audit R8)
|
|
123
126
|
|
|
124
127
|
- **Trap**: the log and metric ingest entries attach to upstream SDKs that are
|
|
125
128
|
pre-1.0 ("development"/alpha). Four load-bearing behaviors could drift under
|
|
@@ -136,11 +139,11 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
|
|
|
136
139
|
(`metric_store` seam), `aggregation/sum.rb:44-50` (`ndp.dup` shallow
|
|
137
140
|
copies), `logs/log_record.rb:16-26` (`attr_accessor :resource`);
|
|
138
141
|
maturity statement — https://opentelemetry.io/docs/languages/ruby/
|
|
139
|
-
- **Decision & why**:
|
|
140
|
-
|
|
141
|
-
already-capped gemspec ranges (R2), and pin each with a
|
|
142
|
-
is a deliberate rule-44 re-prove (any widening re-runs the
|
|
143
|
-
never a silent drift.
|
|
142
|
+
- **Decision & why**: the entries are SHIPPED (README Door 2 section); the
|
|
143
|
+
pre-1.0 logs/metrics SDKs are a version-pin caveat, not a deferral. Keep every
|
|
144
|
+
reliance inside the already-capped gemspec ranges (R2), and pin each with a
|
|
145
|
+
test so a 0.x bump is a deliberate rule-44 re-prove (any widening re-runs the
|
|
146
|
+
door-2 e2e), never a silent drift.
|
|
144
147
|
- **Mitigation**: capped ranges (`foam-otel.gemspec`); masking rebuilds point
|
|
145
148
|
COPIES with fresh attribute hashes (`Redaction.mask_metric_data`,
|
|
146
149
|
`lib/foam/otel/redaction.rb`); subclassing isolated to
|
|
@@ -227,44 +230,44 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
|
|
|
227
230
|
|
|
228
231
|
---
|
|
229
232
|
|
|
230
|
-
## F1: Span attributes freeze at finish — the redaction
|
|
233
|
+
## F1: Span attributes freeze at finish — the redaction key pass cannot be a processor
|
|
231
234
|
|
|
232
|
-
- **Trap**: The obvious place to
|
|
233
|
-
SDK freezes a span's attributes at `finish` BEFORE any `on_finish`
|
|
234
|
-
runs — so a processor cannot mutate them, and neither can a tenant
|
|
235
|
-
see a masked view.
|
|
235
|
+
- **Trap**: The obvious place to apply the redaction key pass is a SpanProcessor,
|
|
236
|
+
but the Ruby SDK freezes a span's attributes at `finish` BEFORE any `on_finish`
|
|
237
|
+
processor runs — so a processor cannot mutate them, and neither can a tenant
|
|
238
|
+
processor see a masked view.
|
|
236
239
|
- **Sources**: installed source — `Span#finish` does
|
|
237
240
|
`@attributes = validated_attributes(@attributes).freeze` then sets `@ended`
|
|
238
241
|
(opentelemetry-sdk `trace/span.rb:277,280`); `to_span_data` returns the frozen
|
|
239
242
|
`@attributes` by reference (`trace/span.rb:296-307`).
|
|
240
|
-
- **
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
offers no safe pre-freeze hook — the
|
|
255
|
-
mutex, so a processor calling
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
243
|
+
- **Opt-in redaction (holistic redesign, 2026-07-26)**: foam does NO redaction by
|
|
244
|
+
default — with no `redact_keys`/`redact_pii_keys` every value is captured RAW
|
|
245
|
+
(no secret floor, no value-pattern masking). When keys ARE configured, foam
|
|
246
|
+
applies the key pass at the EXPORTER boundary — a `RedactingSpanExporter`/
|
|
247
|
+
`RedactingLogRecordExporter` that rebuilds the mutable `SpanData`/
|
|
248
|
+
`LogRecordData` Structs with masked attributes/events/body before
|
|
249
|
+
serialization. With no keys configured the exporter is a true pass-through.
|
|
250
|
+
- **Divergence (NARROWED)**: when keys are configured foam's HELPERS apply the
|
|
251
|
+
key pass AT CAPTURE (`api.rb` `stringify`/`set_attribute`/`log`; `metrics.rb`
|
|
252
|
+
always did), so a tenant `additional_*` instance receives foam-helper data
|
|
253
|
+
ALREADY-MASKED (rule 18 C.3) — the pass is idempotent, so the wire value is
|
|
254
|
+
unchanged. The residual divergence is THIRD-PARTY INSTRUMENTATION attributes
|
|
255
|
+
only: those freeze at finish before any processor runs, so a tenant span
|
|
256
|
+
processor sees them as foam captured them and foam can only apply the key pass
|
|
257
|
+
at the exporter boundary. Ruby offers no safe pre-freeze hook — the
|
|
258
|
+
`on_finishing` hook runs inside the span mutex, so a processor calling
|
|
259
|
+
`set_attribute` there would deadlock. Foam's own export applies the configured
|
|
260
|
+
key pass, and the exporters FAIL CLOSED once a pass has begun: a struct whose
|
|
261
|
+
rebuild raises is DROPPED with a loud warning (`Redaction.mask_*` return nil on
|
|
262
|
+
hard failure), never exported half-masked.
|
|
263
|
+
- **Mitigation**: capture-time key pass in `lib/foam/otel/api.rb` +
|
|
260
264
|
`lib/foam/otel/redacting_exporter.rb` + `lib/foam/otel/redaction.rb`.
|
|
261
|
-
- **Test**: `spec/tenant_seam_spec.rb` (
|
|
262
|
-
|
|
263
|
-
helper-set body + attributes ALREADY-MASKED", the byte-identical
|
|
265
|
+
- **Test**: `spec/tenant_seam_spec.rb` (tenant processors receive helper-set
|
|
266
|
+
LISTED keys ALREADY-MASKED, free-text bodies RAW, the byte-identical
|
|
264
267
|
with/without-tenant proofs for spans, logs, AND metrics),
|
|
265
|
-
`spec/redacting_exporter_spec.rb` (masking on the wire; "a POISONED
|
|
266
|
-
(dup raises) is DROPPED — never exported raw"), `spec/redaction_spec.rb`
|
|
267
|
-
(the
|
|
268
|
+
`spec/redacting_exporter_spec.rb` (opt-in masking on the wire; "a POISONED
|
|
269
|
+
SpanData (dup raises) is DROPPED — never exported raw"), `spec/redaction_spec.rb`
|
|
270
|
+
(default zero-redaction + the two opt-in key lists; capture+exporter idempotence).
|
|
268
271
|
|
|
269
272
|
## F2: OTEL_SDK_DISABLED and OTEL_PROPAGATORS are only honored by SDK.configure
|
|
270
273
|
|
|
@@ -466,6 +469,105 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
|
|
|
466
469
|
foreign SDK claiming LOGS while foam registers TRACES is kept and warned,
|
|
467
470
|
never silently displaced".
|
|
468
471
|
|
|
472
|
+
## F9: The upstream datastore instrumentations SANITIZE by default — db_statement :obfuscate would stand silently
|
|
473
|
+
|
|
474
|
+
- **Trap**: pg/mysql2/redis/mongo contrib all declare
|
|
475
|
+
`option :db_statement, default: :obfuscate` — an `install_all` with no
|
|
476
|
+
config ships OBFUSCATED statements, violating the zero-default-redaction
|
|
477
|
+
ruling with no error anywhere. Worse, `Base#config_options` silently
|
|
478
|
+
REPLACES an invalid option value with the default — so a contrib bump that
|
|
479
|
+
renamed/dropped `:include` would silently fall BACK to `:obfuscate`.
|
|
480
|
+
- **Sources**: installed source — pg `instrumentation.rb:29`, mysql2
|
|
481
|
+
`instrumentation.rb:24`, redis `instrumentation.rb:24`, mongo
|
|
482
|
+
`instrumentation.rb:36` (the `option :db_statement, default: :obfuscate`
|
|
483
|
+
lines); `instrumentation-base base.rb:309-310` (invalid values replaced
|
|
484
|
+
with the default, warning goes to the upstream logger only). Ruling:
|
|
485
|
+
docs/decisions/holistic-redesign-decision-log.md (raised floor, raw
|
|
486
|
+
capture 2026-07-26).
|
|
487
|
+
- **Decision & why**: activation passes a CENTRAL config map
|
|
488
|
+
(`FLOOR_INSTRUMENTATION_CONFIG`, `lib/foam/otel/init.rb`) setting
|
|
489
|
+
`db_statement: :include` for all four datastore instrumentations — raw
|
|
490
|
+
statements are the floor's point; masking is opt-in via redact keys at
|
|
491
|
+
foam's redaction stage. The option's continued upstream validity is pinned
|
|
492
|
+
by a test so a contrib rename is a CI failure, never a silent sanitize.
|
|
493
|
+
- **Test**: `spec/floor_spec.rb` — "install_all receives the CENTRAL
|
|
494
|
+
raw-capture config", ":include is a declared db_statement option on every
|
|
495
|
+
configured floor gem"; presence checks stay proven there too.
|
|
496
|
+
|
|
497
|
+
## F10: The stdlib Logger bridge — two feedback loops and the lazy-block contract
|
|
498
|
+
|
|
499
|
+
- **Trap**: bridging `::Logger` naively (1) turns the OTel SDK's OWN internal
|
|
500
|
+
logger into a producer — every export failure logs, which emits a record,
|
|
501
|
+
which must be exported: unbounded growth the per-stack re-entrancy flag
|
|
502
|
+
cannot see because the batch thread is a different stack; (2) evaluates a
|
|
503
|
+
caller's lazy `logger.debug { expensive }` block even when the severity is
|
|
504
|
+
filtered (stdlib's block form exists precisely to avoid that cost), or
|
|
505
|
+
evaluates an accepted block TWICE (once for the bridge, once in `super`).
|
|
506
|
+
- **Sources**: installed source — opentelemetry-api `opentelemetry.rb:30-31`
|
|
507
|
+
(`OpenTelemetry.logger` memoized `Logger.new($stdout)`) and `:37` (the
|
|
508
|
+
error handler logs through it — the export-failure feedback edge); stdlib
|
|
509
|
+
logger gem `logger.rb:675` (`Logger#add`, the single funnel; lazy block
|
|
510
|
+
resolution order); foam's emit path `lib/foam/otel/logger_bridge.rb`.
|
|
511
|
+
- **Decision & why**: prepend ONE patch on `Logger#add`; skip
|
|
512
|
+
`OpenTelemetry.logger` by live identity; hold a thread-local re-entrancy
|
|
513
|
+
flag around the emit; resolve an accepted block exactly once and hand the
|
|
514
|
+
RESULT down to `super`; return `super` untouched for filtered severities
|
|
515
|
+
(block unevaluated). Emission is gated on foam OWNING the logs slot (rule
|
|
516
|
+
18 B) and every bridge failure degrades to the stdlib write alone (rule 9).
|
|
517
|
+
- **Test**: `spec/logger_bridge_spec.rb` — the two loop-guard examples, the
|
|
518
|
+
lazy-block pair, the foreign-slot darkness, and "NEVER breaks the host's
|
|
519
|
+
log call".
|
|
520
|
+
|
|
521
|
+
## F11: LLM shims patch third-party SDK call sites — the model call is sacred
|
|
522
|
+
|
|
523
|
+
- **Trap**: no official Ruby contrib instrumentation exists for ANY LLM SDK,
|
|
524
|
+
so foam patches the SDKs' public methods directly. A raising extractor, a
|
|
525
|
+
broken tracer, or a semantic drift in the SDK's params/response shapes
|
|
526
|
+
must never fail the customer's model call (a broken instrumentation that
|
|
527
|
+
drops a production LLM request is the worst possible trade), and the SDK's
|
|
528
|
+
own HTTP send must not produce a SECOND span under the gen_ai span.
|
|
529
|
+
- **Sources**: installed source — openai `resources/chat/completions.rb:119`
|
|
530
|
+
/ `resources/responses.rb:96`, anthropic `resources/messages.rb:68`,
|
|
531
|
+
gemini-ai `controllers/client.rb:151`, ruby_llm `provider.rb:44` (the
|
|
532
|
+
patched call sites); opentelemetry-common `utilities.rb:104-117`
|
|
533
|
+
(`untraced` — the suppression context the http contrib honors). Ruling:
|
|
534
|
+
LLM provider parity + testing-mandatory (decision log 2026-07-26).
|
|
535
|
+
- **Decision & why**: every patch routes through ONE guarded core
|
|
536
|
+
(`Foam::Otel::LLM.observe`, `lib/foam/otel/llm.rb`): request extraction
|
|
537
|
+
inside `safe_request` (degrades to bare operation/provider), span start
|
|
538
|
+
fail-to-dark (call runs unspanned), the SDK call's error re-raised
|
|
539
|
+
IDENTICALLY with `error.type` (+ the OBSERVED http status only), the SDK's
|
|
540
|
+
transport run inside `Common::Utilities.untraced` (one call = one span).
|
|
541
|
+
Presence-checked constants + explicit version windows per SDK; emission
|
|
542
|
+
gated on foam owning traces. Wire names are the GenAI semconv strings the
|
|
543
|
+
fleet's js/python gap-fillers emit — content attributes included, RAW.
|
|
544
|
+
- **Test**: `spec/llm_openai_spec.rb`, `spec/llm_anthropic_spec.rb`,
|
|
545
|
+
`spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb` — real SDKs against
|
|
546
|
+
a local fake provider (only the 3rd party mocked), per-provider activity +
|
|
547
|
+
RAW-content assertions, error-path re-raise, and the fail-to-dark set.
|
|
548
|
+
|
|
549
|
+
## F12: Session stamping must beat the freeze (spans) and the buffer (logs)
|
|
550
|
+
|
|
551
|
+
- **Trap**: the two signals offer different safe write points. Span
|
|
552
|
+
attributes FREEZE at finish (F1) — an on_finish stamp is impossible; log
|
|
553
|
+
records are buffered LIVE by the batch processor and serialized later on
|
|
554
|
+
another thread — a stamp must land on the emitting thread before export
|
|
555
|
+
can convert the record.
|
|
556
|
+
- **Sources**: installed source — opentelemetry-sdk `trace/span.rb:277-280`
|
|
557
|
+
(freeze at finish), logs-sdk `batch_log_record_processor.rb:70-83`
|
|
558
|
+
(`on_emit` buffers the live record) + `export` (LogRecordData conversion
|
|
559
|
+
at export time). Ruling: session stitching (decision log 2026-07-26).
|
|
560
|
+
- **Decision & why**: spans stamp in `on_start` (order-independent — the
|
|
561
|
+
attribute exists long before finish); logs stamp in `on_emit` on the
|
|
562
|
+
emitting thread, registered right after foam's batch processor (which
|
|
563
|
+
stays FIRST — pinned invariant). Both read `session.id` /
|
|
564
|
+
`session.previous_id` from the CONTEXT's baggage (foam's W3C propagator
|
|
565
|
+
set already extracts it), stamp verbatim, no-op when absent, never raise.
|
|
566
|
+
Door-2 taps are untouched — foam never mutates the customer's records.
|
|
567
|
+
- **Test**: `spec/session_stitching_spec.rb` — span + log stamping, the
|
|
568
|
+
rack end-to-end with real `traceparent`+`baggage` headers, the no-op and
|
|
569
|
+
never-raise cases.
|
|
570
|
+
|
|
469
571
|
## General gotchas (applicable to Ruby)
|
|
470
572
|
|
|
471
573
|
- **G1 — init after target import / pre-init no-op**: the API's proxy providers
|
|
@@ -521,8 +623,8 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
|
|
|
521
623
|
mitigation (foam never rewrites or drops metric dimensions — rule 4/26);
|
|
522
624
|
the mitigation is the README metrics warning with the classic bad example —
|
|
523
625
|
*Documented* (README "Metrics" section). *Test*: `spec/metrics_spec.rb`
|
|
524
|
-
("
|
|
525
|
-
|
|
626
|
+
("captures a metric attribute RAW by default; masks it only when the key is in
|
|
627
|
+
redact_keys" — pins that unlisted attributes pass through VERBATIM).
|
|
526
628
|
- **G8 — never-ended spans leak memory**: a span started and never ended holds
|
|
527
629
|
memory and context forever (and never exports — the batch processor only
|
|
528
630
|
sees FINISHED spans, `on_finish`). `Foam::Otel.span {}` always ends the span
|
|
@@ -570,6 +672,15 @@ Installed-source refs are rooted at the lockfile-pinned gems (opentelemetry-sdk
|
|
|
570
672
|
active", "the API entry file was loaded from exactly ONE gem copy"),
|
|
571
673
|
`spec/packaging_spec.rb` ("activating a conflicting opentelemetry-api
|
|
572
674
|
version raises Gem::LoadError at boot").
|
|
675
|
+
- **G15 — runtime metrics are asynchronous-instrument callbacks**: the
|
|
676
|
+
hand-written GC/thread/RSS readings run on the PeriodicMetricReader's
|
|
677
|
+
thread at COLLECT time (no extra timer thread; fork recovery rides R1's
|
|
678
|
+
reader restart). A raising reading (missing GC.stat key, unreadable /proc)
|
|
679
|
+
must degrade to nil — the SDK ignores non-numeric callback results — so one
|
|
680
|
+
bad reading can never kill the collect loop or the healthy instruments
|
|
681
|
+
beside it (`lib/foam/otel/runtime_metrics.rb` `guarded_callback`).
|
|
682
|
+
*Test*: `spec/runtime_metrics_spec.rb` ("a poisoned reading is
|
|
683
|
+
fail-to-dark", the foreign-slot and idempotence cases).
|
|
573
684
|
|
|
574
685
|
---
|
|
575
686
|
|
data/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
Foam's OpenTelemetry core for Ruby services. A thin, safe wrapper over the
|
|
4
4
|
official OpenTelemetry libraries: foam owns the pipeline (providers, batch
|
|
5
|
-
processors, OTLP export to the foam fleet endpoint,
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
processors, OTLP export to the foam fleet endpoint), turns on automatic
|
|
6
|
+
tier-1/2 instrumentation, and hands you a small set of never-throw helpers —
|
|
7
|
+
and nothing else. **Redaction is fully opt-in: by default foam captures every
|
|
8
|
+
value RAW** (no secret floor, no value-pattern masking); you enumerate the
|
|
9
|
+
fields to mask via `redact_keys`/`redact_pii_keys`.
|
|
8
10
|
|
|
9
11
|
Built to `docs/BASE_PACKAGE_SPEC.md`. This README is the manual you integrate
|
|
10
12
|
from.
|
|
@@ -19,12 +21,16 @@ Foam builds on the OpenTelemetry authors' work (https://opentelemetry.io) — se
|
|
|
19
21
|
```ruby
|
|
20
22
|
# Gemfile
|
|
21
23
|
gem "foam-otel"
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
# foam-otel bundles the UNIVERSAL FLOOR and auto-activates each piece when its
|
|
25
|
+
# target library is present (presence-checked, and gated on foam owning the
|
|
26
|
+
# slot it produces into): Rack + the Rails family, the HTTP clients (Net::HTTP,
|
|
27
|
+
# Faraday), the primary datastores (pg, mysql2, redis, mongo) with RAW
|
|
28
|
+
# db.statement capture, Sidekiq, the stdlib Logger bridge, hand-written Ruby
|
|
29
|
+
# runtime + GC metrics, session stitching (browser session.id via baggage),
|
|
30
|
+
# and the LLM shims (OpenAI, Anthropic, Gemini, ruby_llm — activity + raw
|
|
31
|
+
# content). Add only the niche long tail your app needs beyond the floor:
|
|
32
|
+
gem "opentelemetry-instrumentation-graphql" # e.g. GraphQL
|
|
33
|
+
gem "opentelemetry-instrumentation-resque" # e.g. Resque
|
|
28
34
|
```
|
|
29
35
|
|
|
30
36
|
**Supported versions** (tested in CI, `spec/version_spec.rb`): Ruby **>= 3.1**;
|
|
@@ -54,10 +60,53 @@ Foam::Otel.init(
|
|
|
54
60
|
```
|
|
55
61
|
|
|
56
62
|
**Outcome**: foam registers the tracer/meter/logger providers, exports OTLP to
|
|
57
|
-
the foam fleet endpoint, and auto-activates the
|
|
58
|
-
instrumentation
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
the foam fleet endpoint, and auto-activates the **universal floor** — the
|
|
64
|
+
official rack/rails instrumentation plus the bundled HTTP (Net::HTTP/Faraday),
|
|
65
|
+
datastore (pg/mysql2/redis/mongo), and Sidekiq instrumentations — each
|
|
66
|
+
presence-gated, and gated on foam owning the traces slot (a foreign-owned
|
|
67
|
+
tracer stands the activation down). The datastore set is activated with
|
|
68
|
+
**`db_statement: :include`** — foam explicitly overrides the upstream
|
|
69
|
+
`:obfuscate` default so RAW SQL / redis args / mongo commands land on the wire.
|
|
70
|
+
On top of the contrib set the floor adds foam's own pieces: the **stdlib
|
|
71
|
+
Logger bridge** (every host `Logger` line also lands as a trace-correlated
|
|
72
|
+
OTel log record, body raw), hand-written Ruby **runtime + GC metrics**
|
|
73
|
+
(GC counts + timing, heap slots, allocation, thread count, RSS — Ruby has no
|
|
74
|
+
drop-in upstream metrics gem, so foam ships its own collector),
|
|
75
|
+
**session stitching** (inbound `baggage: session.id` from the foam browser
|
|
76
|
+
package is stamped onto every span and log record), and the **LLM shims**
|
|
77
|
+
(below). Every inbound request, DB call, HTTP call, job, log line, and model
|
|
78
|
+
call now flows to foam. **Redaction is opt-in: by default every value —
|
|
79
|
+
attributes, URLs/query strings, DB statement text, log bodies, LLM content —
|
|
80
|
+
is captured RAW.** Pass `redact_keys`/`redact_pii_keys` to mask or erase
|
|
81
|
+
specific fields (see below).
|
|
82
|
+
|
|
83
|
+
### The LLM surface — OpenAI, Anthropic, Gemini (and ruby_llm)
|
|
84
|
+
|
|
85
|
+
The Ruby contrib registry ships no LLM instrumentation, so foam does: thin,
|
|
86
|
+
presence-checked shims over the SDKs' public call sites, emitting the standard
|
|
87
|
+
`gen_ai.*` semantic conventions — activity (model, response id, finish
|
|
88
|
+
reasons, token usage, latency) AND prompt/response **content, RAW**
|
|
89
|
+
(`gen_ai.input.messages` / `gen_ai.output.messages` /
|
|
90
|
+
`gen_ai.system_instructions`). Covered: the official `openai` SDK
|
|
91
|
+
(chat.completions.create + responses.create), the official `anthropic` SDK
|
|
92
|
+
(messages.create), Gemini via the `gemini-ai` gem (Google ships no official
|
|
93
|
+
Ruby SDK), and `ruby_llm` (one seam covering all its providers). Absent SDK =
|
|
94
|
+
inert; version-guarded; fail-to-dark — a shim failure can NEVER break the
|
|
95
|
+
model call, and the SDK's own error re-raises identically. One model call =
|
|
96
|
+
one `gen_ai` client span (the SDK's HTTP send rides the same suppression
|
|
97
|
+
context foam's exporters use). Streaming call sites (`#stream`/`#stream_raw`
|
|
98
|
+
on the official SDKs) are a recorded follow-up.
|
|
99
|
+
|
|
100
|
+
### Session stitching — browser `session.id` on backend telemetry
|
|
101
|
+
|
|
102
|
+
The foam browser package stamps `session.id` on every browser signal and
|
|
103
|
+
sends it to your API as W3C Baggage beside `traceparent`. This gem's floor
|
|
104
|
+
copies `session.id` (and `session.previous_id` across a rotation) from the
|
|
105
|
+
inbound baggage onto every span and log record foam emits — browser session →
|
|
106
|
+
backend traces/logs, stitched with no ingest-side join. No-op when the
|
|
107
|
+
baggage is absent. **CORS note (the FDE wires this on every frontend-called
|
|
108
|
+
API):** the allowlist must include BOTH headers —
|
|
109
|
+
`Access-Control-Allow-Headers: traceparent, baggage`.
|
|
61
110
|
|
|
62
111
|
---
|
|
63
112
|
|
|
@@ -70,8 +119,8 @@ now flows to foam, with the redaction floor applied on the wire.
|
|
|
70
119
|
| `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
120
|
| `token:` | String | when enabled | — | `Authorization: Bearer` for export. Required (and validated) only when `enabled: true`; wired explicitly from your secret source — never an env fallback read by the gem. **Fleet convention: read it from the `FOAM_OTEL_TOKEN` env var.** Blank while enabled → raises at boot. |
|
|
72
121
|
| `version:` | String | no | nil | `service.version`, verbatim (git SHA recommended). Missing → warns and continues. Never detected at runtime. |
|
|
73
|
-
| `redact_keys:` | Array<String> | no | `[]` |
|
|
74
|
-
| `redact_pii_keys:` | Array<String> | no | `[]` | Field names
|
|
122
|
+
| `redact_keys:` | Array<String> | no | `[]` | The ONLY field names tail-masked (shape-preserving, e.g. `********f456`). Opt-in — empty (the default) means NO masking. Matches by case-insensitive substring, including keys nested in structured values and query-string keys. |
|
|
123
|
+
| `redact_pii_keys:` | Array<String> | no | `[]` | Field names fully erased to `[REDACTED]` (no tail). Opt-in — empty (the default) means NO erasure. Foam ships no preset; this is your enumeration. |
|
|
75
124
|
| `additional_span_processors:` | Array | no | `[]` | Tenant seam: constructed SpanProcessor instances added to foam's pipeline (additive; never replace foam's export). See coexistence. |
|
|
76
125
|
| `additional_log_record_processors:` | Array | no | `[]` | Tenant seam, logs. |
|
|
77
126
|
| `additional_metric_readers:` | Array | no | `[]` | Tenant seam, metrics. |
|
|
@@ -82,10 +131,10 @@ now flows to foam, with the redaction floor applied on the wire.
|
|
|
82
131
|
Examples for the remaining options (each is runnable as-is):
|
|
83
132
|
|
|
84
133
|
```ruby
|
|
85
|
-
#
|
|
134
|
+
# Redaction is OPT-IN (default: nothing is masked). List the fields to redact:
|
|
86
135
|
Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: ENV.fetch("FOAM_OTEL_TOKEN"),
|
|
87
|
-
redact_keys: %w[internal_ref voucher_code],
|
|
88
|
-
redact_pii_keys: %w[customer_email full_name])
|
|
136
|
+
redact_keys: %w[password api_key internal_ref voucher_code], # tail-masked
|
|
137
|
+
redact_pii_keys: %w[customer_email full_name]) # fully [REDACTED]
|
|
89
138
|
```
|
|
90
139
|
|
|
91
140
|
```ruby
|
|
@@ -221,12 +270,13 @@ Foam::Otel.init(name: "checkout-api", environment: env, enabled: true, token: FO
|
|
|
221
270
|
ignored_outbound_hosts: ["ingest.eval-tool.example"])
|
|
222
271
|
```
|
|
223
272
|
|
|
224
|
-
> Ruby note: foam's HELPERS
|
|
225
|
-
>
|
|
226
|
-
>
|
|
227
|
-
>
|
|
228
|
-
>
|
|
229
|
-
>
|
|
273
|
+
> Ruby note: when you configure `redact_keys`/`redact_pii_keys`, foam's HELPERS
|
|
274
|
+
> apply the key pass at capture, so a tenant processor receives foam-helper data
|
|
275
|
+
> (span/log helper attributes, log bodies, metric labels) ALREADY masked.
|
|
276
|
+
> Because the Ruby SDK freezes span attributes at finish, a tenant
|
|
277
|
+
> processor sees third-party instrumentation attributes UNMASKED (for those,
|
|
278
|
+
> foam's key pass runs at the exporter boundary — GOTCHAS F1). With no keys
|
|
279
|
+
> configured (the default) nothing is masked on either path.
|
|
230
280
|
>
|
|
231
281
|
> Loop-guard note: `ignored_outbound_hosts` suppresses spans for tenant/agent
|
|
232
282
|
> egress made through **Net::HTTP or Excon**. A tenant exporter using
|
|
@@ -265,7 +315,8 @@ end
|
|
|
265
315
|
**Outcome**: their pipeline keeps working exactly as before — their data,
|
|
266
316
|
their resource, their export, byte-identical — and foam ALSO receives the
|
|
267
317
|
signal, carrying THEIR resource identity plus foam's export-time stamp and
|
|
268
|
-
|
|
318
|
+
any per-tap `redact_keys`/`redact_pii_keys` you set (opt-in — none by default;
|
|
319
|
+
details in the Door 2 section).
|
|
269
320
|
|
|
270
321
|
**Unhappy paths**:
|
|
271
322
|
|
|
@@ -351,16 +402,23 @@ how that pipeline's data ALSO reaches foam: constructed instances you add —
|
|
|
351
402
|
one line per claimed signal — to the CUSTOMER'S OWN OTel setup. Additive
|
|
352
403
|
readers only: they never mutate their data, their resource, or their export.
|
|
353
404
|
|
|
354
|
-
> **
|
|
355
|
-
> upstream's pre-1.0 SDKs (logs "development",
|
|
356
|
-
>
|
|
357
|
-
>
|
|
405
|
+
> **All three entries are SHIPPED.** The traces entry rides the stable 1.x SDK;
|
|
406
|
+
> the log and metric entries ride upstream's pre-1.0 SDKs (logs "development",
|
|
407
|
+
> metrics alpha), pinned to this gem's capped ranges (GOTCHAS R2/R5) so a 0.x
|
|
408
|
+
> drift is a deliberate re-prove, never a silent change. The pre-1.0 SDK
|
|
409
|
+
> maturity is a version-pin caveat, not a deferral — the entries are available
|
|
410
|
+
> and supported today.
|
|
411
|
+
>
|
|
412
|
+
> **Version-pin posture:** because the log and metric taps ride those pre-1.0
|
|
413
|
+
> upstream SDKs, they are version-pinned and re-proven on every 0.x bump
|
|
414
|
+
> rather than assumed stable — shipped and supported either way.
|
|
358
415
|
|
|
359
416
|
### The three entries
|
|
360
417
|
|
|
361
418
|
All three share one signature (`token:` and `environment:` required; the
|
|
362
|
-
redaction kwargs
|
|
363
|
-
is tap-scoped narration). Construction
|
|
419
|
+
redaction kwargs are the ONLY keys that tap redacts — opt-in, none by default,
|
|
420
|
+
scoped to that tap; `diagnostics:` is tap-scoped narration). Construction
|
|
421
|
+
validates loudly at boot and NEVER
|
|
364
422
|
throws afterwards — a failure inside a tap can never break their pipeline.
|
|
365
423
|
Fleet canon mapping (pinned by `spec/export_surface_spec.rb`):
|
|
366
424
|
`create_ingest_span_processor` = `createFoamIngestSpanProcessor`,
|
|
@@ -380,9 +438,9 @@ their_tracer_provider.add_span_processor(processor)
|
|
|
380
438
|
# raises ArgumentError at construction for a blank token/environment
|
|
381
439
|
```
|
|
382
440
|
|
|
383
|
-
**Logs** — a LogRecordProcessor for THEIR LoggerProvider. The
|
|
384
|
-
|
|
385
|
-
|
|
441
|
+
**Logs** — a LogRecordProcessor for THEIR LoggerProvider. The tap's
|
|
442
|
+
`redact_keys`/`redact_pii_keys` (if any) match over record attributes and keys
|
|
443
|
+
nested in structured bodies; free-text bodies ride RAW (no value-pattern pass):
|
|
386
444
|
|
|
387
445
|
```ruby
|
|
388
446
|
provider = OpenTelemetry::SDK::Logs::LoggerProvider.new(resource: their_resource)
|
|
@@ -426,7 +484,7 @@ in THEIR pipeline. Three layers keep it closed:
|
|
|
426
484
|
| | their pipeline | foam's copy |
|
|
427
485
|
| --- | --- | --- |
|
|
428
486
|
| `service.name` / `service.version` / `service.instance.id` | theirs | theirs, untouched (the data carries THEIR identity) |
|
|
429
|
-
| all attributes / events / bodies | raw, byte-identical |
|
|
487
|
+
| all attributes / events / bodies | raw, byte-identical | your per-tap `redact_keys`/`redact_pii_keys` applied (default: none → identical to theirs) |
|
|
430
488
|
| `deployment.environment.name` | theirs (if any) | the tap's `environment:` argument (stamped at export time) |
|
|
431
489
|
| `foam.ingest.tier` | absent | `"external"` (the wire-contract marker) |
|
|
432
490
|
| `telemetry.distro.name` / `telemetry.distro.version` | absent | `"foam"` / the gem version (support reads the tap's presence and version from telemetry alone) |
|