foam-otel 0.1.0 → 0.1.5

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: 8650474eaaa3dec06f5500839b073d5c2b211750da4ad2a068459cbbb5feb3f1
4
- data.tar.gz: 6c7b40fe1a9297f0c81547222f4ea7a36c716a9bc1eb6a2f97c5e44d49f6b9eb
3
+ metadata.gz: 89cc714cc54a1a460ea2876e90e1d6e444cddb25c24a3dd854f7ae59a2289286
4
+ data.tar.gz: dd8e3e10cd12c5b5246a6e50ce5ef175c40d97212d274336d28b2a54e6582cbd
5
5
  SHA512:
6
- metadata.gz: 39fd5531d0478acdff0ae4e36ae901ee3975a2b2c55e84319bba959683ac3fa69dd226639f2c1c7f4682eec9d064843071209f7ed56bf149d30591cecc732ba8
7
- data.tar.gz: b32886c34c0b60fd1f7751a7cb7e1a81152804c7c495078217ef7e63a83025b15b5336acffad32ebc23e730c42120aa8e32519cd3a9d1154f049130b75ad8160
6
+ metadata.gz: 401f450f54df5b4787f180a9f3a12ff367381aa731b4c28cc8ff13722900957f69a38e1cad9f0e5563940b2b9f66459fe62d1b946d1b32574adffdeba9d7b640
7
+ data.tar.gz: 7e1b45715643ab7eede7baea523ef2e7784766d62bf77ec1cad1fcd61fd37988d76667f8b5f4279489fb89a13311c449c4998973f37f3246eab5059dff102f76
data/README.md CHANGED
@@ -20,8 +20,28 @@ detection (zero customer effort):
20
20
  controller exceptions that `rescue_from` swallows (via
21
21
  `process_action.action_controller`), and broadcasts `Rails.logger` lines to
22
22
  foam as `log.rails` records — the app's own logger chain is never touched.
23
- - **Sidekiq**: server middleware auto-registers; one `sidekiq.consume <queue>`
24
- consumer span per job, failures re-raised identically (retries untouched).
23
+ - **Sidekiq**: client middleware injects W3C `traceparent` into the job hash;
24
+ server middleware restores parent context and emits one
25
+ `sidekiq.consume <queue>` consumer span per job; failures re-raised
26
+ identically (retries untouched).
27
+ - **Outbound HTTP**: `Net::HTTP` shadow spans via `Module#prepend` (composes
28
+ with Datadog; never injects headers; never captures query strings). The
29
+ OTLP endpoint host, known vendor SaaS intake hosts (Rollbar, Datadog,
30
+ New Relic, LaunchDarkly — SPEC §8), and a loaded Datadog tracer's
31
+ configured agent host (presence-detected) are never shadow-spanned; other
32
+ local sidecars are deployment config — add them via
33
+ `ignored_outbound_hosts:` (a `:port` suffix is ignored, and `localhost` /
34
+ `127.0.0.1` / `::1` cover each other).
35
+ - **Auto metrics**: `http.server.request.duration`, `sidekiq.job.duration`,
36
+ `errors.total` (no fabrication on clean 4xx).
37
+ - **Gen AI**: `gen_ai` spans for ruby-openai (Faraday), Anthropic/`http` gem,
38
+ and Faraday/http traffic to known LLM hosts (OpenTelemetry gen_ai semconv).
39
+ No prompt/response content capture; streaming response bodies are never
40
+ consumed (usage tokens are read from `application/json` responses only).
41
+ - **DB**: `db.postgres` spans via `sql.active_record` (operation only, no SQL text).
42
+ - **Redis**: `cache.redis` spans via redis-client / Redis::Client prepend.
43
+ - **Queue produce**: Sidekiq client middleware emits `queue.produce` Producer
44
+ spans and injects W3C traceparent for consume continuity.
25
45
  - **Datadog** (SPEC §7.3): foam spans and logs carry `dd.trace_id`/`dd.span_id`
26
46
  verbatim from the DD correlation API when a DD trace is active. Foam never
27
47
  patches, parents from, or reconfigures the DD tracer.
@@ -38,7 +58,9 @@ detection (zero customer effort):
38
58
  | `redact_contact_info:` | `false` | append email/phone/telefono/celular to redaction |
39
59
  | `redact_keys:` | `[]` | extra redacted keys |
40
60
  | `health_routes:` | `[]` | routes exempted from request-context capture (none by default — health checks are captured) |
41
- | `sidekiq:` / `rollbar:` / `rails:` | `true` | opt out of auto-wiring that adapter |
61
+ | `sidekiq:` / `rollbar:` / `rails:` / `outbound:` / `gen_ai:` / `db:` / `redis:` | `true` | opt out of auto-wiring that adapter |
62
+ | `auto_metrics:` | `true` | emit request/job duration + errors counters |
63
+ | `ignored_outbound_hosts:` | OTLP host + vendor SaaS intake hosts + detected DD agent host (SPEC §8) | extra hosts skipped by outbound shadow; `:port` stripped, loopback aliases equivalent |
42
64
 
43
65
  Export is production-gated (`RAILS_ENV`/`RACK_ENV`/`ENVIRONMENT`/`ENV`/
44
66
  `NODE_ENV` ∈ {production, prod}) unless `force_export`. Batch cadence comes
@@ -57,6 +57,10 @@ module Foam
57
57
  LOG_RAILS = "log.rails"
58
58
  LOG_CONSOLE = "log.console"
59
59
  APP_CUSTOM = "app.custom"
60
+ METRIC_ERRORS = "metric.errors"
61
+ GEN_AI = "gen_ai"
62
+ DB_POSTGRES = "db.postgres"
63
+ CACHE_REDIS = "cache.redis"
60
64
  end
61
65
  end
62
66
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+ require_relative "errors"
5
+
6
+ module Foam
7
+ module Otel
8
+ # PostgreSQL / ActiveRecord client spans via ActiveSupport::Notifications.
9
+ #
10
+ # Subscribes to `sql.active_record` when ActiveSupport is present. One
11
+ # CLIENT span per query, stamped foam.instrumentation=db.postgres with
12
+ # db.system=postgresql. Skips SCHEMA/CACHE noise and empty SQL.
13
+ #
14
+ # Never captures full SQL text (PHI under multi-tenant apps) — only
15
+ # db.operation (first keyword) and the short AR name ("User Load").
16
+ # Notification handler failures never raise into the app.
17
+ module Db
18
+ NOTIFICATION = "sql.active_record"
19
+ SKIP_NAME_RE = /\b(SCHEMA|CACHE)\b/i.freeze
20
+
21
+ def self.install!
22
+ return false if @installed
23
+ return false unless defined?(::ActiveSupport::Notifications)
24
+
25
+ @subscriber = ::ActiveSupport::Notifications.subscribe(NOTIFICATION) do |*args|
26
+ on_notification(*args)
27
+ rescue StandardError
28
+ nil
29
+ end
30
+ @installed = true
31
+ true
32
+ rescue StandardError => e
33
+ warn "[foam] Db.install! failed: #{e.class}: #{e.message}"
34
+ false
35
+ end
36
+
37
+ def self.reset_for_tests!
38
+ if @subscriber && defined?(::ActiveSupport::Notifications)
39
+ begin
40
+ ::ActiveSupport::Notifications.unsubscribe(@subscriber)
41
+ rescue StandardError
42
+ nil
43
+ end
44
+ end
45
+ @subscriber = nil
46
+ @installed = false
47
+ end
48
+
49
+ # ActiveSupport yields an Event object only to arity-1 blocks; the splat
50
+ # block in install! (arity -1) ALWAYS receives the 5 positional args
51
+ # (name, start, finish, id, payload) — start/finish are Time (or
52
+ # monotonic floats); subtraction is seconds either way.
53
+ def self.on_notification(_name, start, finish, _id, payload)
54
+ return if payload.nil?
55
+
56
+ duration_s = start && finish ? (finish - start).to_f : 0.0
57
+ emit(payload, duration_s)
58
+ end
59
+
60
+ def self.emit(payload, duration_s)
61
+ name = payload[:name].to_s
62
+ return if name.match?(SKIP_NAME_RE)
63
+
64
+ sql = payload[:sql].to_s
65
+ return if sql.empty?
66
+
67
+ op = sql.lstrip[/\A([A-Za-z]+)/, 1].to_s.upcase
68
+ return if op.empty?
69
+
70
+ attrs = {
71
+ INSTRUMENTATION_KEY => STAMPS::DB_POSTGRES,
72
+ "db.system" => "postgresql",
73
+ "db.operation" => op,
74
+ }
75
+ attrs["db.statement.name"] = name unless name.empty?
76
+ # Do not pass custom start/end timestamps: the OTLP exporter's protobuf
77
+ # path rejects some nanosecond shapes ("bignum too big"). The span is
78
+ # created at notification END, so its own start/end are ~zero-width —
79
+ # the real query duration is preserved as an attribute instead.
80
+ attrs["db.duration_ms"] = (duration_s.to_f * 1000).round if duration_s.to_f.positive?
81
+ span = Foam::Otel.tracer("foam-otel/db").start_span(
82
+ "db #{op.downcase}",
83
+ kind: :client,
84
+ attributes: attrs
85
+ )
86
+ begin
87
+ if (ex = payload[:exception_object])
88
+ Errors.record_once(span, ex)
89
+ elsif (pair = payload[:exception]).is_a?(Array)
90
+ span.status = OpenTelemetry::Trace::Status.error(pair[1].to_s)
91
+ span.add_event(
92
+ "exception",
93
+ attributes: {
94
+ "exception.type" => pair[0].to_s,
95
+ "exception.message" => pair[1].to_s,
96
+ }
97
+ )
98
+ else
99
+ span.status = OpenTelemetry::Trace::Status.ok
100
+ end
101
+ ensure
102
+ begin
103
+ span.finish
104
+ rescue StandardError
105
+ nil
106
+ end
107
+ end
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ end
112
+ end
113
+ end
@@ -49,6 +49,72 @@ module Foam
49
49
  rescue StandardError
50
50
  false
51
51
  end
52
+
53
+ def error_status?(span)
54
+ span.status&.code == OpenTelemetry::Trace::Status::ERROR
55
+ rescue StandardError
56
+ false
57
+ end
58
+
59
+ # --- errors.total dedupe (SPEC §13: one failure, ONE increment) -------
60
+ # A tracker bridge (Rollbar) fires mid-request, before the Rack/Sidekiq
61
+ # adapter counts the failure at span end. The bridge marks the span it
62
+ # counted under; the adapters skip their increment for a marked span.
63
+ COUNTED_IVAR = :@__foam_otel_error_counted
64
+
65
+ def mark_counted!(span)
66
+ return false unless span.respond_to?(:recording?) && span.recording?
67
+
68
+ span.instance_variable_set(COUNTED_IVAR, true)
69
+ true
70
+ rescue StandardError
71
+ false
72
+ end
73
+
74
+ def counted?(span)
75
+ span.instance_variable_get(COUNTED_IVAR) ? true : false
76
+ rescue StandardError
77
+ false
78
+ end
79
+
80
+ # The one client-span lifecycle (SPEC §6/§12), shared by every adapter
81
+ # that owns a span around a block: attach `context` (defaults to the
82
+ # span's), run the block, set OK unless an ERROR was recorded or set,
83
+ # record exceptions through record_once (deduped), RE-RAISE identically,
84
+ # and always finish the span and detach the context.
85
+ def with_active_span(span, context: nil)
86
+ return yield if span.nil?
87
+
88
+ ctx = context || OpenTelemetry::Trace.context_with_span(span)
89
+ token = OpenTelemetry::Context.attach(ctx)
90
+ begin
91
+ result = yield
92
+ begin
93
+ span.status = OpenTelemetry::Trace::Status.ok unless recorded?(span) || error_status?(span)
94
+ rescue StandardError
95
+ nil
96
+ end
97
+ result
98
+ rescue Exception => e # rubocop:disable Lint/RescueException
99
+ begin
100
+ record_once(span, e)
101
+ rescue StandardError
102
+ nil
103
+ end
104
+ raise
105
+ ensure
106
+ begin
107
+ span.finish
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ begin
112
+ OpenTelemetry::Context.detach(token)
113
+ rescue StandardError
114
+ nil
115
+ end
116
+ end
117
+ end
52
118
  end
53
119
  end
54
120
  end
@@ -0,0 +1,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "opentelemetry"
5
+ require "uri"
6
+
7
+ require_relative "constants"
8
+ require_relative "errors"
9
+
10
+ module Foam
11
+ module Otel
12
+ # Generative-AI client spans (OpenTelemetry gen_ai semantic conventions).
13
+ #
14
+ # Why this exists separately from Outbound (Net::HTTP):
15
+ # - ruby-openai talks over **Faraday** (not Net::HTTP directly).
16
+ # - AnthropicService (and similar) talk over the **http** gem.
17
+ # Net::HTTP prepend alone never sees those calls, so LLM traffic was invisible.
18
+ #
19
+ # Install path (presence detection at init):
20
+ # 1. OpenAI::Client (ruby-openai) — method-level: chat/embeddings/completions/responses
21
+ # 2. Faraday::Connection#run_request — host/path classifier for OpenAI-compatible APIs
22
+ # 3. HTTP::Client#request — host classifier for Anthropic / other http-gem clients
23
+ #
24
+ # Spans:
25
+ # name: "chat <model>" | "embeddings <model>" | "gen_ai <provider>"
26
+ # kind: client
27
+ # foam.instrumentation: gen_ai
28
+ # gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model (when known)
29
+ # gen_ai.usage.input_tokens / output_tokens when the response body exposes them
30
+ # http.status_code when available
31
+ #
32
+ # Never captures prompt/response content (PHI / cost). Never injects headers.
33
+ # Never consumes a streaming response body (usage is only extracted from
34
+ # application/json responses). Failures record exception + ERROR and
35
+ # re-raise identically.
36
+ module GenAi
37
+ STAMP = STAMPS::GEN_AI
38
+ PROVIDER_HOSTS = {
39
+ "api.openai.com" => "openai",
40
+ "api.anthropic.com" => "anthropic",
41
+ "api.together.xyz" => "together",
42
+ "api.fireworks.ai" => "fireworks",
43
+ "generativelanguage.googleapis.com" => "gemini",
44
+ "api.deepseek.com" => "deepseek",
45
+ }.freeze
46
+
47
+ # Paths distinctive enough to classify traffic to an UNKNOWN host as
48
+ # gen_ai (Azure/OpenAI-compatible proxies, local mocks). Bare
49
+ # "/messages", "/completions", or "/responses" is NOT enough — half the
50
+ # REST APIs on the internet have those (api.intercom.io/messages,
51
+ # api.typeform.com/forms/x/responses would become bogus spans); those
52
+ # forms must be /v1-prefixed the way the LLM APIs actually serve them
53
+ # (Azure's Responses API is on the v1 surface: /openai/v1/responses).
54
+ # "/embeddings" stays bare: classic Azure serves
55
+ # /openai/deployments/<d>/embeddings with no /v1, and bare /embeddings
56
+ # is rare on non-LLM APIs.
57
+ DISTINCTIVE_PATH_RE =
58
+ %r{/(chat/completions|embeddings)(/|\z)|/v1/(messages|completions|responses)(/|\z)}i
59
+
60
+ # Context key marking "a foam gen_ai span owns this call". Carried by
61
+ # the CONTEXT (not just the current span), so suppression survives
62
+ # intermediate spans another instrumentation may open in between.
63
+ SUPPRESS_KEY = OpenTelemetry::Context.create_key("foam-otel-gen-ai-owned")
64
+
65
+ def self.install!
66
+ # No top-level latch: each inner installer is idempotent, so a later
67
+ # init() can wire a client gem that was required after the first init
68
+ # (a latch here would permanently blind foam to late-loaded gems).
69
+ ok = false
70
+ ok |= install_openai!
71
+ ok |= install_faraday!
72
+ ok |= install_http_gem!
73
+ ok
74
+ rescue StandardError => e
75
+ warn "[foam] GenAi.install! failed: #{e.class}: #{e.message}"
76
+ false
77
+ end
78
+
79
+ def self.reset_for_tests!
80
+ @openai_installed = false
81
+ @faraday_installed = false
82
+ @http_installed = false
83
+ end
84
+
85
+ def self.install_openai!
86
+ return false if @openai_installed
87
+ return false unless defined?(::OpenAI::Client)
88
+
89
+ ::OpenAI::Client.prepend(OpenAIClientPatch)
90
+ if defined?(::OpenAI::Responses)
91
+ ::OpenAI::Responses.prepend(OpenAIResponsesPatch)
92
+ end
93
+ @openai_installed = true
94
+ true
95
+ rescue StandardError
96
+ false
97
+ end
98
+
99
+ def self.install_faraday!
100
+ return false if @faraday_installed
101
+ return false unless defined?(::Faraday::Connection)
102
+
103
+ ::Faraday::Connection.prepend(FaradayPatch)
104
+ @faraday_installed = true
105
+ true
106
+ rescue StandardError
107
+ false
108
+ end
109
+
110
+ def self.install_http_gem!
111
+ return false if @http_installed
112
+ return false unless defined?(::HTTP::Client)
113
+
114
+ ::HTTP::Client.prepend(HttpGemPatch)
115
+ @http_installed = true
116
+ true
117
+ rescue StandardError
118
+ false
119
+ end
120
+
121
+ def self.provider_for_host(host)
122
+ h = host.to_s.downcase
123
+ return nil if h.empty?
124
+
125
+ PROVIDER_HOSTS.each do |suffix, name|
126
+ return name if h == suffix || h.end_with?(".#{suffix}")
127
+ end
128
+ # OpenAI-compatible custom bases often still path like /v1/chat/completions
129
+ nil
130
+ end
131
+
132
+ def self.operation_for_path(path)
133
+ p = path.to_s
134
+ return "chat" if p.include?("/chat/completions") || p.include?("/messages")
135
+ return "embeddings" if p.include?("/embeddings")
136
+ return "responses" if p.include?("/responses")
137
+ return "completions" if p.include?("/completions")
138
+ return "images" if p.include?("/images")
139
+
140
+ "chat"
141
+ end
142
+
143
+ # Query strings carry secrets (Gemini authenticates via ?key=…) — the
144
+ # http.url attribute is captured without them (SPEC §10).
145
+ def self.strip_query(url)
146
+ url.to_s.split("?", 2).first.to_s
147
+ end
148
+
149
+ def self.parse_json_body(body)
150
+ return body if body.is_a?(Hash)
151
+ return nil if body.nil?
152
+
153
+ s = body.respond_to?(:to_s) ? body.to_s : nil
154
+ return nil if s.nil? || s.empty?
155
+
156
+ JSON.parse(s)
157
+ rescue StandardError
158
+ nil
159
+ end
160
+
161
+ def self.extract_usage!(span, body_hash)
162
+ return unless body_hash.is_a?(Hash)
163
+
164
+ usage = body_hash["usage"] || body_hash[:usage]
165
+ return unless usage.is_a?(Hash)
166
+
167
+ in_t = usage["prompt_tokens"] || usage["input_tokens"] || usage[:prompt_tokens] || usage[:input_tokens]
168
+ out_t = usage["completion_tokens"] || usage["output_tokens"] || usage[:completion_tokens] || usage[:output_tokens]
169
+ span.set_attribute("gen_ai.usage.input_tokens", in_t.to_i) if in_t
170
+ span.set_attribute("gen_ai.usage.output_tokens", out_t.to_i) if out_t
171
+ rescue StandardError
172
+ nil
173
+ end
174
+
175
+ # Records status code (+ERROR on 5xx) and usage tokens on the span.
176
+ # `body` must already be safe to read (a Hash or a fully-buffered
177
+ # String) — callers decide whether reading is safe, this never does.
178
+ def self.apply_response!(span, code, body)
179
+ if code
180
+ span.set_attribute("http.status_code", code.to_i)
181
+ span.status = OpenTelemetry::Trace::Status.error("") if code.to_i >= 500
182
+ end
183
+ extract_usage!(span, parse_json_body(body))
184
+ rescue StandardError
185
+ nil
186
+ end
187
+
188
+ def self.inside_gen_ai_span?
189
+ OpenTelemetry::Context.current.value(SUPPRESS_KEY) ? true : false
190
+ rescue StandardError
191
+ false
192
+ end
193
+
194
+ def self.start_span(name:, provider:, operation:, model: nil, extra: nil)
195
+ attrs = {
196
+ INSTRUMENTATION_KEY => STAMP,
197
+ "gen_ai.operation.name" => operation.to_s,
198
+ "gen_ai.provider.name" => provider.to_s,
199
+ }
200
+ attrs["gen_ai.request.model"] = model.to_s if model && !model.to_s.empty?
201
+ extra&.each { |k, v| attrs[k] = v unless v.nil? }
202
+
203
+ Foam::Otel.tracer("foam-otel/gen_ai").start_span(
204
+ name,
205
+ kind: :client,
206
+ attributes: attrs
207
+ )
208
+ rescue StandardError
209
+ nil
210
+ end
211
+
212
+ def self.with_span(span)
213
+ return yield if span.nil?
214
+
215
+ ctx = OpenTelemetry::Trace.context_with_span(span).set_value(SUPPRESS_KEY, true)
216
+ Errors.with_active_span(span, context: ctx) { yield }
217
+ end
218
+
219
+ # ---- OpenAI gem (ruby-openai) -----------------------------------------
220
+
221
+ module OpenAIClientPatch
222
+ %w[chat embeddings completions].each do |operation|
223
+ define_method(operation) do |parameters: {}|
224
+ model = parameters[:model] || parameters["model"]
225
+ span = GenAi.start_span(
226
+ name: "#{operation} #{model || "openai"}",
227
+ provider: openai_provider_name,
228
+ operation: operation,
229
+ model: model
230
+ )
231
+ GenAi.with_span(span) do
232
+ resp = super(parameters: parameters)
233
+ GenAi.extract_usage!(span, resp.is_a?(Hash) ? resp : nil) if span
234
+ resp
235
+ end
236
+ end
237
+ end
238
+
239
+ private
240
+
241
+ def openai_provider_name
242
+ base = begin
243
+ @uri_base.to_s
244
+ rescue StandardError
245
+ ""
246
+ end
247
+ host = begin
248
+ URI.parse(base).host
249
+ rescue StandardError
250
+ nil
251
+ end
252
+ provider = GenAi.provider_for_host(host)
253
+ return provider if provider
254
+
255
+ # Custom uri_base (proxy, alt region): fall back to matching the
256
+ # distinctive second-level label of each known host, derived from
257
+ # PROVIDER_HOSTS so a new provider needs exactly one table entry.
258
+ GenAi::PROVIDER_HOSTS.each do |host_key, name|
259
+ label = host_key.split(".")[-2].to_s
260
+ return name if !label.empty? && base.include?(label)
261
+ end
262
+ "openai"
263
+ end
264
+ end
265
+
266
+ module OpenAIResponsesPatch
267
+ def create(parameters: {})
268
+ model = parameters[:model] || parameters["model"]
269
+ provider = begin
270
+ client = instance_variable_get(:@client)
271
+ if client.respond_to?(:uri_base, true) || client.instance_variable_defined?(:@uri_base)
272
+ base = client.instance_variable_get(:@uri_base).to_s
273
+ host = URI.parse(base).host rescue nil
274
+ GenAi.provider_for_host(host) || "openai"
275
+ else
276
+ "openai"
277
+ end
278
+ rescue StandardError
279
+ "openai"
280
+ end
281
+ span = GenAi.start_span(
282
+ name: "responses #{model || provider}",
283
+ provider: provider,
284
+ operation: "responses",
285
+ model: model
286
+ )
287
+ GenAi.with_span(span) do
288
+ resp = super
289
+ GenAi.extract_usage!(span, resp.is_a?(Hash) ? resp : nil) if span
290
+ resp
291
+ end
292
+ end
293
+ end
294
+
295
+ # ---- Faraday (OpenAI-compatible HTTP) ---------------------------------
296
+
297
+ module FaradayPatch
298
+ def run_request(method, url, body, headers, &block)
299
+ abs = begin
300
+ build_exclusive_url(url).to_s
301
+ rescue StandardError
302
+ url.to_s
303
+ end
304
+ uri = begin
305
+ URI.parse(abs)
306
+ rescue StandardError
307
+ nil
308
+ end
309
+ host = uri&.host
310
+ path = uri&.path.to_s
311
+ provider = GenAi.provider_for_host(host)
312
+ # Also treat distinctive gen_ai paths on unknown hosts
313
+ # (Azure/OpenAI-compatible proxies, conformance mocks).
314
+ gen_path = path.match?(GenAi::DISTINCTIVE_PATH_RE)
315
+ return super if provider.nil? && !gen_path
316
+ # OpenAI::Client (or other method-level) patch already owns the gen_ai span.
317
+ return super if GenAi.inside_gen_ai_span?
318
+
319
+ provider ||= (path.include?("/messages") ? "anthropic" : "openai")
320
+ operation = GenAi.operation_for_path(path)
321
+ model = nil
322
+ begin
323
+ parsed = GenAi.parse_json_body(body)
324
+ model = parsed["model"] || parsed[:model] if parsed
325
+ rescue StandardError
326
+ nil
327
+ end
328
+ # body may be set in the block — model often only there; still span without model
329
+ span = GenAi.start_span(
330
+ name: "#{operation} #{model || provider}",
331
+ provider: provider,
332
+ operation: operation,
333
+ model: model,
334
+ extra: {
335
+ "http.method" => method.to_s.upcase,
336
+ "http.url" => GenAi.strip_query(abs),
337
+ }
338
+ )
339
+ return super if span.nil?
340
+
341
+ GenAi.with_span(span) do
342
+ resp = super
343
+ begin
344
+ # Faraday adapters buffer resp.body (a String, or a Hash when
345
+ # the json middleware ran) unless the caller streams via
346
+ # on_data — in which case body is nil/empty and this reads
347
+ # nothing.
348
+ code = resp.respond_to?(:status) ? resp.status.to_i : nil
349
+ GenAi.apply_response!(span, code, resp.respond_to?(:body) ? resp.body : nil)
350
+ rescue StandardError
351
+ nil
352
+ end
353
+ resp
354
+ end
355
+ end
356
+ end
357
+
358
+ # ---- http gem (AnthropicService etc.) ---------------------------------
359
+
360
+ module HttpGemPatch
361
+ def request(verb, uri, opts = {})
362
+ url = uri.to_s
363
+ parsed = begin
364
+ URI.parse(url)
365
+ rescue StandardError
366
+ nil
367
+ end
368
+ host = parsed&.host
369
+ path = parsed&.path.to_s
370
+ provider = GenAi.provider_for_host(host)
371
+ gen_path = path.match?(GenAi::DISTINCTIVE_PATH_RE)
372
+ return super if provider.nil? && !gen_path
373
+ return super if GenAi.inside_gen_ai_span?
374
+
375
+ # Path-based provider when host is a mock/proxy (127.0.0.1, localhost, …)
376
+ provider ||= (path.include?("/messages") ? "anthropic" : "openai")
377
+ operation = GenAi.operation_for_path(path)
378
+ model = nil
379
+ begin
380
+ json_body = opts[:json] || opts["json"]
381
+ if json_body.is_a?(Hash)
382
+ model = json_body[:model] || json_body["model"]
383
+ elsif opts[:body]
384
+ parsed_body = GenAi.parse_json_body(opts[:body])
385
+ model = parsed_body["model"] if parsed_body
386
+ end
387
+ rescue StandardError
388
+ nil
389
+ end
390
+ span = GenAi.start_span(
391
+ name: "#{operation} #{model || provider}",
392
+ provider: provider,
393
+ operation: operation,
394
+ model: model,
395
+ extra: {
396
+ "http.method" => verb.to_s.upcase,
397
+ "http.url" => GenAi.strip_query(url),
398
+ }
399
+ )
400
+ return super if span.nil?
401
+
402
+ GenAi.with_span(span) do
403
+ resp = super
404
+ begin
405
+ code = if resp.respond_to?(:status)
406
+ s = resp.status
407
+ s.respond_to?(:code) ? s.code.to_i : s.to_i
408
+ end
409
+ # The http gem's response body is a lazy stream: reading it here
410
+ # would consume it (Body#to_s buffers and poisons a later
411
+ # body.each with StateError). Only JSON responses — which the
412
+ # caller will buffer anyway, and Body#to_s caches — are read for
413
+ # usage; SSE/streaming responses are never touched.
414
+ body = nil
415
+ if resp.respond_to?(:mime_type) && resp.mime_type.to_s.include?("json")
416
+ body = resp.body if resp.respond_to?(:body)
417
+ end
418
+ GenAi.apply_response!(span, code, body)
419
+ rescue StandardError
420
+ nil
421
+ end
422
+ resp
423
+ end
424
+ end
425
+ end
426
+ end
427
+ end
428
+ end