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 +4 -4
- data/README.md +25 -3
- data/lib/foam/otel/constants.rb +4 -0
- data/lib/foam/otel/db.rb +113 -0
- data/lib/foam/otel/errors.rb +66 -0
- data/lib/foam/otel/gen_ai.rb +428 -0
- data/lib/foam/otel/init.rb +86 -5
- data/lib/foam/otel/metrics.rb +156 -0
- data/lib/foam/otel/outbound.rb +204 -0
- data/lib/foam/otel/rack.rb +19 -0
- data/lib/foam/otel/redis_client.rb +123 -0
- data/lib/foam/otel/sidekiq.rb +169 -10
- data/lib/foam/otel/vendor/datadog.rb +17 -0
- data/lib/foam/otel/vendor/rollbar.rb +15 -1
- data/lib/foam/otel/version.rb +1 -1
- data/lib/foam/otel.rb +6 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 89cc714cc54a1a460ea2876e90e1d6e444cddb25c24a3dd854f7ae59a2289286
|
|
4
|
+
data.tar.gz: dd8e3e10cd12c5b5246a6e50ce5ef175c40d97212d274336d28b2a54e6582cbd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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**:
|
|
24
|
-
|
|
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
|
data/lib/foam/otel/constants.rb
CHANGED
data/lib/foam/otel/db.rb
ADDED
|
@@ -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
|
data/lib/foam/otel/errors.rb
CHANGED
|
@@ -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
|