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 +4 -4
- data/GOTCHAS.md +59 -14
- data/README.md +38 -28
- data/RESEARCH.md +31 -5
- data/lib/foam/otel/init.rb +6 -0
- data/lib/foam/otel/llm/http_anthropic_shim.rb +269 -0
- data/lib/foam/otel/llm/ruby_openai_shim.rb +136 -0
- data/lib/foam/otel/llm.rb +47 -18
- data/lib/foam/otel/version.rb +59 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65833871aff8351bab7083f4a9d636afd5704cd1327e95bc4af117437d12ad85
|
|
4
|
+
data.tar.gz: 206b1b6bfe1633b44267283c775b63ae47cd429be0bc9260c27ab89fd4f8b6ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
546
|
-
|
|
547
|
-
|
|
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
|
|
560
|
-
|
|
561
|
-
|
|
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
|
|
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**:
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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,
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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,
|
|
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),
|
|
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
|
|
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
|
|
152
|
-
>
|
|
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. **
|
|
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
|
-
|
|
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 —
|
|
486
|
-
this list) as non-negotiable. The list is byte-identical in every foam
|
|
487
|
-
and gate-checked in CI against the
|
|
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
|
-
**
|
|
491
|
-
concept in every
|
|
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
|
|
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
|
|
565
|
-
|
|
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: [...] }` (
|
|
593
|
-
|
|
594
|
-
placeholders are byte-identical in every foam
|
|
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
|
|
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
|
-
|
|
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 —
|
|
1102
|
-
| `OTEL_EXPORTER_OTLP_ENDPOINT` | HONORED — the one operator-level override of the pinned
|
|
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),
|
|
250
|
-
seam covering all its providers)
|
|
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
|
-
- `
|
|
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 ;
|
data/lib/foam/otel/init.rb
CHANGED
|
@@ -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`
|
|
17
|
-
#
|
|
18
|
-
# * `anthropic`
|
|
19
|
-
# * `gemini-ai`
|
|
20
|
-
#
|
|
21
|
-
# * `ruby_llm`
|
|
22
|
-
#
|
|
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
|
|
37
|
-
# runs inside the OTel suppression context
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
data/lib/foam/otel/version.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
427
|
+
version: '3.4'
|
|
426
428
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
427
429
|
requirements:
|
|
428
430
|
- - ">="
|