foam-otel 1.9.1 → 2.1.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: a2e506ae900650923638df263e5775efa20dd8ab7a63540eefb9d5156c1d008c
4
- data.tar.gz: 64ea4091c85c9541ec1f08dd5560608e560ec80e8ee6918f95098b976b0ec82e
3
+ metadata.gz: 65833871aff8351bab7083f4a9d636afd5704cd1327e95bc4af117437d12ad85
4
+ data.tar.gz: 206b1b6bfe1633b44267283c775b63ae47cd429be0bc9260c27ab89fd4f8b6ed
5
5
  SHA512:
6
- metadata.gz: 4c776696c10dc442394e5b023db7b47d178ec4b328addd693ba72b60e726c19f923533f0f1645e7b62ec322e63d6fcd0b39b1ca917cd9aa66403295c0b67e266
7
- data.tar.gz: 615ec0ee4400e6e11baa6a6572fcafdccdeb6764f9bb2e65afcf4ff72a8185e9ba45086cd5e8e039934ea1043fe165d94784983539ac0d3e361a6b28f72a986e
6
+ metadata.gz: c7c97cf55df3769670636aab31b8dafa53ee8975b4648bd1339e167f5af63245609355b547e25eefcaefd68f155e8e5934e739a54f012a5137231f343a966a2e
7
+ data.tar.gz: eb600f006bdce6aabf407e8b7df5f1e2fe7ece7a7f7590ac956689a6d1c148e0b5a1d32a4095606608d931f3792a908bb901fe06f804e49f52eda366b6e97650
data/GOTCHAS.md CHANGED
@@ -542,9 +542,16 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
542
542
  own HTTP send must not produce a SECOND span under the gen_ai span.
543
543
  - **Sources**: installed source — openai `resources/chat/completions.rb:119`
544
544
  / `resources/responses.rb:96`, anthropic `resources/messages.rb:68`,
545
- gemini-ai `controllers/client.rb:151`, ruby_llm `provider.rb:44` (the
546
- patched call sites); opentelemetry-common `utilities.rb:104-117`
547
- (`untraced` the suppression context the http contrib honors). Ruling:
545
+ gemini-ai `controllers/client.rb:151`, ruby_llm `provider.rb:44`,
546
+ ruby-openai `lib/openai/client.rb` (`#chat(parameters:)`/`#embeddings`
547
+ the COMMUNITY gem sharing the official gem's OpenAI namespace and
548
+ lib/openai.rb entrypoint: presence is discriminated by the
549
+ `parameters:`-keyword SHAPE, never the bare method name), http (HTTP.rb)
550
+ `lib/http/client.rb#request` (the hand-rolled Anthropic transport seam,
551
+ shape-checked per call); opentelemetry-common `utilities.rb:104-117`
552
+ (`untraced` — the suppression context net_http/excon honor; contrib
553
+ `-http` does NOT consult it (G13/UNGUARDABLE) — its transport span NESTS
554
+ under the gen_ai span, the contract's sanctioned rendering). Ruling:
548
555
  LLM provider parity + testing-mandatory (decision log 2026-07-26).
549
556
  - **Decision & why**: every patch routes through ONE guarded core
550
557
  (`Foam::Otel::LLM.observe`, `lib/foam/otel/llm.rb`): request extraction
@@ -555,10 +562,23 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
555
562
  Presence-checked constants + explicit version windows per SDK; emission
556
563
  gated on foam owning traces. Wire names are the GenAI semconv strings the
557
564
  fleet's js/python gap-fillers emit — content attributes included, RAW.
565
+ The transport shim (HTTP.rb) additionally must never be the FIRST reader
566
+ of the app's response: content is parsed only for bounded JSON bodies
567
+ (Content-Length ≤ its byte cap) and the drained bytes are re-wrapped onto
568
+ the response so app-side `#to_s`/`#each`/`#readpartial` are untouched;
569
+ SSE/chunked/oversized bodies skip content capture entirely. HTTP.rb never
570
+ raises on 4xx/5xx, so `LLM.observe`'s `error_detector:` turns the
571
+ OBSERVED status into the ERROR span (`error.type` = the bare code string).
558
572
  - **Test**: `spec/llm_openai_spec.rb`, `spec/llm_anthropic_spec.rb`,
559
- `spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb` — real SDKs against
560
- a local fake provider (only the 3rd party mocked), per-provider activity +
561
- RAW-content assertions, error-path re-raise, and the fail-to-dark set.
573
+ `spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb`,
574
+ `spec/llm_ruby_openai_spec.rb` (isolated subprocess the two openai gems
575
+ can never share an interpreter; both cross-gem discriminator negatives
576
+ pinned here and in `spec/llm_openai_spec.rb`),
577
+ `spec/llm_http_anthropic_spec.rb` (streaming-reader safety, SSE
578
+ never-consume, byte-cap, near-miss-path negatives, persistent-client) —
579
+ real SDKs against a local fake provider (only the 3rd party mocked),
580
+ per-provider activity + RAW-content assertions, error-path re-raise, and
581
+ the fail-to-dark set.
562
582
 
563
583
  ## F12: Session stamping must beat the freeze (spans) and the buffer (logs)
564
584
 
@@ -937,7 +957,7 @@ exfiltratable — the value-pattern secret layer is the required second control
937
957
 
938
958
  ---
939
959
 
940
- ## F18: One private-method namespace per class — cross-vendor helper collisions crash the APP, and foam shields them (coexistence mandate 2026-07-29)
960
+ ## F18: One private-method namespace per class — cross-vendor helper collisions crash the APP, and foam shields them (coexistence mandate 2026-07-29; HTTP::Client twin added in 2.1.0)
941
961
 
942
962
  - **Trap**: Every module prepended onto a class shares ONE private-method
943
963
  namespace. When two observability vendors prepend same-named private
@@ -949,6 +969,12 @@ exfiltratable — the value-pattern secret layer is the required second control
949
969
  `annotate_span_with_response!` (3-arg vs 2-arg). Foam ships that contrib
950
970
  patch for capture, so a foam + agent process CONTAINS the collision — and
951
971
  foam's promise is coexistence: the app must keep working.
972
+ THE SAME COLLISION EXISTS ON A SECOND CLASS: dd-trace-rb's httprb patch
973
+ and the contrib `opentelemetry-instrumentation-http` patch both define
974
+ `annotate_span_with_response!` on `HTTP::Client` (3-arg vs 2-arg) —
975
+ upstream tracks the combination as unsupported (dd-trace-rb#3575,
976
+ opentelemetry-ruby#1625, httprb/http#838). A `datadog/auto_instrument` +
977
+ contrib-http process crashes every HTTP.rb request the same way.
952
978
  - **Sources**:
953
979
  - Installed source — the two colliding definitions:
954
980
  opentelemetry-instrumentation-net_http 0.29.0
@@ -957,6 +983,14 @@ exfiltratable — the value-pattern secret layer is the required second control
957
983
  3-arg spelling of the same private name (verified live in the
958
984
  coexistence gate; backtrace pinned in
959
985
  docs/decisions/export-isolation-ruby.md §A).
986
+ - The HTTP::Client twin: dd-trace-rb
987
+ `lib/datadog/tracing/contrib/httprb/instrumentation.rb`
988
+ (3-arg `annotate_span_with_response!(span, response, options)`) vs
989
+ contrib opentelemetry-instrumentation-http `patches/client.rb`
990
+ (2-arg); upstream reports
991
+ https://github.com/DataDog/dd-trace-rb/issues/3575 ,
992
+ https://github.com/open-telemetry/opentelemetry-ruby/issues/1625 ,
993
+ https://github.com/httprb/http/issues/838 .
960
994
  - Ruby semantics: prepended modules join the class's ancestor chain and
961
995
  private methods resolve through the SAME chain for every caller — there
962
996
  is no per-module helper namespace.
@@ -964,7 +998,8 @@ exfiltratable — the value-pattern secret layer is the required second control
964
998
  resolves the name through the same shared chain — a rename breaks the
965
999
  gem being "fixed"), and foam must never edit another vendor's module. So
966
1000
  foam owns the collision instead: after the instrumentation sweep, init
967
- scans Net::HTTP's prepend chain for private-helper names defined by two
1001
+ scans the prepend chains of Net::HTTP AND `HTTP::Client` (when the http
1002
+ gem is loaded) for private-helper names defined by two
968
1003
  or more foreign modules at NON-identical positional signatures (names the
969
1004
  class itself defines are super-chains, never shielded) and prepends ONE
970
1005
  frontmost dispatch method per colliding name — routing by caller source
@@ -979,17 +1014,27 @@ exfiltratable — the value-pattern secret layer is the required second control
979
1014
  - **Mitigation**: with the shield installed the coexistence gate's
980
1015
  patched-client probe went from 3/3 crashes to 3/3 passes under the real
981
1016
  agent, with both vendors' helpers verifiably executing again.
982
- - **Residual**: an agent that patches AFTER foam's init lands ahead of the
983
- shield and re-exposes the raw collision until a later init/fork re-scan;
984
- agents overwhelmingly boot first (initializer/preload) the order the
985
- coexistence gate proves. Identical-signature collisions are left alone
1017
+ - **Residual**: the scan is one-shot at init and only shields collisions
1018
+ BOTH of whose definitions are already on the chain an agent that
1019
+ patches AFTER foam's init lands ahead of the shield and re-exposes the
1020
+ raw collision until a later init/fork re-scan. For Net::HTTP agents
1021
+ overwhelmingly boot first (initializer/preload — the order the
1022
+ coexistence gate proves). The HTTP::Client twin is MORE order-sensitive:
1023
+ `defined?(::HTTP::Client)` must be true at init (the http gem required
1024
+ first — Bundler.require satisfies this) and both vendor patches must
1025
+ already be installed; a `Foam::Otel.init` that runs before
1026
+ `Datadog.configure` leaves that collision unshielded — initialize foam
1027
+ AFTER the vendor agents (the recommended order for every coexistence
1028
+ bridge). Identical-signature collisions are left alone
986
1029
  (no crash class; dispatch could not disambiguate semantics).
987
1030
  - **Test**: `spec/prepend_collision_shield_spec.rb` (the unshielded RED
988
1031
  control, both-vendors-work dispatch, idempotence, no-op purity on
989
1032
  collision-free chains, super-chain and identical-signature exclusions,
990
1033
  fall-through to the real error, rule-9 scan degradation, caller-source
991
- dispatch across two on-disk gem roots, and the real Net::HTTP + real
992
- contrib patch field scenario red→green in a forked child);
1034
+ dispatch across two on-disk gem roots, the real Net::HTTP + real
1035
+ contrib patch field scenario red→green in a forked child, and the real
1036
+ `HTTP::Client` field scenario — 2-arg × 3-arg vendor-shaped collision on
1037
+ the real class, red→green in a forked child);
993
1038
  `test-apps/ruby-coexistence/run-verify.sh` (the patched-client probe is a
994
1039
  REQUIRED pass on every vendor posture).
995
1040
 
data/README.md CHANGED
@@ -2,7 +2,7 @@
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), turns on automatic
5
+ processors, OTLP export to the foam endpoint), turns on automatic
6
6
  tier-1/2 instrumentation, and hands you a small set of never-throw helpers —
7
7
  and nothing else. **Redaction is fully opt-in above two always-on exceptions:
8
8
  by default foam captures every value RAW** (no PII preset) **except (1) the
@@ -42,7 +42,8 @@ gem "opentelemetry-instrumentation-graphql" # e.g. GraphQL
42
42
  gem "opentelemetry-instrumentation-resque" # e.g. Resque
43
43
  ```
44
44
 
45
- **Supported versions** (tested in CI, `spec/version_spec.rb`): Ruby **>= 3.1**;
45
+ **Supported versions** (tested in CI, `spec/version_spec.rb`): Ruby **>= 3.4**
46
+ (the version the CI matrix runs — the floor follows the tested matrix);
46
47
  OpenTelemetry API **`opentelemetry-api` ~> 1.1** (1.x). The metrics and logs
47
48
  SDKs are pre-1.0 upstream (metrics alpha, logs development) and are pinned with
48
49
  pessimistic constraints; foam wraps them behind stable helpers so app code never
@@ -69,7 +70,7 @@ Foam::Otel.init(
69
70
  ```
70
71
 
71
72
  **Outcome**: foam registers the tracer/meter/logger providers, exports OTLP to
72
- the foam fleet endpoint, and auto-activates the **universal floor** — the
73
+ the foam endpoint, and auto-activates the **universal floor** — the
73
74
  official rack/rails instrumentation plus the bundled HTTP (Net::HTTP/Faraday),
74
75
  datastore (pg/mysql2/redis/mongo), and Sidekiq instrumentations — each
75
76
  presence-gated, and gated on foam owning the traces slot (a foreign-owned
@@ -99,7 +100,7 @@ or erase further specific fields (see below).
99
100
  > Set the standard contrib lever:
100
101
  > `OTEL_RUBY_INSTRUMENTATION_SIDEKIQ_CONFIG_OPTS='propagation_style=child'`.
101
102
 
102
- ### The LLM surface — OpenAI, Anthropic, Gemini (and ruby_llm)
103
+ ### The LLM surface — OpenAI, Anthropic, Gemini (plus ruby_llm, ruby-openai, and hand-rolled HTTP.rb Anthropic)
103
104
 
104
105
  The Ruby contrib registry ships no LLM instrumentation, so foam does: thin,
105
106
  presence-checked shims over the SDKs' public call sites, emitting the standard
@@ -111,16 +112,27 @@ turns keep their tool names/arguments and tool-result turns keep their
111
112
  `tool_call_id` linkage inside the message content. `gen_ai.operation.name`
112
113
  (and the `{operation} {model}` span name) follows the semconv operation for
113
114
  each API shape — `chat` for the OpenAI/Anthropic/ruby_llm chat seams,
114
- `generate_content` for Gemini's generateContent, fleet-identical with the
115
+ `generate_content` for Gemini's generateContent, identical to foam's
115
116
  js/python Gemini paths (**wire change in 1.2.1**: Gemini spans previously
116
117
  said `chat`). Covered: the official `openai` SDK
117
118
  (chat.completions.create + responses.create), the official `anthropic` SDK
118
119
  (messages.create), Gemini via the `gemini-ai` gem (Google ships no official
119
- Ruby SDK), and `ruby_llm` (one seam covering all its providers). Absent SDK =
120
+ Ruby SDK), `ruby_llm` (one seam covering all its providers), the community
121
+ `ruby-openai` gem (`client.chat` / `client.embeddings` — presence is
122
+ discriminated from the official gem by call shape, since both reopen the
123
+ `OpenAI` namespace; embeddings emit tokens + models, never the input
124
+ documents), and hand-rolled HTTP.rb Anthropic clients (`HTTP.post` /
125
+ `HTTP.persistent(...).post` to `api.anthropic.com/v1/messages` — exactly
126
+ that endpoint; HTTP.rb's non-raising 4xx/5xx become ERROR spans with the
127
+ observed status, and foam never consumes a response stream: SSE, chunked,
128
+ and oversized bodies pass through untouched, and buffered JSON bodies are
129
+ handed back readable). Absent SDK =
120
130
  inert; version-guarded; fail-to-dark — a shim failure can NEVER break the
121
131
  model call, and the SDK's own error re-raises identically. One model call =
122
132
  one `gen_ai` client span (the SDK's HTTP send rides the same suppression
123
- context foam's exporters use). Streaming call sites (`#stream`/`#stream_raw`
133
+ context foam's exporters use; transport gems that ignore that context, like
134
+ the contrib `http` instrumentation, nest beneath it instead). Streaming
135
+ call sites (`#stream`/`#stream_raw`
124
136
  on the official SDKs) are a recorded follow-up.
125
137
 
126
138
  ### Session stitching — browser `session.id` on backend telemetry
@@ -148,8 +160,8 @@ listed request and response headers as
148
160
  > **Honest limit (deliberate divergence from foam's js/python cores):**
149
161
  > Ruby inbound captures the **named default list below, NOT all headers** —
150
162
  > the official gem's options are an enumerated allowlist with no
151
- > capture-all form, and that official option is the chosen seam (ruling
152
- > 2026-07-28). A custom header outside the list is **not captured** unless
163
+ > capture-all form, and that official option is the chosen seam.
164
+ > A custom header outside the list is **not captured** unless
153
165
  > you extend the list (recipe below).
154
166
 
155
167
  The default lists (`Foam::Otel::HeaderCapture::DEFAULT_REQUEST_HEADERS` /
@@ -321,7 +333,7 @@ not captured — inbound only.
321
333
  | `name:` | String | yes | — | `service.name`. Blank → raises at boot. |
322
334
  | `environment:` | String | yes | — | `deployment.environment.name`, exported verbatim. A value outside `{production, staging, development, test}` warns but is never rewritten. Blank → raises. |
323
335
  | `enabled:` | Boolean | yes | — | The config switch. `false` = fully inert (no SDK, no providers, no network, helpers no-op). `true` = export, in EVERY environment. No default — you write the logic. |
324
- | `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. |
336
+ | `token:` | String | when enabled | — | `Authorization: Bearer` for export. Required (and validated) only when `enabled: true`; wired explicitly from your secret source — never an env fallback read by the gem. **The convention across all foam SDKs: read it from the `FOAM_OTEL_TOKEN` env var.** Blank while enabled → raises at boot. |
325
337
  | `version:` | String | no | nil | `service.version`, verbatim (git SHA recommended). Missing → warns and continues. Never detected at runtime. |
326
338
  | `redact:` | Hash | no | nil | The grouped redaction object (1.5.0, the preferred surface): `{ secrets: [...], pii: [...], detect: [...] }` — symbol or string keys. `secrets:` has `redact_keys` semantics (tail mask) and `pii:` has `redact_pii_keys` semantics (full `[REDACTED]`); when both a legacy option and its `redact` field are set, the lists UNION. `detect:` is the opt-in PII detection tier's entity list — value-shape detection with typed placeholders (see "PII detection (opt-in)" below). An unknown field, a non-Array value, or an unknown `detect` entity name raises at boot. Absent (the default) → behavior byte-identical to before. |
327
339
  | `redact_keys:` | Array<String> | no | `[]` | Alias — the preferred spelling is `redact: { secrets: [...] }` (same semantics; the lists union when both are set). The ONLY field names tail-masked (shape-preserving, e.g. `********f456`). Opt-in and ADDITIVE on top of the always-on credential floor — empty (the default) means no masking beyond the floor. Matches by case-insensitive substring, including keys nested in structured values and query-string keys. A key that is also on the floor stays fully `[REDACTED]` (the floor wins; it is never downgraded to a tail). |
@@ -432,8 +444,7 @@ override is the operator-level `OTEL_EXPORTER_OTLP_ENDPOINT` env var (below).
432
444
 
433
445
  ## The default credential denylist (the always-on floor)
434
446
 
435
- **Fleet ruling 2026-07-26** (binding design:
436
- `docs/decisions/credential-denylist-design.md`; frozen fixture:
447
+ Always on in every foam SDK, identical everywhere (frozen fixture:
437
448
  `contract/credential-denylist.json`). This is the ONE exception to foam's
438
449
  raw-by-default capture, and it has **no off switch** — no init option, no env
439
450
  var, and no door-2 parameter can disable, shrink, or re-spell it.
@@ -482,13 +493,13 @@ byte-identical raw, exactly as documented everywhere else in this README.
482
493
 
483
494
  **Why.** Coverage-over-masking remains the mission, but raw `Authorization`
484
495
  headers, cookies, and API keys on the wire are a breach in waiting for every
485
- customer at once — the fleet ruling carves out exactly this list (and only
486
- this list) as non-negotiable. The list is byte-identical in every foam core
487
- and gate-checked in CI against the fleet fixture
496
+ customer at once — foam carves out exactly this list (and only
497
+ this list) as non-negotiable. The list is byte-identical in every foam SDK
498
+ and gate-checked in CI against the shipped fixture
488
499
  (`spec/credential_floor_spec.rb`).
489
500
 
490
- **Fleet param-name canon** (the customer options above the floor, identical
491
- concept in every core — the floor itself has NO init surface anywhere):
501
+ **Param names across the foam SDKs** (the customer options above the floor,
502
+ identical concept in every SDK — the floor itself has NO init surface anywhere):
492
503
 
493
504
  | Concept | js/otel | js/browser | python | ruby | java |
494
505
  |---|---|---|---|---|---|
@@ -517,8 +528,7 @@ The credential floor masks by NAME and is deliberately value-shape-blind — a
517
528
  live AWS key under the field name `note` would sail past it. Foam telemetry is
518
529
  read downstream by LLMs, so a leaked credential is exfiltratable by prompt
519
530
  injection; "no leakage at all" is the bar. The value-pattern secret layer is
520
- the second always-on control (fleet ruling 2026-07-27,
521
- `docs/decisions/security-fixes-design.md`):
531
+ the second always-on control:
522
532
 
523
533
  **What it masks.** Credential-SHAPED value spans, regardless of the field
524
534
  name they hide under: AWS access-key ids and keyword-anchored AWS secrets,
@@ -561,8 +571,8 @@ value rather than shipping it raw.
561
571
 
562
572
  **Provenance.** The ruleset derives from MIT/Apache-licensed OSS secret
563
573
  scanners (gitleaks, detect-secrets, secretlint) — see `THIRD-PARTY-NOTICES`.
564
- Rules are frozen module constants, byte-consistent across the foam cores,
565
- changed only by fleet ruling.
574
+ Rules are frozen module constants, byte-consistent across all foam SDKs —
575
+ not configurable per app.
566
576
 
567
577
  > **Migration note — 1.4.0 (minor): value-pattern secret masking + redaction
568
578
  > coverage fix + TLS pin.** foam now masks credential-SHAPED values
@@ -589,9 +599,9 @@ The floor and the secret layer protect credentials. PII in VALUES — an email
589
599
  address inside a log line, a card number inside an exception message — still
590
600
  exports RAW by default, because coverage-over-masking is the mission and only
591
601
  you know your privacy posture. As of 1.5.0 you can opt into value-shape PII
592
- detection per entity, via `redact: { detect: [...] }` (fleet ruling
593
- 2026-07-28; frozen fixture: `contract/pii-detect.json` — the entity names and
594
- placeholders are byte-identical in every foam core and gate-checked in CI by
602
+ detection per entity, via `redact: { detect: [...] }` (frozen fixture:
603
+ `contract/pii-detect.json` — the entity names and
604
+ placeholders are byte-identical in every foam SDK and gate-checked in CI by
595
605
  `spec/pii_detect_spec.rb`). Nothing detects unless you list the entity —
596
606
  an empty/absent `detect` list is exactly today's behavior.
597
607
 
@@ -696,7 +706,7 @@ at-exit flush. Anything a hook needs from the request/job thread
696
706
  attribute at capture time (see the `additional_span_processors` stamper
697
707
  pattern); thread-locals are gone by the time the hook runs.
698
708
 
699
- Boot validation is strict (rule 10): a non-callable — or a **lambda/Method
709
+ Boot validation is strict: a non-callable — or a **lambda/Method
700
710
  whose signature cannot accept one positional argument** (e.g. a Sentry-port
701
711
  `->(event, hint) { ... }` with two required params, or a required keyword)
702
712
  — raises `ArgumentError` at `init`, because it would otherwise fault on
@@ -963,7 +973,7 @@ which every tap applies with zero configuration; `diagnostics:` is tap-scoped
963
973
  narration). Construction
964
974
  validates loudly at boot and NEVER
965
975
  throws afterwards — a failure inside a tap can never break their pipeline.
966
- Fleet canon mapping (pinned by `spec/export_surface_spec.rb`):
976
+ Naming across the foam SDKs (pinned by `spec/export_surface_spec.rb`):
967
977
  `create_ingest_span_processor` = `createFoamIngestSpanProcessor`,
968
978
  `create_ingest_log_record_processor` = `createFoamIngestLogRecordProcessor`,
969
979
  `create_ingest_metric_reader` = `createFoamIngestMetricReader`.
@@ -1098,8 +1108,8 @@ end
1098
1108
 
1099
1109
  | Var | Posture |
1100
1110
  | --- | --- |
1101
- | `OTEL_SDK_DISABLED=true` | HONORED — full kill switch, supersedes `enabled: true` (matched trimmed + case-insensitive, so `TRUE` / ` true ` also kill — the fleet's one emergency switch behaves the same across languages). Door-2 ingest entries honor it too: construction returns an inert instance (read once at construction; changing it implies a restart). |
1102
- | `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. Applies to door-2 taps identically (and moves the host the required loop step must name). |
1111
+ | `OTEL_SDK_DISABLED=true` | HONORED — full kill switch, supersedes `enabled: true` (matched trimmed + case-insensitive, so `TRUE` / ` true ` also kill — this one emergency switch behaves the same in every foam SDK). Door-2 ingest entries honor it too: construction returns an inert instance (read once at construction; changing it implies a restart). |
1112
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | HONORED — the one operator-level override of the pinned foam endpoint (for foam's own conformance rig / enterprise egress). Active → loud `[foam]` warning naming the destination. Applies to door-2 taps identically (and moves the host the required loop step must name). |
1103
1113
  | `OTEL_PROPAGATORS=none` | HONORED — turns trace propagation OFF (links lost) while telemetry keeps flowing; warns. Any other value warns and is ignored (foam's propagator set is fixed: W3C tracecontext + baggage). |
1104
1114
  | `OTEL_BSP_SCHEDULE_DELAY` / `OTEL_BLRP_SCHEDULE_DELAY` / `OTEL_METRIC_EXPORT_INTERVAL` | HONORED — batch cadence, read natively by the upstream SDK. |
1105
1115
  | `FOAM_CAPTURE_PAYLOADS` | HONORED — the operator clamp over the `capture_payloads:` init option (`off`/`errors`/`always`, case-insensitive), overriding it in BOTH directions with one loud `[foam]` line when it changes the mode. An invalid value warns and falls back to the init option (never crashes a boot). This is the ONE foam-named env var the gem reads — an override valve over an init-declared option, never an on/off switch, token, or config fallback (those still arrive only through `init`'s explicit arguments). Read once at init; changing it implies a restart. |
data/RESEARCH.md CHANGED
@@ -246,8 +246,16 @@ SDK — foam ships its own thin gap-filler shims (LANDED,
246
246
  shims patch the SDKs' public call sites — official `openai`
247
247
  (chat.completions.create + responses.create), official `anthropic`
248
248
  (messages.create), `gemini-ai` (generate_content / stream_generate_content;
249
- Google ships no official Ruby SDK), and `ruby_llm` (Provider#complete, one
250
- seam covering all its providers) emitting the same GenAI semconv attribute
249
+ Google ships no official Ruby SDK), `ruby_llm` (Provider#complete, one
250
+ seam covering all its providers), `ruby-openai` (GRADUATED from tier-3 in
251
+ 2.1.0: the long-dominant COMMUNITY OpenAI client, `OpenAI::Client#chat
252
+ (parameters:)`/`#embeddings` — it shares the official gem's `OpenAI`
253
+ namespace and `lib/openai.rb` entrypoint, so the two gems can never load in
254
+ one process and presence is discriminated by the `parameters:`-keyword
255
+ SHAPE, never the bare method name; specs run in an isolated subprocess for
256
+ the same reason, `spec/llm_ruby_openai_spec.rb`), and `http` (HTTP.rb —
257
+ the hand-rolled Anthropic transport seam, `HTTP::Client#request`, below) —
258
+ emitting the same GenAI semconv attribute
251
259
  set the fleet's js/python gap-fillers emit. `gen_ai.operation.name` matches
252
260
  the fleet per API shape: `chat` on the chat seams, `generate_content` on the
253
261
  Gemini shim (aligned in 1.2.1 — Ruby previously emitted `chat` for Gemini, a
@@ -267,7 +275,11 @@ the model call is never breakable by telemetry (GOTCHAS F11). LLM testing is
267
275
  MANDATORY and landed: per-provider specs against the REAL SDKs with only the
268
276
  3rd party mocked (`spec/llm_openai_spec.rb`, `spec/llm_anthropic_spec.rb`,
269
277
  `spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb` — the last is the
270
- wire-level Gemini proof over a local fake provider).
278
+ wire-level Gemini proof over a local fake provider
279
+ `spec/llm_ruby_openai_spec.rb`, and `spec/llm_http_anthropic_spec.rb`,
280
+ which additionally pins the transport shim's never-first-reader guarantee:
281
+ streaming reads survive capture, SSE and oversized bodies are never
282
+ consumed, and the observed-status error path).
271
283
 
272
284
  - `-anthropic` (in the registry / `all` meta-gem) is **context-propagation ONLY**
273
285
  — its sole patch stores/restores OTel context across the SDK's connection pool;
@@ -278,12 +290,26 @@ wire-level Gemini proof over a local fake provider).
278
290
  but is NOT bundled: foam's own `ruby_llm` shim emits the fleet-identical
279
291
  attribute set instead, avoiding a non-registry supply-chain dependency and
280
292
  a cross-language semconv divergence.
281
- - `ruby-openai` (community gem), `langchainrb`, AWS Bedrock (via `-aws_sdk`,
293
+ - `http` (HTTP.rb) hand-rolled Anthropic clients: **TRANSPORT-LEVEL SHIM,
294
+ DELIBERATELY BOUNDED (2.1.0).** SDK-less apps that POST
295
+ `api.anthropic.com/v1/messages` directly (a live customer pattern) get the
296
+ full gen_ai set via a per-call shape check — known host + EXACT
297
+ distinctive path + POST (contract SPEC §10's transport-hook rules;
298
+ near-miss endpoints like `/v1/messages/count_tokens` and
299
+ `/v1/messages/batches` are pinned NEGATIVE, never fabricated). Scope is a
300
+ closed set — ONE provider, ONE transport: foam-otel 0.1.x shipped broad
301
+ transport classifiers and RETIRED them (the ecosystem churns too fast to
302
+ chase from a shared gem), and that ruling stands; extending this seam to
303
+ more hosts/transports (Net::HTTP, Faraday hand-rolls) is a per-addition
304
+ fleet ruling with this same census entry updated, never a silent patch.
305
+ Embeddings note (fleet parity): NO shim captures embeddings INPUT —
306
+ python's contrib openai-v2 path emits none; tokens + models only.
307
+ - `langchainrb`, AWS Bedrock (via `-aws_sdk`,
282
308
  which emits no gen_ai attributes), `llm.rb`: **TIER3-recipe** —
283
309
  hand-written spans via helpers + passthroughs with `gen_ai.*` semconv + token
284
310
  counts + trace/log correlation. An uncovered library the FDE hits becomes a
285
311
  filed package need (the FDE activates, never authors); until then the tier-3
286
- recipe covers it.
312
+ recipe covers it — the path `ruby-openai` itself took to graduation.
287
313
  - No mainstream library is CANNOT-COVER — no finding against the helper set.
288
314
 
289
315
  Sources: contrib `-anthropic` CHANGELOG; https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm ;
@@ -620,6 +620,12 @@ module Foam
620
620
  # vendor private-helper collisions and shim them. A collision-free
621
621
  # chain (the common case) installs NOTHING.
622
622
  PrependCollisionShield.install!(::Net::HTTP) if defined?(::Net::HTTP)
623
+ # The HTTP.rb ("http" gem) twin of the same collision: dd-trace-rb's
624
+ # httprb patch and the contrib http patch both define the private
625
+ # helper `annotate_span_with_response!` on HTTP::Client at
626
+ # non-identical signatures (3-arg vs 2-arg) — without the shield,
627
+ # EVERY HTTP.rb request crashes once both agents are loaded.
628
+ PrependCollisionShield.install!(::HTTP::Client) if defined?(::HTTP::Client)
623
629
  rescue StandardError => e
624
630
  Diagnostics.warn("instrumentation activation failed: #{e.class}: #{e.message}")
625
631
  end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Foam
6
+ module Otel
7
+ module LLM
8
+ # Gap-filler for Anthropic API calls made DIRECTLY over the `http` gem
9
+ # (HTTP.rb) — the hand-rolled-client pattern (HTTP.headers(...).post(
10
+ # "https://api.anthropic.com/v1/messages", json: body)) that apps use
11
+ # instead of an SDK. Presence-checked on HTTP.rb and shape-checked per
12
+ # call (host + EXACT path + POST), so it is inert for every other
13
+ # HTTP.rb request in the process and in any app without the gem. The
14
+ # host may come from the request uri or, for the idiomatic persistent
15
+ # client (HTTP.persistent(origin).post("/v1/messages", ...)), from the
16
+ # client's persistent origin.
17
+ #
18
+ # ONE gen_ai span per logical call: the request runs inside
19
+ # LLM.observe's suppression context. NOTE the contrib http
20
+ # instrumentation does NOT consult the untraced context (GOTCHAS G13,
21
+ # init.rb's UNGUARDABLE_HTTP_CLIENT_INSTRUMENTATIONS) — when that gem
22
+ # is active its transport span NESTS beneath the gen_ai span instead
23
+ # of being suppressed, the contract's sanctioned rendering (SPEC §10:
24
+ # a different layer, like rails-inside-rack).
25
+ #
26
+ # The app's response stream is NEVER taken away from it: response
27
+ # content is read only when the body is bounded (Content-Length ≤ the
28
+ # parse cap) AND can be handed back — the drained bytes are re-wrapped
29
+ # onto the response so #to_s, #each and #readpartial all still work.
30
+ # Anything else (SSE/event-stream, chunked, oversized, restore-probe
31
+ # failure) skips content capture: activity + observed status survive,
32
+ # the stream is untouched. Error responses carry their OBSERVED status
33
+ # (rule 25); HTTP.rb never raises on 4xx/5xx, so the status IS the
34
+ # error signal — success spans carry NO http.* (fleet gen_ai
35
+ # vocabulary, llm.rb).
36
+ module HttpAnthropicShim
37
+ SUPPORTED_BELOW = Gem::Version.new("6.0.0")
38
+ HOSTS = ["api.anthropic.com"].freeze
39
+ MESSAGES_PATH = "/v1/messages"
40
+ # Transport twin of PayloadCapture's byte cap: bodies above this are
41
+ # never materialized or parsed by the shim (rule 49 — bounded work
42
+ # on the caller thread). Content is omitted, activity survives.
43
+ BODY_PARSE_BYTE_CAP = 32_768
44
+
45
+ class << self
46
+ def present?
47
+ defined?(::HTTP::Client) &&
48
+ ::HTTP::Client.method_defined?(:request) &&
49
+ supported_version?
50
+ end
51
+
52
+ def install!
53
+ return true if @installed
54
+
55
+ ::HTTP::Client.prepend(RequestPatch)
56
+ Diagnostics.info("LLM shim installed: http-rb anthropic (POST #{HOSTS.join(', ')}#{MESSAGES_PATH})")
57
+ @installed = true
58
+ end
59
+
60
+ def installed? = @installed
61
+
62
+ def supported_version?
63
+ spec = Gem.loaded_specs["http"]
64
+ return true if spec.nil?
65
+
66
+ supported = spec.version < SUPPORTED_BELOW
67
+ unless supported || @version_warned
68
+ @version_warned = true
69
+ Diagnostics.warn("http #{spec.version} is outside foam's LLM-shim window (< #{SUPPORTED_BELOW}) " \
70
+ "— the HTTP.rb Anthropic shim stays dark; file this with foam support")
71
+ end
72
+ supported
73
+ rescue StandardError
74
+ true
75
+ end
76
+
77
+ # Shape check: is this ONE call an Anthropic Messages API request?
78
+ # uri arrives as whatever the caller passed (String / URI /
79
+ # HTTP::URI — possibly a bare path on a persistent client, whose
80
+ # origin then supplies the host). The path must match EXACTLY:
81
+ # /v1/messages/count_tokens, /v1/messages/batches and friends are
82
+ # real non-inference Anthropic endpoints under the same prefix,
83
+ # and a prefix match would fabricate chat spans for them
84
+ # (contract SPEC §10: an ordinary REST path must never fabricate
85
+ # a gen_ai span).
86
+ def anthropic_messages?(verb, uri, client = nil)
87
+ return false unless verb.to_s.downcase == "post"
88
+
89
+ parsed = uri.is_a?(String) ? URI.parse(uri) : uri
90
+ host = parsed.respond_to?(:host) ? parsed.host : nil
91
+ path = parsed.respond_to?(:path) ? parsed.path : nil
92
+ host ||= persistent_host(client)
93
+ HOSTS.include?(host) && path.to_s.chomp("/") == MESSAGES_PATH
94
+ rescue StandardError
95
+ false
96
+ end
97
+
98
+ # The persistent-client idiom passes a bare path; the origin lives
99
+ # on the client's default options (client.rb joins them only
100
+ # inside #request, after this check runs).
101
+ def persistent_host(client)
102
+ opts = client.respond_to?(:default_options) ? client.default_options : nil
103
+ base = opts.respond_to?(:persistent) ? opts.persistent : nil
104
+ base ? URI.parse(base.to_s).host : nil
105
+ rescue StandardError
106
+ nil
107
+ end
108
+
109
+ # The request body: the patch sees opts BEFORE Client#request
110
+ # normalizes it — usually a plain Hash ({json: {...}} from the
111
+ # chainable), sometimes an HTTP::Options (structured :json
112
+ # pre-encoding) — or an encoded string body, parsed best-effort
113
+ # under the byte cap. NEVER consumes streams (file/IO bodies
114
+ # degrade to the bare operation/provider pair).
115
+ def request_body(opts)
116
+ json, body =
117
+ if opts.is_a?(Hash)
118
+ [opts[:json] || opts["json"], opts[:body] || opts["body"]]
119
+ else
120
+ [opts.respond_to?(:json) ? opts.json : nil,
121
+ opts.respond_to?(:body) ? opts.body : nil]
122
+ end
123
+ return json if json.is_a?(Hash)
124
+ return nil unless body.is_a?(String) && body.bytesize <= BODY_PARSE_BYTE_CAP && body.start_with?("{")
125
+
126
+ JSON.parse(body)
127
+ rescue StandardError
128
+ nil
129
+ end
130
+
131
+ def request_attributes(body)
132
+ body = body.is_a?(Hash) ? body : {}
133
+ LLM.request_attributes(
134
+ provider: "anthropic", operation: "chat",
135
+ model: body["model"] || body[:model],
136
+ max_tokens: body["max_tokens"] || body[:max_tokens],
137
+ input_messages: body["messages"] || body[:messages],
138
+ system_instructions: body["system"] || body[:system]
139
+ )
140
+ end
141
+
142
+ # Reads the finished JSON body for capture WITHOUT taking anything
143
+ # away from the app: only when the body is bounded (declared
144
+ # Content-Length within the cap) and a replacement Body can be
145
+ # built (probed BEFORE consuming — if HTTP.rb's internals ever
146
+ # drift, foam skips capture rather than risk the app's stream).
147
+ # The drained bytes are re-wrapped in a fresh HTTP::Response::Body
148
+ # over an EOF-compatible buffer and swapped back, so app-side
149
+ # #to_s, #each and #readpartial behave exactly as if foam had
150
+ # never read the response.
151
+ def readable_json_body(response)
152
+ length = content_length(response)
153
+ return nil unless length&.positive? && length <= BODY_PARSE_BYTE_CAP
154
+ return nil unless response.instance_variable_defined?(:@body)
155
+ return nil unless build_body("") # constructor probe, pre-consumption
156
+
157
+ contents = response.body.to_s
158
+ replacement = build_body(contents)
159
+ response.instance_variable_set(:@body, replacement) if replacement
160
+ contents
161
+ rescue StandardError
162
+ nil
163
+ end
164
+
165
+ def content_length(response)
166
+ raw = response.respond_to?(:headers) ? response.headers["Content-Length"] : nil
167
+ raw.nil? ? nil : Integer(raw.to_s, 10)
168
+ rescue StandardError
169
+ nil
170
+ end
171
+
172
+ def build_body(contents)
173
+ ::HTTP::Response::Body.new(BufferedBodyStream.new(contents), encoding: contents.encoding)
174
+ rescue StandardError
175
+ nil
176
+ end
177
+ end
178
+
179
+ # Matches HTTP::Connection's readpartial contract (nil at EOF, never
180
+ # EOFError) so a restored Body streams exactly like a live one.
181
+ class BufferedBodyStream
182
+ CHUNK = 16_384
183
+
184
+ def initialize(contents)
185
+ @io = StringIO.new(contents)
186
+ end
187
+
188
+ def readpartial(size = CHUNK, outbuf = nil)
189
+ return nil if @io.eof?
190
+
191
+ outbuf ? @io.readpartial(size, outbuf) : @io.readpartial(size)
192
+ end
193
+ end
194
+
195
+ # Non-streaming JSON Message → response attributes; usage rides
196
+ # {input_tokens, output_tokens} exactly like the SDK shim.
197
+ RESPONSE = lambda do |response|
198
+ h = response.is_a?(Hash) ? response : {}
199
+ usage = h["usage"].is_a?(Hash) ? h["usage"] : {}
200
+ LLM.response_attributes(
201
+ model: h["model"],
202
+ id: h["id"],
203
+ finish_reasons: h["stop_reason"] ? [h["stop_reason"]] : nil,
204
+ input_tokens: usage["input_tokens"],
205
+ output_tokens: usage["output_tokens"],
206
+ output_messages: h["content"]
207
+ )
208
+ end
209
+
210
+ module RequestPatch
211
+ def request(verb, uri, opts = {})
212
+ unless Foam::Otel::LLM.emit? && HttpAnthropicShim.anthropic_messages?(verb, uri, self)
213
+ return super
214
+ end
215
+
216
+ body = HttpAnthropicShim.request_body(opts)
217
+ Foam::Otel::LLM.observe(
218
+ provider: "anthropic",
219
+ request: Foam::Otel::LLM.safe_request(provider: "anthropic") do
220
+ HttpAnthropicShim.request_attributes(body)
221
+ end,
222
+ error_detector: HttpAnthropicShim::ERROR_DETECTOR,
223
+ response_extractor: HttpAnthropicShim::EXTRACTOR
224
+ ) { super }
225
+ end
226
+ end
227
+
228
+ # Observed-status error detection: HTTP.rb never raises on 4xx/5xx —
229
+ # the status IS the error signal (rule 25: observed, never fabricated).
230
+ ERROR_DETECTOR = lambda do |response|
231
+ status = response.respond_to?(:status) ? response.status.to_i : 0
232
+ status >= 400 ? status : nil
233
+ end
234
+
235
+ # Response attributes. The status code is stamped ONLY on the error
236
+ # path (the observed error signal — fleet gen_ai spans carry no
237
+ # http.* on success; python removed exactly this success-path stamp
238
+ # as a deliberate wire correction). error.type carries the bare
239
+ # status string ("400"), matching the span status description and
240
+ # semconv's well-known values — never an invented format.
241
+ EXTRACTOR = lambda do |response|
242
+ attrs = {}
243
+ status = response.respond_to?(:status) ? response.status.to_i : 0
244
+ if status >= 400
245
+ attrs[LLM::HTTP_RESPONSE_STATUS_CODE] = status
246
+ attrs[LLM::ERROR_TYPE] = status.to_s
247
+ end
248
+ # HTTP::ContentType#to_s is the INSPECT string, not the media
249
+ # type — read #mime_type (via Response#mime_type), falling back
250
+ # to the raw header.
251
+ mime = response.respond_to?(:mime_type) ? response.mime_type.to_s : ""
252
+ if mime.empty? && response.respond_to?(:headers)
253
+ mime = response.headers["Content-Type"].to_s
254
+ end
255
+ if mime.include?("json")
256
+ begin
257
+ raw = HttpAnthropicShim.readable_json_body(response)
258
+ parsed = raw ? JSON.parse(raw) : nil
259
+ attrs.merge!(RESPONSE.call(parsed)) if parsed.is_a?(Hash)
260
+ rescue StandardError
261
+ nil # unparseable body: activity + status survive, content lost
262
+ end
263
+ end
264
+ attrs
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Foam
4
+ module Otel
5
+ module LLM
6
+ # Gap-filler for the `ruby-openai` gem (the long-standing community
7
+ # OpenAI client: `OpenAI::Client#chat`, NOT the official `openai`
8
+ # gem's resource layer OpenAIShim covers). Plenty of production Rails
9
+ # apps still ride it — presence-checked and version-windowed like its
10
+ # siblings so it is inert anywhere the gem is absent, and dark outside
11
+ # the supported window.
12
+ module RubyOpenAIShim
13
+ SUPPORTED_BELOW = Gem::Version.new("9.0.0")
14
+
15
+ class << self
16
+ # The official `openai` gem ALSO defines OpenAI::Client#chat — but
17
+ # as a zero-argument RESOURCE ACCESSOR (client.chat.completions…).
18
+ # ruby-openai's is the request method chat(parameters:). The shape
19
+ # check (the parameters: keyword) is the ONLY reliable discriminator
20
+ # between the two gems sharing this namespace — never the bare
21
+ # method name.
22
+ def present?
23
+ return false unless defined?(::OpenAI::Client) && ::OpenAI::Client.method_defined?(:chat)
24
+
25
+ chat = ::OpenAI::Client.instance_method(:chat)
26
+ takes_parameters = chat.parameters.any? { |type, name| %i[key keyreq].include?(type) && name == :parameters }
27
+ takes_parameters && supported_version?
28
+ rescue StandardError
29
+ false
30
+ end
31
+
32
+ def install!
33
+ return true if @installed
34
+
35
+ ::OpenAI::Client.prepend(ChatPatch)
36
+ if ::OpenAI::Client.method_defined?(:embeddings)
37
+ ::OpenAI::Client.prepend(EmbeddingsPatch)
38
+ end
39
+ Diagnostics.info("LLM shim installed: ruby-openai (client.chat, client.embeddings)")
40
+ @installed = true
41
+ end
42
+
43
+ def installed? = @installed
44
+
45
+ def supported_version?
46
+ spec = Gem.loaded_specs["ruby-openai"]
47
+ return true if spec.nil? # vendored/unknown: the shape checks decide
48
+
49
+ supported = spec.version < SUPPORTED_BELOW
50
+ unless supported || @version_warned
51
+ @version_warned = true
52
+ Diagnostics.warn("ruby-openai #{spec.version} is outside foam's LLM-shim window (< #{SUPPORTED_BELOW}) " \
53
+ "— the ruby-openai shim stays dark; file this with foam support")
54
+ end
55
+ supported
56
+ rescue StandardError
57
+ true
58
+ end
59
+
60
+ def chat_request(params)
61
+ params = params.is_a?(Hash) ? params : {}
62
+ LLM.request_attributes(
63
+ provider: "openai", operation: "chat",
64
+ model: params[:model] || params["model"],
65
+ max_tokens: params[:max_tokens] || params["max_tokens"] ||
66
+ params[:max_completion_tokens] || params["max_completion_tokens"],
67
+ input_messages: params[:messages] || params["messages"]
68
+ )
69
+ end
70
+
71
+ def embeddings_request(params)
72
+ params = params.is_a?(Hash) ? params : {}
73
+ # Embeddings INPUT is deliberately NOT captured (fleet parity:
74
+ # the python core rides contrib openai-v2, which emits no
75
+ # embeddings input under any attribute — document chunks are a
76
+ # classic PII carrier, and gen_ai.input.messages is defined for
77
+ # chat messages, not document batches). Tokens + models carry
78
+ # the signal; widening this is a fleet ruling, not a shim choice.
79
+ LLM.request_attributes(
80
+ provider: "openai", operation: "embeddings",
81
+ model: params[:model] || params["model"]
82
+ )
83
+ end
84
+ end
85
+
86
+ # ruby-openai returns plain parsed-JSON Hashes (string keys):
87
+ # {"id"=>..., "model"=>..., "choices"=>[{"message"=>{...},
88
+ # "finish_reason"=>...}], "usage"=>{"prompt_tokens"=>N, ...}}.
89
+ CHAT_RESPONSE = lambda do |response|
90
+ h = response.is_a?(Hash) ? response : {}
91
+ usage = h["usage"].is_a?(Hash) ? h["usage"] : {}
92
+ choices = h["choices"].is_a?(Array) ? h["choices"] : []
93
+ LLM.response_attributes(
94
+ model: h["model"],
95
+ id: h["id"],
96
+ finish_reasons: choices.map { |c| c.is_a?(Hash) ? c["finish_reason"] : nil }.compact,
97
+ input_tokens: usage["prompt_tokens"],
98
+ output_tokens: usage["completion_tokens"],
99
+ output_messages: choices.map { |c| c.is_a?(Hash) ? c["message"] : nil }.compact
100
+ )
101
+ end
102
+
103
+ # Embeddings response: usage.prompt_tokens only; the vectors are
104
+ # NOT content (and would be enormous) — count them, never ship them.
105
+ EMBEDDINGS_RESPONSE = lambda do |response|
106
+ h = response.is_a?(Hash) ? response : {}
107
+ usage = h["usage"].is_a?(Hash) ? h["usage"] : {}
108
+ LLM.response_attributes(
109
+ model: h["model"],
110
+ input_tokens: usage["prompt_tokens"]
111
+ )
112
+ end
113
+
114
+ module ChatPatch
115
+ def chat(parameters: {})
116
+ Foam::Otel::LLM.observe(
117
+ provider: "openai",
118
+ request: Foam::Otel::LLM.safe_request(provider: "openai") { RubyOpenAIShim.chat_request(parameters) },
119
+ response_extractor: RubyOpenAIShim::CHAT_RESPONSE
120
+ ) { super }
121
+ end
122
+ end
123
+
124
+ module EmbeddingsPatch
125
+ def embeddings(parameters: {})
126
+ Foam::Otel::LLM.observe(
127
+ provider: "openai", operation: "embeddings",
128
+ request: Foam::Otel::LLM.safe_request(provider: "openai") { RubyOpenAIShim.embeddings_request(parameters) },
129
+ response_extractor: RubyOpenAIShim::EMBEDDINGS_RESPONSE
130
+ ) { super }
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
data/lib/foam/otel/llm.rb CHANGED
@@ -13,13 +13,22 @@ module Foam
13
13
  # `_openai_responses.py` and js/otel's anthropic gap-filler — over the
14
14
  # SDKs' public call sites:
15
15
  #
16
- # * `openai` (official OpenAI Ruby SDK) — chat.completions.create +
17
- # responses.create
18
- # * `anthropic` (official Anthropic Ruby SDK) — messages.create
19
- # * `gemini-ai` (the de-facto Gemini Ruby SDK; Google ships no official
20
- # Ruby SDK) — generate_content / stream_generate_content
21
- # * `ruby_llm` (multi-provider client) — Provider#complete, one seam
22
- # covering its OpenAI/Anthropic/Gemini/... providers
16
+ # * `openai` (official OpenAI Ruby SDK) — chat.completions.create +
17
+ # responses.create
18
+ # * `anthropic` (official Anthropic Ruby SDK) — messages.create
19
+ # * `gemini-ai` (the de-facto Gemini Ruby SDK; Google ships no official
20
+ # Ruby SDK) — generate_content / stream_generate_content
21
+ # * `ruby_llm` (multi-provider client) — Provider#complete, one seam
22
+ # covering its OpenAI/Anthropic/Gemini/... providers
23
+ # * `ruby-openai` (the long-standing COMMUNITY OpenAI client —
24
+ # OpenAI::Client#chat(parameters:) / #embeddings; shares
25
+ # the OpenAI namespace with the official gem, so presence
26
+ # is checked by the parameters:-keyword SHAPE, never the
27
+ # bare method name) — chat + embeddings
28
+ # * `http` (HTTP.rb) — hand-rolled-provider-client pattern: POSTs
29
+ # to api.anthropic.com/v1/messages from SDK-less apps;
30
+ # shape-checked per call so every other HTTP.rb request
31
+ # in the process keeps its plain transport span
23
32
  #
24
33
  # Contract invariants (identical to the python/js gap-fillers):
25
34
  # * Content is CAPTURED, then redacted BEFORE serialization (coverage
@@ -33,12 +42,15 @@ module Foam
33
42
  # fail-to-dark; the wrapper NEVER throws into app code or fails the
34
43
  # customer's model call — the SDK call's own error re-raises
35
44
  # IDENTICALLY, telemetry failures degrade to an unspanned call.
36
- # * ONE logical LLM call = ONE foam client span: the SDK's own HTTP send
37
- # runs inside the OTel suppression context the official http
38
- # instrumentations honor (Common::Utilities.untraced — the same
39
- # mechanism the exporters use, GOTCHAS G4), so the transport is never
40
- # double-spanned. Feature-detected; absent the call runs in the
41
- # gen_ai span's context and the transport span parents under it.
45
+ # * ONE logical LLM call = ONE foam gen_ai span: the SDK's own HTTP
46
+ # send runs inside the OTel suppression context (Common::Utilities
47
+ # .untraced — the same mechanism the exporters use, GOTCHAS G4).
48
+ # Instrumentations that honor it (net_http, excon) emit no transport
49
+ # span at all; those that DON'T (contrib -http GOTCHAS G13, the
50
+ # UNGUARDABLE list) emit a transport span that NESTS beneath the
51
+ # gen_ai span — the contract's sanctioned rendering (SPEC §10: a
52
+ # different layer, like rails-inside-rack). Either way exactly one
53
+ # gen_ai span. Feature-detected; Utilities absent → nesting.
42
54
  # * Wire vocabulary (rule 26): current GenAI semconv exactly as the
43
55
  # fleet's other packages emit it — span name "{operation} {model}",
44
56
  # gen_ai.operation.name / gen_ai.provider.name / request+response
@@ -69,7 +81,7 @@ module Foam
69
81
 
70
82
  class << self
71
83
  def adapters
72
- [OpenAIShim, AnthropicShim, GeminiShim, RubyLLMShim]
84
+ [OpenAIShim, AnthropicShim, GeminiShim, RubyLLMShim, RubyOpenAIShim, HttpAnthropicShim]
73
85
  end
74
86
 
75
87
  # Presence-checked, fault-isolated activation — one broken adapter
@@ -118,7 +130,7 @@ module Foam
118
130
  # value passes through untouched, its error re-raises identically.
119
131
  # Every telemetry step is individually guarded — a shim failure can
120
132
  # only ever mean a missing span, never a broken model call.
121
- def observe(provider:, request:, operation: "chat", response_extractor: nil, &block)
133
+ def observe(provider:, request:, operation: "chat", response_extractor: nil, error_detector: nil, &block)
122
134
  return yield unless emit?
123
135
 
124
136
  span = start_span(provider, operation, request)
@@ -132,6 +144,13 @@ module Foam
132
144
  raise
133
145
  end
134
146
  begin
147
+ # Non-raising transports (HTTP.rb: a 4xx/5xx is a RETURN VALUE,
148
+ # not an exception) surface their error through the detector —
149
+ # the observed status becomes the span status (rule 25).
150
+ detected = error_detector&.call(result)
151
+ if detected
152
+ span.status = OpenTelemetry::Trace::Status.error(detected.to_s)
153
+ end
135
154
  response = response_extractor&.call(result)
136
155
  apply_attributes(span, response) if response
137
156
  rescue StandardError, SystemStackError
@@ -237,7 +256,10 @@ module Foam
237
256
 
238
257
  def start_span(provider, operation, request)
239
258
  model = request[GEN_AI_REQUEST_MODEL]
240
- name = model ? "#{operation} #{model}" : operation
259
+ # Contract SPEC §10 span-name shape "<operation> <model|provider>":
260
+ # when the model is unextractable the provider stands in — never a
261
+ # bare operation name.
262
+ name = "#{operation} #{model || provider}"
241
263
  OpenTelemetry.tracer_provider.tracer(TRACER_NAME).start_span(
242
264
  name, kind: :client, attributes: request
243
265
  )
@@ -276,8 +298,13 @@ module Foam
276
298
  span.status = OpenTelemetry::Trace::Status.error(error.class.to_s)
277
299
  apply_attributes(span, ERROR_TYPE => error.class.to_s)
278
300
  # The SDKs' API errors carry the REAL transport status — emitted
279
- # only when OBSERVED (rule 25: never fabricate).
280
- status = error.respond_to?(:status) ? error.status : nil
301
+ # only when OBSERVED (rule 25: never fabricate). Faraday-backed
302
+ # SDKs (ruby-openai) expose it as #response_status.
303
+ status = if error.respond_to?(:status)
304
+ error.status
305
+ elsif error.respond_to?(:response_status)
306
+ error.response_status
307
+ end
281
308
  apply_attributes(span, HTTP_RESPONSE_STATUS_CODE => status) if status.is_a?(Integer)
282
309
  span.record_exception(error) if error.is_a?(Exception)
283
310
  rescue StandardError, SystemStackError
@@ -298,3 +325,5 @@ require_relative "llm/openai_shim"
298
325
  require_relative "llm/anthropic_shim"
299
326
  require_relative "llm/gemini_shim"
300
327
  require_relative "llm/ruby_llm_shim"
328
+ require_relative "llm/ruby_openai_shim"
329
+ require_relative "llm/http_anthropic_shim"
@@ -186,6 +186,64 @@ module Foam
186
186
  # ORIGINAL resource/scope are force-restored onto every survivor
187
187
  # after the pipeline (identity is not hook-writable — the Go port's
188
188
  # contract; README updated).
189
- VERSION = "1.9.1"
189
+ # 2.0.0: RUNTIME FLOOR RAISE (label-follows-tested-matrix ruling
190
+ # 2026-07-29). required_ruby_version >= 3.1 -> >= 3.4 — the version the
191
+ # CI matrix actually runs. The 3.1 floor was an unproven published
192
+ # promise: no CI leg ran below 3.4, and on 3.1–3.2 (both EOL) Bundler
193
+ # resolves an OLDER upstream otel gem set than the proven lockfile —
194
+ # a combination never tested. MAJOR: narrowing the supported-runtime
195
+ # label is breaking for installs on the dropped versions. No wire/API
196
+ # change — every 1.x contract above still holds.
197
+ # 2.1.0: LLM GAP-FILLER SHIMS + HTTP::Client COEXISTENCE SHIELD (MINOR —
198
+ # additive instrumentations, zero default-path behavior change for apps
199
+ # without the gems). Two new presence-checked, version-windowed LLM
200
+ # adapters closing the SDK-less-call gap: RubyOpenAIShim for the
201
+ # ruby-openai community client (OpenAI::Client#chat(parameters:) /
202
+ # #embeddings — presence by the parameters:-keyword SHAPE, never the
203
+ # bare method name: the official openai gem shares the OpenAI
204
+ # namespace and defines Client#chat as a zero-arg resource accessor,
205
+ # so a name check misfires on official-gem apps; both cross-gem
206
+ # negatives are pinned. Embeddings emit tokens + models ONLY — input
207
+ # documents are never captured, matching the python core's contrib
208
+ # openai-v2 path), and
209
+ # HttpAnthropicShim for hand-rolled HTTP.rb Anthropic clients (POST
210
+ # api.anthropic.com/v1/messages — EXACT path match; near-miss
211
+ # endpoints such as /v1/messages/count_tokens and
212
+ # /v1/messages/batches are pinned negative, never fabricated —
213
+ # including the persistent-client idiom
214
+ # HTTP.persistent(origin).post("/v1/messages", ...)), shape-checked
215
+ # per call so every other HTTP.rb request keeps only its plain
216
+ # transport span.
217
+ # Both inherit the LLM contract: RAW content capture redacted before
218
+ # serialization, one gen_ai span per logical call (transport spans
219
+ # from untraced-blind gems nest beneath — GOTCHAS G13),
220
+ # fail-to-dark everywhere. The transport shim is never the FIRST
221
+ # reader of the app's response: bounded JSON bodies
222
+ # (Content-Length ≤ 32 KiB) are captured and handed back re-readable
223
+ # (to_s/each/readpartial all intact); SSE (text/event-stream),
224
+ # chunked, and oversized bodies are never materialized (rule 49 cap;
225
+ # content omitted, activity + observed status survive). LLM.observe
226
+ # gains an optional error_detector: hook — HTTP.rb never raises on
227
+ # 4xx/5xx, so the shim turns the OBSERVED status into an ERROR span
228
+ # (rule 25); the status code rides http.response.status_code on the
229
+ # ERROR path ONLY (success gen_ai spans stay http.*-free, the fleet
230
+ # vocabulary) and error.type is the bare status string ("400").
231
+ # Degraded gen_ai span names now fall back to
232
+ # "<operation> <provider>" (contract SPEC §10 shape) instead of a
233
+ # bare operation, and record_failure reads Faraday-style
234
+ # #response_status so ruby-openai provider errors carry their
235
+ # observed status. ALSO
236
+ # (coexistence mandate, GOTCHAS F18 twin): the prepend-collision shield
237
+ # now also scans HTTP::Client — dd-trace-rb's httprb patch and the
238
+ # contrib http patch define annotate_span_with_response! at
239
+ # non-identical signatures (3-arg vs 2-arg), the exact F18 Net::HTTP
240
+ # collision on a second class, live in the wild under
241
+ # datadog/auto_instrument (upstream tracks it as unsupported:
242
+ # dd-trace-rb#3575, opentelemetry-ruby#1625, httprb/http#838); the
243
+ # signature-dispatch shim lets both agents keep working WHEN both
244
+ # vendor patches precede foam's init-time scan (F18 residual:
245
+ # initialize foam after the vendor agents; a later-arriving patch is
246
+ # unshielded until a re-init/fork re-scan).
247
+ VERSION = "2.1.0"
190
248
  end
191
249
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foam-otel
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.1
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Foam
@@ -395,8 +395,10 @@ files:
395
395
  - lib/foam/otel/llm.rb
396
396
  - lib/foam/otel/llm/anthropic_shim.rb
397
397
  - lib/foam/otel/llm/gemini_shim.rb
398
+ - lib/foam/otel/llm/http_anthropic_shim.rb
398
399
  - lib/foam/otel/llm/openai_shim.rb
399
400
  - lib/foam/otel/llm/ruby_llm_shim.rb
401
+ - lib/foam/otel/llm/ruby_openai_shim.rb
400
402
  - lib/foam/otel/logger_bridge.rb
401
403
  - lib/foam/otel/metrics.rb
402
404
  - lib/foam/otel/payload_capture.rb
@@ -422,7 +424,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
422
424
  requirements:
423
425
  - - ">="
424
426
  - !ruby/object:Gem::Version
425
- version: '3.1'
427
+ version: '3.4'
426
428
  required_rubygems_version: !ruby/object:Gem::Requirement
427
429
  requirements:
428
430
  - - ">="