Skip to content

Commit c9fc08b

Browse files
committed
Add initial OpenTelemetry instrumentation for RubyLLM
Provides automatic tracing for RubyLLM::Chat#ask calls with provider and model identification and token usage tracking using [OpenTelemetry's semantic conventions for generative AI systems](https://opentelemetry.io/docs/specs/semconv/gen-ai/).
0 parents  commit c9fc08b

11 files changed

Lines changed: 302 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.gem

Gemfile

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# frozen_string_literal: true
2+
3+
source "https://rubygems.org"
4+
5+
gemspec
6+
7+
gem "ruby_llm"
8+
gem "opentelemetry-sdk"
9+
10+
group :test do
11+
gem "minitest"
12+
gem "webmock"
13+
gem "rake"
14+
end

Gemfile.lock

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
PATH
2+
remote: .
3+
specs:
4+
opentelemetry-instrumentation-ruby_llm (0.1.0)
5+
opentelemetry-api (~> 1.0)
6+
opentelemetry-instrumentation-base (~> 0.23)
7+
8+
GEM
9+
remote: https://rubygems.org/
10+
specs:
11+
addressable (2.8.8)
12+
public_suffix (>= 2.0.2, < 8.0)
13+
base64 (0.3.0)
14+
bigdecimal (4.0.1)
15+
crack (1.0.1)
16+
bigdecimal
17+
rexml
18+
event_stream_parser (1.0.0)
19+
faraday (2.14.0)
20+
faraday-net_http (>= 2.0, < 3.5)
21+
json
22+
logger
23+
faraday-multipart (1.2.0)
24+
multipart-post (~> 2.0)
25+
faraday-net_http (3.4.2)
26+
net-http (~> 0.5)
27+
faraday-retry (2.4.0)
28+
faraday (~> 2.0)
29+
hashdiff (1.2.1)
30+
json (2.18.0)
31+
logger (1.7.0)
32+
marcel (1.1.0)
33+
minitest (6.0.1)
34+
prism (~> 1.5)
35+
multipart-post (2.4.1)
36+
net-http (0.9.1)
37+
uri (>= 0.11.1)
38+
opentelemetry-api (1.7.0)
39+
opentelemetry-common (0.23.0)
40+
opentelemetry-api (~> 1.0)
41+
opentelemetry-instrumentation-base (0.25.0)
42+
opentelemetry-api (~> 1.7)
43+
opentelemetry-common (~> 0.21)
44+
opentelemetry-registry (~> 0.1)
45+
opentelemetry-registry (0.4.0)
46+
opentelemetry-api (~> 1.1)
47+
opentelemetry-sdk (1.10.0)
48+
opentelemetry-api (~> 1.1)
49+
opentelemetry-common (~> 0.20)
50+
opentelemetry-registry (~> 0.2)
51+
opentelemetry-semantic_conventions
52+
opentelemetry-semantic_conventions (1.36.0)
53+
opentelemetry-api (~> 1.0)
54+
prism (1.9.0)
55+
public_suffix (7.0.2)
56+
rake (13.3.1)
57+
rexml (3.4.4)
58+
ruby_llm (1.11.0)
59+
base64
60+
event_stream_parser (~> 1)
61+
faraday (>= 1.10.0)
62+
faraday-multipart (>= 1)
63+
faraday-net_http (>= 1)
64+
faraday-retry (>= 1)
65+
marcel (~> 1.0)
66+
ruby_llm-schema (~> 0.2.1)
67+
zeitwerk (~> 2)
68+
ruby_llm-schema (0.2.5)
69+
uri (1.1.1)
70+
webmock (3.26.1)
71+
addressable (>= 2.8.0)
72+
crack (>= 0.3.2)
73+
hashdiff (>= 0.4.0, < 2.0.0)
74+
zeitwerk (2.7.4)
75+
76+
PLATFORMS
77+
arm64-darwin-24
78+
ruby
79+
80+
DEPENDENCIES
81+
minitest
82+
opentelemetry-instrumentation-ruby_llm!
83+
opentelemetry-sdk
84+
rake
85+
ruby_llm
86+
webmock
87+
88+
BUNDLED WITH
89+
2.7.1

Rakefile

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
require "rake/testtask"
2+
3+
Rake::TestTask.new(:test) do |t|
4+
t.libs << "test"
5+
t.pattern = "test/**/*_test.rb"
6+
end
7+
8+
task default: :test
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
require "opentelemetry-api"
2+
require "opentelemetry-instrumentation-base"
3+
4+
require_relative "opentelemetry/instrumentation/ruby_llm/version"
5+
require_relative "opentelemetry/instrumentation/ruby_llm/instrumentation"
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
module OpenTelemetry
4+
module Instrumentation
5+
module RubyLLM
6+
class Instrumentation < OpenTelemetry::Instrumentation::Base
7+
instrumentation_name "OpenTelemetry::Instrumentation::RubyLLM"
8+
instrumentation_version VERSION
9+
10+
present do
11+
defined?(::RubyLLM)
12+
end
13+
14+
install do |_config|
15+
require_relative "patches/chat"
16+
::RubyLLM::Chat.prepend(Patches::Chat)
17+
end
18+
end
19+
end
20+
end
21+
end
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
module OpenTelemetry
4+
module Instrumentation
5+
module RubyLLM
6+
module Patches
7+
module Chat
8+
def ask(message, &block)
9+
provider = @model&.provider || "unknown"
10+
model_id = @model&.id || "unknown"
11+
12+
attributes = {
13+
"gen_ai.operation.name" => "chat",
14+
"gen_ai.provider.name" => provider,
15+
"gen_ai.request.model" => model_id,
16+
}
17+
18+
tracer.in_span("chat #{model_id}", attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::CLIENT) do |span|
19+
begin
20+
result = super
21+
22+
if @messages.last
23+
response = @messages.last
24+
span.set_attribute("gen_ai.response.model", response.model_id) if response.model_id
25+
span.set_attribute("gen_ai.usage.input_tokens", response.input_tokens) if response.input_tokens
26+
span.set_attribute("gen_ai.usage.output_tokens", response.output_tokens) if response.output_tokens
27+
span.set_attribute("gen_ai.request.temperature", @temperature) if @temperature
28+
end
29+
30+
result
31+
rescue => e
32+
span.record_exception(e)
33+
span.status = OpenTelemetry::Trace::Status.error(e.message)
34+
span.set_attribute("error.type", e.class.name)
35+
raise
36+
end
37+
end
38+
end
39+
40+
private
41+
42+
def tracer
43+
RubyLLM::Instrumentation.instance.tracer
44+
end
45+
end
46+
end
47+
end
48+
end
49+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# frozen_string_literal: true
2+
3+
module OpenTelemetry
4+
module Instrumentation
5+
module RubyLLM
6+
VERSION = "0.1.0"
7+
end
8+
end
9+
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
require_relative "lib/opentelemetry/instrumentation/ruby_llm/version"
2+
3+
Gem::Specification.new do |spec|
4+
spec.name = "opentelemetry-instrumentation-ruby_llm"
5+
spec.version = OpenTelemetry::Instrumentation::RubyLLM::VERSION
6+
spec.authors = ["Clarissa Borges"]
7+
spec.email = ["cborges@thoughtbot.com"]
8+
9+
spec.summary = "OpenTelemetry instrumentation for RubyLLM"
10+
spec.description = "Adds OpenTelemetry tracing to RubyLLM chat operations"
11+
spec.homepage = "https://github.com/thoughtbot/opentelemetry-instrumentation-ruby_llm"
12+
13+
spec.required_ruby_version = ">= 3.2.0"
14+
15+
spec.metadata["homepage_uri"] = spec.homepage
16+
17+
spec.files = `git ls-files`.split("\n")
18+
spec.require_paths = ["lib"]
19+
20+
spec.add_dependency "opentelemetry-api", "~> 1.0"
21+
spec.add_dependency "opentelemetry-instrumentation-base", "~> 0.23"
22+
end

test/instrumentation_test.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
require "test_helper"
2+
3+
class InstrumentationTest < Minitest::Test
4+
def setup
5+
EXPORTER.reset
6+
7+
RubyLLM.configure do |c|
8+
c.openai_api_key = "fake-key-for-testing"
9+
end
10+
end
11+
12+
def test_creates_span_with_attributes
13+
stub_request(:post, "https://api.openai.com/v1/chat/completions")
14+
.to_return(
15+
status: 200,
16+
headers: { "Content-Type" => "application/json" },
17+
body: {
18+
id: "chatcmpl-123",
19+
object: "chat.completion",
20+
model: "gpt-4o-mini",
21+
choices: [
22+
{
23+
index: 0,
24+
message: { role: "assistant", content: "Hello, world!" },
25+
finish_reason: "stop"
26+
}
27+
],
28+
usage: {
29+
prompt_tokens: 10,
30+
completion_tokens: 5,
31+
total_tokens: 15
32+
}
33+
}.to_json
34+
)
35+
36+
chat = RubyLLM.chat(model: "gpt-4o-mini")
37+
chat.ask("Hi")
38+
39+
spans = EXPORTER.finished_spans
40+
assert_equal 1, spans.length
41+
42+
span = spans.first
43+
assert_equal OpenTelemetry::Trace::SpanKind::CLIENT, span.kind
44+
assert_equal "chat gpt-4o-mini", span.name
45+
assert_equal "openai", span.attributes["gen_ai.provider.name"]
46+
assert_equal "gpt-4o-mini", span.attributes["gen_ai.request.model"]
47+
assert_equal "chat", span.attributes["gen_ai.operation.name"]
48+
assert_equal 10, span.attributes["gen_ai.usage.input_tokens"]
49+
assert_equal 5, span.attributes["gen_ai.usage.output_tokens"]
50+
end
51+
52+
def test_records_error_on_api_failure
53+
stub_request(:post, "https://api.openai.com/v1/chat/completions")
54+
.to_return(status: 500, body: "Internal Server Error")
55+
56+
chat = RubyLLM.chat(model: "gpt-4o-mini")
57+
58+
assert_raises do
59+
chat.ask("Hi")
60+
end
61+
62+
spans = EXPORTER.finished_spans
63+
span = spans.last
64+
65+
assert_equal "chat gpt-4o-mini", span.name
66+
assert span.attributes["error.type"]
67+
assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code
68+
end
69+
end

0 commit comments

Comments
 (0)