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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 767e21dce7e53eb8a74e430c736f2ba94e22c882022733fff7fb8edb058c118e
4
- data.tar.gz: 4d9e7b6789d79daec4eb0482a22ac68520263c0a267e1b4e4def7eae97d9e642
3
+ metadata.gz: 894986849774ca1e6ee37ed18eccb168fe0b026a438abdd53432b46a5de4b9d4
4
+ data.tar.gz: c59a29ea7223dc7a92500303a2615d03d048fcf70927c12e37250d21a8785f57
5
5
  SHA512:
6
- metadata.gz: 4512202149fa0ee2b50093b7485b07373a09ed29315b6489d3e59d0c111ecf3d023e77e49f9af271ca97df33210e2028ba3376e4a43516284bd6f0fe195a8627
7
- data.tar.gz: 9d15c2c183dfdac3ca20f0a3eccae5c6f2e45134206a697604a1b70e7a9046c775d0b5e805662a162aee2b0d89e62f810cdea8e43a72b9f15db783f1b07ccb0d
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` (the
546
- patched call sites); opentelemetry-common `utilities.rb:104-117`
547
- (`untraced` the suppression context the http contrib honors). Ruling:
545
+ gemini-ai `controllers/client.rb:151`, ruby_llm `provider.rb:44`,
546
+ ruby-openai `lib/openai/client.rb` (`#chat(parameters:)`/`#embeddings`
547
+ the COMMUNITY gem sharing the official gem's OpenAI namespace and
548
+ lib/openai.rb entrypoint: presence is discriminated by the
549
+ `parameters:`-keyword SHAPE, never the bare method name), http (HTTP.rb)
550
+ `lib/http/client.rb#request` (the hand-rolled Anthropic transport seam,
551
+ shape-checked per call); opentelemetry-common `utilities.rb:104-117`
552
+ (`untraced` — the suppression context net_http/excon honor; contrib
553
+ `-http` does NOT consult it (G13/UNGUARDABLE) — its transport span NESTS
554
+ under the gen_ai span, the contract's sanctioned rendering). Ruling:
548
555
  LLM provider parity + testing-mandatory (decision log 2026-07-26).
549
556
  - **Decision & why**: every patch routes through ONE guarded core
550
557
  (`Foam::Otel::LLM.observe`, `lib/foam/otel/llm.rb`): request extraction
@@ -555,10 +562,23 @@ anthropic 1.59.0, gemini-ai 4.3.0, ruby_llm 1.16.0).
555
562
  Presence-checked constants + explicit version windows per SDK; emission
556
563
  gated on foam owning traces. Wire names are the GenAI semconv strings the
557
564
  fleet's js/python gap-fillers emit — content attributes included, RAW.
565
+ The transport shim (HTTP.rb) additionally must never be the FIRST reader
566
+ of the app's response: content is parsed only for bounded JSON bodies
567
+ (Content-Length ≤ its byte cap) and the drained bytes are re-wrapped onto
568
+ the response so app-side `#to_s`/`#each`/`#readpartial` are untouched;
569
+ SSE/chunked/oversized bodies skip content capture entirely. HTTP.rb never
570
+ raises on 4xx/5xx, so `LLM.observe`'s `error_detector:` turns the
571
+ OBSERVED status into the ERROR span (`error.type` = the bare code string).
558
572
  - **Test**: `spec/llm_openai_spec.rb`, `spec/llm_anthropic_spec.rb`,
559
- `spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb` — real SDKs against
560
- a local fake provider (only the 3rd party mocked), per-provider activity +
561
- RAW-content assertions, error-path re-raise, and the fail-to-dark set.
573
+ `spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb`,
574
+ `spec/llm_ruby_openai_spec.rb` (isolated subprocess the two openai gems
575
+ can never share an interpreter; both cross-gem discriminator negatives
576
+ pinned here and in `spec/llm_openai_spec.rb`),
577
+ `spec/llm_http_anthropic_spec.rb` (streaming-reader safety, SSE
578
+ never-consume, byte-cap, near-miss-path negatives, persistent-client) —
579
+ real SDKs against a local fake provider (only the 3rd party mocked),
580
+ per-provider activity + RAW-content assertions, error-path re-raise, and
581
+ the fail-to-dark set.
562
582
 
563
583
  ## F12: Session stamping must beat the freeze (spans) and the buffer (logs)
564
584
 
@@ -937,7 +957,7 @@ exfiltratable — the value-pattern secret layer is the required second control
937
957
 
938
958
  ---
939
959
 
940
- ## F18: One private-method namespace per class — cross-vendor helper collisions crash the APP, and foam shields them (coexistence mandate 2026-07-29)
960
+ ## F18: One private-method namespace per class — cross-vendor helper collisions crash the APP, and foam shields them (coexistence mandate 2026-07-29; HTTP::Client twin added in 2.1.0)
941
961
 
942
962
  - **Trap**: Every module prepended onto a class shares ONE private-method
943
963
  namespace. When two observability vendors prepend same-named private
@@ -949,6 +969,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 Net::HTTP's prepend chain for private-helper names defined by two
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**: an agent that patches AFTER foam's init lands ahead of the
983
- shield and re-exposes the raw collision until a later init/fork re-scan;
984
- agents overwhelmingly boot first (initializer/preload) the order the
985
- coexistence gate proves. Identical-signature collisions are left alone
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, and the real Net::HTTP + real
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 ruby_llm)
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), and `ruby_llm` (one seam covering all its providers). Absent SDK =
120
+ Ruby SDK), `ruby_llm` (one seam covering all its providers), the community
121
+ `ruby-openai` gem (`client.chat` / `client.embeddings` — presence is
122
+ discriminated from the official gem by call shape, since both reopen the
123
+ `OpenAI` namespace; embeddings emit tokens + models, never the input
124
+ documents), and hand-rolled HTTP.rb Anthropic clients (`HTTP.post` /
125
+ `HTTP.persistent(...).post` to `api.anthropic.com/v1/messages` — exactly
126
+ that endpoint; HTTP.rb's non-raising 4xx/5xx become ERROR spans with the
127
+ observed status, and foam never consumes a response stream: SSE, chunked,
128
+ and oversized bodies pass through untouched, and buffered JSON bodies are
129
+ handed back readable). Absent SDK =
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). Streaming call sites (`#stream`/`#stream_raw`
133
+ context foam's exporters use; transport gems that ignore that context, like
134
+ the contrib `http` instrumentation, nest beneath it instead). Streaming
135
+ call sites (`#stream`/`#stream_raw`
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), and `ruby_llm` (Provider#complete, one
250
- seam covering all its providers) emitting the same GenAI semconv attribute
249
+ Google ships no official Ruby SDK), `ruby_llm` (Provider#complete, one
250
+ seam covering all its providers), `ruby-openai` (GRADUATED from tier-3 in
251
+ 2.1.0: the long-dominant COMMUNITY OpenAI client, `OpenAI::Client#chat
252
+ (parameters:)`/`#embeddings` — it shares the official gem's `OpenAI`
253
+ namespace and `lib/openai.rb` entrypoint, so the two gems can never load in
254
+ one process and presence is discriminated by the `parameters:`-keyword
255
+ SHAPE, never the bare method name; specs run in an isolated subprocess for
256
+ the same reason, `spec/llm_ruby_openai_spec.rb`), and `http` (HTTP.rb —
257
+ the hand-rolled Anthropic transport seam, `HTTP::Client#request`, below) —
258
+ emitting the same GenAI semconv attribute
251
259
  set the fleet's js/python gap-fillers emit. `gen_ai.operation.name` matches
252
260
  the fleet per API shape: `chat` on the chat seams, `generate_content` on the
253
261
  Gemini shim (aligned in 1.2.1 — Ruby previously emitted `chat` for Gemini, a
@@ -267,7 +275,11 @@ the model call is never breakable by telemetry (GOTCHAS F11). LLM testing is
267
275
  MANDATORY and landed: per-provider specs against the REAL SDKs with only the
268
276
  3rd party mocked (`spec/llm_openai_spec.rb`, `spec/llm_anthropic_spec.rb`,
269
277
  `spec/llm_gemini_spec.rb`, `spec/llm_ruby_llm_spec.rb` — the last is the
270
- wire-level Gemini proof over a local fake provider).
278
+ wire-level Gemini proof over a local fake provider
279
+ `spec/llm_ruby_openai_spec.rb`, and `spec/llm_http_anthropic_spec.rb`,
280
+ which additionally pins the transport shim's never-first-reader guarantee:
281
+ streaming reads survive capture, SSE and oversized bodies are never
282
+ consumed, and the observed-status error path).
271
283
 
272
284
  - `-anthropic` (in the registry / `all` meta-gem) is **context-propagation ONLY**
273
285
  — its sole patch stores/restores OTel context across the SDK's connection pool;
@@ -278,12 +290,26 @@ wire-level Gemini proof over a local fake provider).
278
290
  but is NOT bundled: foam's own `ruby_llm` shim emits the fleet-identical
279
291
  attribute set instead, avoiding a non-registry supply-chain dependency and
280
292
  a cross-language semconv divergence.
281
- - `ruby-openai` (community gem), `langchainrb`, AWS Bedrock (via `-aws_sdk`,
293
+ - `http` (HTTP.rb) hand-rolled Anthropic clients: **TRANSPORT-LEVEL SHIM,
294
+ DELIBERATELY BOUNDED (2.1.0).** SDK-less apps that POST
295
+ `api.anthropic.com/v1/messages` directly (a live customer pattern) get the
296
+ full gen_ai set via a per-call shape check — known host + EXACT
297
+ distinctive path + POST (contract SPEC §10's transport-hook rules;
298
+ near-miss endpoints like `/v1/messages/count_tokens` and
299
+ `/v1/messages/batches` are pinned NEGATIVE, never fabricated). Scope is a
300
+ closed set — ONE provider, ONE transport: foam-otel 0.1.x shipped broad
301
+ transport classifiers and RETIRED them (the ecosystem churns too fast to
302
+ chase from a shared gem), and that ruling stands; extending this seam to
303
+ more hosts/transports (Net::HTTP, Faraday hand-rolls) is a per-addition
304
+ fleet ruling with this same census entry updated, never a silent patch.
305
+ Embeddings note (fleet parity): NO shim captures embeddings INPUT —
306
+ python's contrib openai-v2 path emits none; tokens + models only.
307
+ - `langchainrb`, AWS Bedrock (via `-aws_sdk`,
282
308
  which emits no gen_ai attributes), `llm.rb`: **TIER3-recipe** —
283
309
  hand-written spans via helpers + passthroughs with `gen_ai.*` semconv + token
284
310
  counts + trace/log correlation. An uncovered library the FDE hits becomes a
285
311
  filed package need (the FDE activates, never authors); until then the tier-3
286
- recipe covers it.
312
+ recipe covers it — the path `ruby-openai` itself took to graduation.
287
313
  - No mainstream library is CANNOT-COVER — no finding against the helper set.
288
314
 
289
315
  Sources: contrib `-anthropic` CHANGELOG; https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm ;
@@ -620,6 +620,12 @@ module Foam
620
620
  # vendor private-helper collisions and shim them. A collision-free
621
621
  # chain (the common case) installs NOTHING.
622
622
  PrependCollisionShield.install!(::Net::HTTP) if defined?(::Net::HTTP)
623
+ # The HTTP.rb ("http" gem) twin of the same collision: dd-trace-rb's
624
+ # httprb patch and the contrib http patch both define the private
625
+ # helper `annotate_span_with_response!` on HTTP::Client at
626
+ # non-identical signatures (3-arg vs 2-arg) — without the shield,
627
+ # EVERY HTTP.rb request crashes once both agents are loaded.
628
+ PrependCollisionShield.install!(::HTTP::Client) if defined?(::HTTP::Client)
623
629
  rescue StandardError => e
624
630
  Diagnostics.warn("instrumentation activation failed: #{e.class}: #{e.message}")
625
631
  end
@@ -0,0 +1,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` (official OpenAI Ruby SDK) — chat.completions.create +
17
- # responses.create
18
- # * `anthropic` (official Anthropic Ruby SDK) — messages.create
19
- # * `gemini-ai` (the de-facto Gemini Ruby SDK; Google ships no official
20
- # Ruby SDK) — generate_content / stream_generate_content
21
- # * `ruby_llm` (multi-provider client) — Provider#complete, one seam
22
- # covering its OpenAI/Anthropic/Gemini/... providers
16
+ # * `openai` (official OpenAI Ruby SDK) — chat.completions.create +
17
+ # responses.create
18
+ # * `anthropic` (official Anthropic Ruby SDK) — messages.create
19
+ # * `gemini-ai` (the de-facto Gemini Ruby SDK; Google ships no official
20
+ # Ruby SDK) — generate_content / stream_generate_content
21
+ # * `ruby_llm` (multi-provider client) — Provider#complete, one seam
22
+ # covering its OpenAI/Anthropic/Gemini/... providers
23
+ # * `ruby-openai` (the long-standing COMMUNITY OpenAI client —
24
+ # OpenAI::Client#chat(parameters:) / #embeddings; shares
25
+ # the OpenAI namespace with the official gem, so presence
26
+ # is checked by the parameters:-keyword SHAPE, never the
27
+ # bare method name) — chat + embeddings
28
+ # * `http` (HTTP.rb) — hand-rolled-provider-client pattern: POSTs
29
+ # to api.anthropic.com/v1/messages from SDK-less apps;
30
+ # shape-checked per call so every other HTTP.rb request
31
+ # in the process keeps its plain transport span
23
32
  #
24
33
  # Contract invariants (identical to the python/js gap-fillers):
25
34
  # * Content is CAPTURED, then redacted BEFORE serialization (coverage
@@ -33,12 +42,15 @@ module Foam
33
42
  # fail-to-dark; the wrapper NEVER throws into app code or fails the
34
43
  # customer's model call — the SDK call's own error re-raises
35
44
  # IDENTICALLY, telemetry failures degrade to an unspanned call.
36
- # * ONE logical LLM call = ONE foam client span: the SDK's own HTTP send
37
- # runs inside the OTel suppression context the official http
38
- # instrumentations honor (Common::Utilities.untraced — the same
39
- # mechanism the exporters use, GOTCHAS G4), so the transport is never
40
- # double-spanned. Feature-detected; absent the call runs in the
41
- # gen_ai span's context and the transport span parents under it.
45
+ # * ONE logical LLM call = ONE foam gen_ai span: the SDK's own HTTP
46
+ # send runs inside the OTel suppression context (Common::Utilities
47
+ # .untraced — the same mechanism the exporters use, GOTCHAS G4).
48
+ # Instrumentations that honor it (net_http, excon) emit no transport
49
+ # span at all; those that DON'T (contrib -http GOTCHAS G13, the
50
+ # UNGUARDABLE list) emit a transport span that NESTS beneath the
51
+ # gen_ai span — the contract's sanctioned rendering (SPEC §10: a
52
+ # different layer, like rails-inside-rack). Either way exactly one
53
+ # gen_ai span. Feature-detected; Utilities absent → nesting.
42
54
  # * Wire vocabulary (rule 26): current GenAI semconv exactly as the
43
55
  # fleet's other packages emit it — span name "{operation} {model}",
44
56
  # gen_ai.operation.name / gen_ai.provider.name / request+response
@@ -69,7 +81,7 @@ module Foam
69
81
 
70
82
  class << self
71
83
  def adapters
72
- [OpenAIShim, AnthropicShim, GeminiShim, RubyLLMShim]
84
+ [OpenAIShim, AnthropicShim, GeminiShim, RubyLLMShim, RubyOpenAIShim, HttpAnthropicShim]
73
85
  end
74
86
 
75
87
  # Presence-checked, fault-isolated activation — one broken adapter
@@ -118,7 +130,7 @@ module Foam
118
130
  # value passes through untouched, its error re-raises identically.
119
131
  # Every telemetry step is individually guarded — a shim failure can
120
132
  # only ever mean a missing span, never a broken model call.
121
- def observe(provider:, request:, operation: "chat", response_extractor: nil, &block)
133
+ def observe(provider:, request:, operation: "chat", response_extractor: nil, error_detector: nil, &block)
122
134
  return yield unless emit?
123
135
 
124
136
  span = start_span(provider, operation, request)
@@ -132,6 +144,13 @@ module Foam
132
144
  raise
133
145
  end
134
146
  begin
147
+ # Non-raising transports (HTTP.rb: a 4xx/5xx is a RETURN VALUE,
148
+ # not an exception) surface their error through the detector —
149
+ # the observed status becomes the span status (rule 25).
150
+ detected = error_detector&.call(result)
151
+ if detected
152
+ span.status = OpenTelemetry::Trace::Status.error(detected.to_s)
153
+ end
135
154
  response = response_extractor&.call(result)
136
155
  apply_attributes(span, response) if response
137
156
  rescue StandardError, SystemStackError
@@ -237,7 +256,10 @@ module Foam
237
256
 
238
257
  def start_span(provider, operation, request)
239
258
  model = request[GEN_AI_REQUEST_MODEL]
240
- name = model ? "#{operation} #{model}" : operation
259
+ # Contract SPEC §10 span-name shape "<operation> <model|provider>":
260
+ # when the model is unextractable the provider stands in — never a
261
+ # bare operation name.
262
+ name = "#{operation} #{model || provider}"
241
263
  OpenTelemetry.tracer_provider.tracer(TRACER_NAME).start_span(
242
264
  name, kind: :client, attributes: request
243
265
  )
@@ -276,8 +298,13 @@ module Foam
276
298
  span.status = OpenTelemetry::Trace::Status.error(error.class.to_s)
277
299
  apply_attributes(span, ERROR_TYPE => error.class.to_s)
278
300
  # The SDKs' API errors carry the REAL transport status — emitted
279
- # only when OBSERVED (rule 25: never fabricate).
280
- status = error.respond_to?(:status) ? error.status : nil
301
+ # only when OBSERVED (rule 25: never fabricate). Faraday-backed
302
+ # SDKs (ruby-openai) expose it as #response_status.
303
+ status = if error.respond_to?(:status)
304
+ error.status
305
+ elsif error.respond_to?(:response_status)
306
+ error.response_status
307
+ end
281
308
  apply_attributes(span, HTTP_RESPONSE_STATUS_CODE => status) if status.is_a?(Integer)
282
309
  span.record_exception(error) if error.is_a?(Exception)
283
310
  rescue StandardError, SystemStackError
@@ -298,3 +325,5 @@ require_relative "llm/openai_shim"
298
325
  require_relative "llm/anthropic_shim"
299
326
  require_relative "llm/gemini_shim"
300
327
  require_relative "llm/ruby_llm_shim"
328
+ require_relative "llm/ruby_openai_shim"
329
+ require_relative "llm/http_anthropic_shim"
@@ -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
- VERSION = "2.0.0"
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.0.0
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