foam-otel 2.0.0 → 2.1.1
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 +60 -14
- data/README.md +14 -3
- data/RESEARCH.md +31 -5
- data/lib/foam/otel/init.rb +6 -0
- data/lib/foam/otel/llm/http_anthropic_shim.rb +318 -0
- data/lib/foam/otel/llm/ruby_openai_shim.rb +138 -0
- data/lib/foam/otel/llm.rb +47 -18
- data/lib/foam/otel/version.rb +75 -1
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 894986849774ca1e6ee37ed18eccb168fe0b026a438abdd53432b46a5de4b9d4
|
|
4
|
+
data.tar.gz: c59a29ea7223dc7a92500303a2615d03d048fcf70927c12e37250d21a8785f57
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5cddf45218f81f9c18ab2557c7737a53da552bdf429524e6ef1f1cb64fbc84e19757de30d370e6e1217cf2a5217d271b3c4a19b6b8610d36f1846699e96a2cf6
|
|
7
|
+
data.tar.gz: 2199d01ee1f3cb89ae50d721ad6110c6c803256ac5093ba81dfc070e8cdb3af460ff13d653e6f9dd307f5a5005673e178693ca5a630459e698adc7e29a00b76e
|
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,15 @@ 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) — the
|
|
975
|
+
colliding definitions verified at source (below). A
|
|
976
|
+
`datadog/auto_instrument` +
|
|
977
|
+
contrib-http process crashes every HTTP.rb request the same way.
|
|
978
|
+
(The upstream issue numbers the introducing PR cited for this twin could
|
|
979
|
+
NOT be verified as tracking it and are withdrawn — the colliding source
|
|
980
|
+
definitions are the primary evidence.)
|
|
952
981
|
- **Sources**:
|
|
953
982
|
- Installed source — the two colliding definitions:
|
|
954
983
|
opentelemetry-instrumentation-net_http 0.29.0
|
|
@@ -957,6 +986,12 @@ exfiltratable — the value-pattern secret layer is the required second control
|
|
|
957
986
|
3-arg spelling of the same private name (verified live in the
|
|
958
987
|
coexistence gate; backtrace pinned in
|
|
959
988
|
docs/decisions/export-isolation-ruby.md §A).
|
|
989
|
+
- The HTTP::Client twin: dd-trace-rb
|
|
990
|
+
`lib/datadog/tracing/contrib/httprb/instrumentation.rb`
|
|
991
|
+
(3-arg `annotate_span_with_response!(span, response, options)`) vs
|
|
992
|
+
contrib opentelemetry-instrumentation-http
|
|
993
|
+
`lib/opentelemetry/instrumentation/http/patches/stable/client.rb`
|
|
994
|
+
(2-arg) — both definitions verified at source.
|
|
960
995
|
- Ruby semantics: prepended modules join the class's ancestor chain and
|
|
961
996
|
private methods resolve through the SAME chain for every caller — there
|
|
962
997
|
is no per-module helper namespace.
|
|
@@ -964,7 +999,8 @@ exfiltratable — the value-pattern secret layer is the required second control
|
|
|
964
999
|
resolves the name through the same shared chain — a rename breaks the
|
|
965
1000
|
gem being "fixed"), and foam must never edit another vendor's module. So
|
|
966
1001
|
foam owns the collision instead: after the instrumentation sweep, init
|
|
967
|
-
scans
|
|
1002
|
+
scans the prepend chains of Net::HTTP AND `HTTP::Client` (when the http
|
|
1003
|
+
gem is loaded) for private-helper names defined by two
|
|
968
1004
|
or more foreign modules at NON-identical positional signatures (names the
|
|
969
1005
|
class itself defines are super-chains, never shielded) and prepends ONE
|
|
970
1006
|
frontmost dispatch method per colliding name — routing by caller source
|
|
@@ -979,17 +1015,27 @@ exfiltratable — the value-pattern secret layer is the required second control
|
|
|
979
1015
|
- **Mitigation**: with the shield installed the coexistence gate's
|
|
980
1016
|
patched-client probe went from 3/3 crashes to 3/3 passes under the real
|
|
981
1017
|
agent, with both vendors' helpers verifiably executing again.
|
|
982
|
-
- **Residual**:
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1018
|
+
- **Residual**: the scan is one-shot at init and only shields collisions
|
|
1019
|
+
BOTH of whose definitions are already on the chain — an agent that
|
|
1020
|
+
patches AFTER foam's init lands ahead of the shield and re-exposes the
|
|
1021
|
+
raw collision until a later init/fork re-scan. For Net::HTTP agents
|
|
1022
|
+
overwhelmingly boot first (initializer/preload — the order the
|
|
1023
|
+
coexistence gate proves). The HTTP::Client twin is MORE order-sensitive:
|
|
1024
|
+
`defined?(::HTTP::Client)` must be true at init (the http gem required
|
|
1025
|
+
first — Bundler.require satisfies this) and both vendor patches must
|
|
1026
|
+
already be installed; a `Foam::Otel.init` that runs before
|
|
1027
|
+
`Datadog.configure` leaves that collision unshielded — initialize foam
|
|
1028
|
+
AFTER the vendor agents (the recommended order for every coexistence
|
|
1029
|
+
bridge). Identical-signature collisions are left alone
|
|
986
1030
|
(no crash class; dispatch could not disambiguate semantics).
|
|
987
1031
|
- **Test**: `spec/prepend_collision_shield_spec.rb` (the unshielded RED
|
|
988
1032
|
control, both-vendors-work dispatch, idempotence, no-op purity on
|
|
989
1033
|
collision-free chains, super-chain and identical-signature exclusions,
|
|
990
1034
|
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
|
|
1035
|
+
dispatch across two on-disk gem roots, the real Net::HTTP + real
|
|
1036
|
+
contrib patch field scenario red→green in a forked child, and the real
|
|
1037
|
+
`HTTP::Client` field scenario — 2-arg × 3-arg vendor-shaped collision on
|
|
1038
|
+
the real class, red→green in a forked child);
|
|
993
1039
|
`test-apps/ruby-coexistence/run-verify.sh` (the patched-client probe is a
|
|
994
1040
|
REQUIRED pass on every vendor posture).
|
|
995
1041
|
|
data/README.md
CHANGED
|
@@ -100,7 +100,7 @@ or erase further specific fields (see below).
|
|
|
100
100
|
> Set the standard contrib lever:
|
|
101
101
|
> `OTEL_RUBY_INSTRUMENTATION_SIDEKIQ_CONFIG_OPTS='propagation_style=child'`.
|
|
102
102
|
|
|
103
|
-
### 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)
|
|
104
104
|
|
|
105
105
|
The Ruby contrib registry ships no LLM instrumentation, so foam does: thin,
|
|
106
106
|
presence-checked shims over the SDKs' public call sites, emitting the standard
|
|
@@ -117,11 +117,22 @@ js/python Gemini paths (**wire change in 1.2.1**: Gemini spans previously
|
|
|
117
117
|
said `chat`). Covered: the official `openai` SDK
|
|
118
118
|
(chat.completions.create + responses.create), the official `anthropic` SDK
|
|
119
119
|
(messages.create), Gemini via the `gemini-ai` gem (Google ships no official
|
|
120
|
-
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 =
|
|
121
130
|
inert; version-guarded; fail-to-dark — a shim failure can NEVER break the
|
|
122
131
|
model call, and the SDK's own error re-raises identically. One model call =
|
|
123
132
|
one `gen_ai` client span (the SDK's HTTP send rides the same suppression
|
|
124
|
-
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`
|
|
125
136
|
on the official SDKs) are a recorded follow-up.
|
|
126
137
|
|
|
127
138
|
### Session stitching — browser `session.id` on backend telemetry
|
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,318 @@
|
|
|
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, no Content-Encoding — a gzip
|
|
145
|
+
# body's declared length is the COMPRESSED size, and the inflated
|
|
146
|
+
# stream could dwarf the cap) and a replacement Body can be
|
|
147
|
+
# built (probed BEFORE consuming — if HTTP.rb's internals ever
|
|
148
|
+
# drift, foam skips capture rather than risk the app's stream).
|
|
149
|
+
# The drained bytes are re-wrapped in a fresh HTTP::Response::Body
|
|
150
|
+
# over an EOF-compatible buffer and swapped back, so app-side
|
|
151
|
+
# #to_s, #each and #readpartial behave exactly as if foam had
|
|
152
|
+
# never read the response. A transport failure MID-READ is
|
|
153
|
+
# re-armed: the swapped-in body re-raises the ORIGINAL exception
|
|
154
|
+
# on the app's own read — never a misleading StateError. The read
|
|
155
|
+
# itself inherits the app's HTTP.rb timeout configuration (bytes
|
|
156
|
+
# are capped here; deadlines belong to the client's .timeout).
|
|
157
|
+
def readable_json_body(response)
|
|
158
|
+
length = content_length(response)
|
|
159
|
+
return nil unless length&.positive? && length <= BODY_PARSE_BYTE_CAP
|
|
160
|
+
return nil if content_encoded?(response)
|
|
161
|
+
return nil unless response.instance_variable_defined?(:@body)
|
|
162
|
+
return nil unless build_body("") # constructor probe, pre-consumption
|
|
163
|
+
|
|
164
|
+
contents = begin
|
|
165
|
+
response.body.to_s
|
|
166
|
+
rescue StandardError, SystemStackError => e
|
|
167
|
+
rearm_failed_body(response, e)
|
|
168
|
+
return nil
|
|
169
|
+
end
|
|
170
|
+
replacement = build_body(contents) ||
|
|
171
|
+
build_body(contents.dup.force_encoding(Encoding::BINARY))
|
|
172
|
+
# No hand-back, no capture: foam never profits from a stream it
|
|
173
|
+
# failed to return to the app.
|
|
174
|
+
return nil unless replacement
|
|
175
|
+
|
|
176
|
+
response.instance_variable_set(:@body, replacement)
|
|
177
|
+
# Belt over the declared length: never parse more than the cap
|
|
178
|
+
# actually materialized (a lying Content-Length header).
|
|
179
|
+
contents.bytesize <= BODY_PARSE_BYTE_CAP ? contents : nil
|
|
180
|
+
rescue StandardError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def content_length(response)
|
|
185
|
+
raw = response.respond_to?(:headers) ? response.headers["Content-Length"] : nil
|
|
186
|
+
raw.nil? ? nil : Integer(raw.to_s, 10)
|
|
187
|
+
rescue StandardError
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def content_encoded?(response)
|
|
192
|
+
return false unless response.respond_to?(:headers)
|
|
193
|
+
|
|
194
|
+
encoding = response.headers["Content-Encoding"].to_s
|
|
195
|
+
!encoding.empty? && encoding.downcase != "identity"
|
|
196
|
+
rescue StandardError
|
|
197
|
+
true # unknown => assume encoded, skip capture
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def build_body(contents)
|
|
201
|
+
::HTTP::Response::Body.new(BufferedBodyStream.new(contents), encoding: contents.encoding)
|
|
202
|
+
rescue StandardError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# A read that died under foam must die IDENTICALLY under the app:
|
|
207
|
+
# swap in a body whose reads re-raise the original transport error.
|
|
208
|
+
def rearm_failed_body(response, error)
|
|
209
|
+
replacement = ::HTTP::Response::Body.new(FailingBodyStream.new(error), encoding: Encoding::BINARY)
|
|
210
|
+
response.instance_variable_set(:@body, replacement)
|
|
211
|
+
rescue StandardError
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Matches HTTP::Connection's readpartial contract (nil at EOF, never
|
|
217
|
+
# EOFError) so a restored Body streams exactly like a live one.
|
|
218
|
+
class BufferedBodyStream
|
|
219
|
+
CHUNK = 16_384
|
|
220
|
+
|
|
221
|
+
def initialize(contents)
|
|
222
|
+
@io = StringIO.new(contents)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def readpartial(size = CHUNK, outbuf = nil)
|
|
226
|
+
return nil if @io.eof?
|
|
227
|
+
|
|
228
|
+
outbuf ? @io.readpartial(size, outbuf) : @io.readpartial(size)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Re-raises the transport error foam swallowed mid-capture, so the
|
|
233
|
+
# app's own read fails with the REAL exception class and message.
|
|
234
|
+
class FailingBodyStream
|
|
235
|
+
def initialize(error)
|
|
236
|
+
@error = error
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def readpartial(*_args)
|
|
240
|
+
raise @error
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Non-streaming JSON Message → response attributes; usage rides
|
|
245
|
+
# {input_tokens, output_tokens} exactly like the SDK shim.
|
|
246
|
+
RESPONSE = lambda do |response|
|
|
247
|
+
h = response.is_a?(Hash) ? response : {}
|
|
248
|
+
usage = h["usage"].is_a?(Hash) ? h["usage"] : {}
|
|
249
|
+
LLM.response_attributes(
|
|
250
|
+
model: h["model"],
|
|
251
|
+
id: h["id"],
|
|
252
|
+
finish_reasons: h["stop_reason"] ? [h["stop_reason"]] : nil,
|
|
253
|
+
input_tokens: usage["input_tokens"],
|
|
254
|
+
output_tokens: usage["output_tokens"],
|
|
255
|
+
output_messages: h["content"]
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
module RequestPatch
|
|
260
|
+
def request(verb, uri, opts = {})
|
|
261
|
+
unless Foam::Otel::LLM.emit? && HttpAnthropicShim.anthropic_messages?(verb, uri, self)
|
|
262
|
+
return super
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
body = HttpAnthropicShim.request_body(opts)
|
|
266
|
+
Foam::Otel::LLM.observe(
|
|
267
|
+
provider: "anthropic",
|
|
268
|
+
request: Foam::Otel::LLM.safe_request(provider: "anthropic") do
|
|
269
|
+
HttpAnthropicShim.request_attributes(body)
|
|
270
|
+
end,
|
|
271
|
+
error_detector: HttpAnthropicShim::ERROR_DETECTOR,
|
|
272
|
+
response_extractor: HttpAnthropicShim::EXTRACTOR
|
|
273
|
+
) { super }
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Observed-status error detection: HTTP.rb never raises on 4xx/5xx —
|
|
278
|
+
# the status IS the error signal (rule 25: observed, never fabricated).
|
|
279
|
+
ERROR_DETECTOR = lambda do |response|
|
|
280
|
+
status = response.respond_to?(:status) ? response.status.to_i : 0
|
|
281
|
+
status >= 400 ? status : nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Response attributes. The status code is stamped ONLY on the error
|
|
285
|
+
# path (the observed error signal — fleet gen_ai spans carry no
|
|
286
|
+
# http.* on success, the posture python's gap-filler also converged
|
|
287
|
+
# on). error.type carries the bare
|
|
288
|
+
# status string ("400"), matching the span status description and
|
|
289
|
+
# semconv's well-known values — never an invented format.
|
|
290
|
+
EXTRACTOR = lambda do |response|
|
|
291
|
+
attrs = {}
|
|
292
|
+
status = response.respond_to?(:status) ? response.status.to_i : 0
|
|
293
|
+
if status >= 400
|
|
294
|
+
attrs[LLM::HTTP_RESPONSE_STATUS_CODE] = status
|
|
295
|
+
attrs[LLM::ERROR_TYPE] = status.to_s
|
|
296
|
+
end
|
|
297
|
+
# HTTP::ContentType#to_s is the INSPECT string, not the media
|
|
298
|
+
# type — read #mime_type (via Response#mime_type), falling back
|
|
299
|
+
# to the raw header.
|
|
300
|
+
mime = response.respond_to?(:mime_type) ? response.mime_type.to_s : ""
|
|
301
|
+
if mime.empty? && response.respond_to?(:headers)
|
|
302
|
+
mime = response.headers["Content-Type"].to_s
|
|
303
|
+
end
|
|
304
|
+
if mime.include?("json")
|
|
305
|
+
begin
|
|
306
|
+
raw = HttpAnthropicShim.readable_json_body(response)
|
|
307
|
+
parsed = raw ? JSON.parse(raw) : nil
|
|
308
|
+
attrs.merge!(RESPONSE.call(parsed)) if parsed.is_a?(Hash)
|
|
309
|
+
rescue StandardError
|
|
310
|
+
nil # unparseable body: activity + status survive, content lost
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
attrs
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
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", operation: "embeddings") do
|
|
129
|
+
RubyOpenAIShim.embeddings_request(parameters)
|
|
130
|
+
end,
|
|
131
|
+
response_extractor: RubyOpenAIShim::EMBEDDINGS_RESPONSE
|
|
132
|
+
) { super }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
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
|
@@ -194,6 +194,80 @@ module Foam
|
|
|
194
194
|
# a combination never tested. MAJOR: narrowing the supported-runtime
|
|
195
195
|
# label is breaking for installs on the dropped versions. No wire/API
|
|
196
196
|
# change — every 1.x contract above still holds.
|
|
197
|
-
|
|
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 (both colliding definitions verified at
|
|
242
|
+
# source — GOTCHAS F18); 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
|
+
# 2.1.1: LLM CAPTURE HARDENING + RESPONSE-STYLE MATRICES (PATCH — bug
|
|
248
|
+
# fixes and tests only, no new surface). HttpAnthropicShim's body
|
|
249
|
+
# capture: content-encoded (gzip) responses are never captured (the
|
|
250
|
+
# declared Content-Length is the COMPRESSED size — the 32 KiB cap
|
|
251
|
+
# could be bypassed by inflation; a post-materialization bytesize
|
|
252
|
+
# guard also covers lying Content-Length); a transport failure DURING
|
|
253
|
+
# capture now re-raises the ORIGINAL error on the app's own read
|
|
254
|
+
# (was: a misleading HTTP::StateError); if the body hand-back cannot
|
|
255
|
+
# be rebuilt, capture is skipped entirely. Degraded ruby-openai
|
|
256
|
+
# embeddings spans keep gen_ai.operation.name="embeddings" (the
|
|
257
|
+
# fallback stamped "chat" on a span named "embeddings openai").
|
|
258
|
+
# GOTCHAS F18 citation hygiene: the three upstream issue links cited
|
|
259
|
+
# for the HTTP::Client collision could not be verified and are
|
|
260
|
+
# withdrawn; the colliding source definitions are the evidence.
|
|
261
|
+
# Tests: full response-style matrices for both providers — every
|
|
262
|
+
# documented Anthropic /v1/messages shape (parallel tool_use,
|
|
263
|
+
# thinking/redacted_thinking, all stop_reasons incl.
|
|
264
|
+
# model_context_window_exceeded, citation splitting, server-tool
|
|
265
|
+
# round trips, error envelopes 400-529, non-object/invalid JSON) and
|
|
266
|
+
# every documented OpenAI chat/embeddings shape (all finish_reasons
|
|
267
|
+
# + unknown pass-through, parallel/custom/legacy tool calls,
|
|
268
|
+
# refusal, n>1 choices, usage-detail variants, Faraday error
|
|
269
|
+
# classes with observed status, base64 embeddings) — plus gzip
|
|
270
|
+
# skip-capture, mid-read re-arm, and restored-body #each pins.
|
|
271
|
+
VERSION = "2.1.1"
|
|
198
272
|
end
|
|
199
273
|
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: 2.
|
|
4
|
+
version: 2.1.1
|
|
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
|