opentelemetry-instrumentation-ruby_llm 0.5.0 → 0.6.0
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/.github/workflows/main.yml +8 -1
- data/.gitignore +1 -0
- data/Appraisals +14 -0
- data/Gemfile +2 -1
- data/README.md +11 -2
- data/gemfiles/ruby_llm_1.8.0.gemfile +16 -0
- data/gemfiles/ruby_llm_1_latest.gemfile +16 -0
- data/lib/opentelemetry/instrumentation/ruby_llm/instrumentation.rb +18 -0
- data/lib/opentelemetry/instrumentation/ruby_llm/patches/chat.rb +30 -5
- data/lib/opentelemetry/instrumentation/ruby_llm/version.rb +1 -1
- data/test/instrumentation_test.rb +116 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c7dafd7bb3a8ccbb99ed902b457f203d0814a155c0604ea56ef68bdff17030a9
|
|
4
|
+
data.tar.gz: 21c19ccdf21c94db540dda4d557f3aa97406898f5dc17e4b3a18ee6d42ba9a69
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab14d77a71b7a964b98d92073dd3146f6b2c4917a393b25e524e67fdb101ef14b43a58b4e72384b1c77d5f08f9f21c3bf53e6ff71c2dcb30736fbc24fda2dc4d
|
|
7
|
+
data.tar.gz: 294d6307dbc72957bde11f5ef11b026d3575bce9794888fe788f73f3c95c06e7fbeb369feda88c5245db92974101e289c8ef0ed36c5263cb38895fd938c54317
|
data/.github/workflows/main.yml
CHANGED
|
@@ -9,14 +9,21 @@ on:
|
|
|
9
9
|
jobs:
|
|
10
10
|
build:
|
|
11
11
|
runs-on: ubuntu-latest
|
|
12
|
-
name: Ruby ${{ matrix.ruby }}
|
|
12
|
+
name: Ruby ${{ matrix.ruby }} / ${{ matrix.gemfile }}
|
|
13
13
|
strategy:
|
|
14
|
+
fail-fast: false
|
|
14
15
|
matrix:
|
|
15
16
|
ruby:
|
|
16
17
|
- '3.4'
|
|
17
18
|
- '3.3'
|
|
18
19
|
- '3.2'
|
|
19
20
|
- '3.1'
|
|
21
|
+
gemfile:
|
|
22
|
+
- gemfiles/ruby_llm_1.8.0.gemfile
|
|
23
|
+
- gemfiles/ruby_llm_1_latest.gemfile
|
|
24
|
+
|
|
25
|
+
env:
|
|
26
|
+
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
|
|
20
27
|
|
|
21
28
|
steps:
|
|
22
29
|
- uses: actions/checkout@v4
|
data/.gitignore
CHANGED
data/Appraisals
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Test the instrumentation against the earliest supported `ruby_llm` and
|
|
4
|
+
# the latest 1.x release. 1.8.0 is the practical floor because the embedding
|
|
5
|
+
# patch calls `RubyLLM::Models.resolve` (class method delegation added in
|
|
6
|
+
# 1.8.0).
|
|
7
|
+
|
|
8
|
+
appraise "ruby_llm-1.8.0" do
|
|
9
|
+
gem "ruby_llm", "1.8.0"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
appraise "ruby_llm-1-latest" do
|
|
13
|
+
gem "ruby_llm", "~> 1.8"
|
|
14
|
+
end
|
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -7,7 +7,7 @@ OpenTelemetry instrumentation for [RubyLLM](https://rubyllm.com).
|
|
|
7
7
|
Install the gem using:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
|
-
gem opentelemetry-instrumentation-ruby_llm
|
|
10
|
+
gem install opentelemetry-instrumentation-ruby_llm
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
Or, if you use [bundler](https://bundler.io/), include `opentelemetry-instrumentation-ruby_llm` in your `Gemfile`.
|
|
@@ -96,11 +96,20 @@ Attributes persist across calls on the same chat instance and the method returns
|
|
|
96
96
|
| Conversation tracking (`gen_ai.conversation.id`) | Planned |
|
|
97
97
|
| System instructions capture | Supported (via `capture_content`) |
|
|
98
98
|
| Custom attributes on traces and spans | Supported (via `with_otel_attributes`) |
|
|
99
|
-
| Embeddings |
|
|
99
|
+
| Embeddings | Supported |
|
|
100
100
|
| Streaming | Planned |
|
|
101
101
|
|
|
102
102
|
This gem follows the [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/).
|
|
103
103
|
|
|
104
|
+
## Compatibility
|
|
105
|
+
|
|
106
|
+
This gem is tested against the following `ruby_llm` versions:
|
|
107
|
+
|
|
108
|
+
- `1.8.0` (minimum supported)
|
|
109
|
+
- `~> 1.8` (latest 1.x release)
|
|
110
|
+
|
|
111
|
+
The Ruby matrix covers Ruby 3.1, 3.2, 3.3, and 3.4.
|
|
112
|
+
|
|
104
113
|
## License
|
|
105
114
|
|
|
106
115
|
Copyright (c) Clarissa Borges and thoughtbot, inc.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "ruby_llm", "1.8.0"
|
|
6
|
+
gem "opentelemetry-sdk"
|
|
7
|
+
gem "opentelemetry-exporter-otlp"
|
|
8
|
+
|
|
9
|
+
group :test do
|
|
10
|
+
gem "appraisal", "~> 2.5"
|
|
11
|
+
gem "minitest"
|
|
12
|
+
gem "rake"
|
|
13
|
+
gem "webmock"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "ruby_llm", "~> 1.8"
|
|
6
|
+
gem "opentelemetry-sdk"
|
|
7
|
+
gem "opentelemetry-exporter-otlp"
|
|
8
|
+
|
|
9
|
+
group :test do
|
|
10
|
+
gem "appraisal", "~> 2.5"
|
|
11
|
+
gem "minitest"
|
|
12
|
+
gem "rake"
|
|
13
|
+
gem "webmock"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
gemspec path: "../"
|
|
@@ -4,6 +4,8 @@ module OpenTelemetry
|
|
|
4
4
|
module Instrumentation
|
|
5
5
|
module RubyLLM
|
|
6
6
|
class Instrumentation < OpenTelemetry::Instrumentation::Base
|
|
7
|
+
MINIMUM_RUBY_LLM_VERSION = "1.8.0"
|
|
8
|
+
|
|
7
9
|
instrumentation_name "OpenTelemetry::Instrumentation::RubyLLM"
|
|
8
10
|
instrumentation_version VERSION
|
|
9
11
|
|
|
@@ -13,6 +15,22 @@ module OpenTelemetry
|
|
|
13
15
|
defined?(::RubyLLM)
|
|
14
16
|
end
|
|
15
17
|
|
|
18
|
+
compatible do
|
|
19
|
+
# The embedding patch calls `RubyLLM::Models.resolve` (class-method delegation added in 1.8.0);
|
|
20
|
+
# Anything older than 1.8.0 would NoMethodError / NameError at install or first use.
|
|
21
|
+
compatible = Gem::Version.new(::RubyLLM::VERSION) >= Gem::Version.new(MINIMUM_RUBY_LLM_VERSION)
|
|
22
|
+
|
|
23
|
+
unless compatible
|
|
24
|
+
OpenTelemetry.logger.warn(
|
|
25
|
+
"[OpenTelemetry::Instrumentation::RubyLLM] ruby_llm " \
|
|
26
|
+
"#{::RubyLLM::VERSION} is below the required minimum " \
|
|
27
|
+
"#{MINIMUM_RUBY_LLM_VERSION}; instrumentation will not be installed."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
compatible
|
|
32
|
+
end
|
|
33
|
+
|
|
16
34
|
install do |_config|
|
|
17
35
|
require_relative "patches/chat"
|
|
18
36
|
require_relative "patches/embedding"
|
|
@@ -19,6 +19,9 @@ module OpenTelemetry
|
|
|
19
19
|
"gen_ai.provider.name" => provider,
|
|
20
20
|
"gen_ai.request.model" => model_id,
|
|
21
21
|
}
|
|
22
|
+
# Per GenAI semconv: set `gen_ai.request.stream` if and only if
|
|
23
|
+
# the request is streaming. Absence means non-streaming.
|
|
24
|
+
attributes["gen_ai.request.stream"] = true if block_given?
|
|
22
25
|
|
|
23
26
|
tracer.in_span("chat #{model_id}", attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::CLIENT) do |span|
|
|
24
27
|
begin
|
|
@@ -37,6 +40,16 @@ module OpenTelemetry
|
|
|
37
40
|
span.set_attribute("gen_ai.usage.output_tokens", response.output_tokens) if response.output_tokens
|
|
38
41
|
span.set_attribute("gen_ai.request.temperature", @temperature) if @temperature
|
|
39
42
|
|
|
43
|
+
# Prompt-cache token accessors were added in ruby_llm 1.9.0 (commit 869a755f).
|
|
44
|
+
if response.respond_to?(:cached_tokens) && response.cached_tokens
|
|
45
|
+
span.set_attribute("gen_ai.usage.cache_read.input_tokens", response.cached_tokens)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Prompt-cache token accessors were added in ruby_llm 1.9.0 (commit 869a755f).
|
|
49
|
+
if response.respond_to?(:cache_creation_tokens) && response.cache_creation_tokens
|
|
50
|
+
span.set_attribute("gen_ai.usage.cache_creation.input_tokens", response.cache_creation_tokens)
|
|
51
|
+
end
|
|
52
|
+
|
|
40
53
|
if capture_content?
|
|
41
54
|
system_messages = @messages.select { |m| m.role == :system }
|
|
42
55
|
input_messages = @messages[0..-2].reject { |m| m.role == :system }
|
|
@@ -58,16 +71,28 @@ module OpenTelemetry
|
|
|
58
71
|
|
|
59
72
|
def execute_tool(tool_call)
|
|
60
73
|
attributes = {
|
|
74
|
+
"gen_ai.operation.name" => "execute_tool",
|
|
61
75
|
"gen_ai.tool.name" => tool_call.name,
|
|
62
76
|
"gen_ai.tool.call.id" => tool_call.id,
|
|
63
77
|
"gen_ai.tool.call.arguments" => tool_call.arguments.to_json,
|
|
64
|
-
"gen_ai.tool.type" => "function"
|
|
65
|
-
|
|
78
|
+
"gen_ai.tool.type" => "function",
|
|
79
|
+
"gen_ai.tool.description" => tools[tool_call.name.to_sym]&.description
|
|
80
|
+
}.compact
|
|
66
81
|
|
|
67
82
|
tracer.in_span("execute_tool #{tool_call.name}", attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::INTERNAL) do |span|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
83
|
+
begin
|
|
84
|
+
result = super
|
|
85
|
+
rescue => e
|
|
86
|
+
span.record_exception(e)
|
|
87
|
+
span.status = OpenTelemetry::Trace::Status.error(e.message)
|
|
88
|
+
span.set_attribute("error.type", e.class.name)
|
|
89
|
+
raise
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# `RubyLLM::Tool::Halt#to_s` returns `@content.to_s`, so a single
|
|
93
|
+
# `to_s` covers both the Halt and plain-result cases.
|
|
94
|
+
span.set_attribute("gen_ai.tool.call.result", result.to_s[0..500])
|
|
95
|
+
|
|
71
96
|
result
|
|
72
97
|
end
|
|
73
98
|
end
|
|
@@ -9,6 +9,27 @@ class InstrumentationTest < Minitest::Test
|
|
|
9
9
|
end
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
+
def test_compatible_is_true_for_current_ruby_llm_version
|
|
13
|
+
instrumentation = OpenTelemetry::Instrumentation::RubyLLM::Instrumentation.instance
|
|
14
|
+
assert_equal true, instrumentation.compatible?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def test_compatible_is_false_when_ruby_llm_below_minimum
|
|
18
|
+
original_version = ::RubyLLM::VERSION
|
|
19
|
+
::RubyLLM.send(:remove_const, :VERSION)
|
|
20
|
+
::RubyLLM.const_set(:VERSION, "1.7.99")
|
|
21
|
+
|
|
22
|
+
instrumentation = OpenTelemetry::Instrumentation::RubyLLM::Instrumentation.instance
|
|
23
|
+
assert_equal false, instrumentation.compatible?
|
|
24
|
+
ensure
|
|
25
|
+
::RubyLLM.send(:remove_const, :VERSION)
|
|
26
|
+
::RubyLLM.const_set(:VERSION, original_version)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def test_minimum_ruby_llm_version_is_pinned_at_1_8_0
|
|
30
|
+
assert_equal "1.8.0", OpenTelemetry::Instrumentation::RubyLLM::Instrumentation::MINIMUM_RUBY_LLM_VERSION
|
|
31
|
+
end
|
|
32
|
+
|
|
12
33
|
def test_creates_span_with_attributes
|
|
13
34
|
stub_request(:post, "https://api.openai.com/v1/chat/completions")
|
|
14
35
|
.to_return(
|
|
@@ -45,10 +66,61 @@ class InstrumentationTest < Minitest::Test
|
|
|
45
66
|
assert_equal "openai", span.attributes["gen_ai.provider.name"]
|
|
46
67
|
assert_equal "gpt-4o-mini", span.attributes["gen_ai.request.model"]
|
|
47
68
|
assert_equal "chat", span.attributes["gen_ai.operation.name"]
|
|
69
|
+
# Per GenAI semconv, `gen_ai.request.stream` is set only when streaming.
|
|
70
|
+
assert_nil span.attributes["gen_ai.request.stream"]
|
|
48
71
|
assert_equal 10, span.attributes["gen_ai.usage.input_tokens"]
|
|
49
72
|
assert_equal 5, span.attributes["gen_ai.usage.output_tokens"]
|
|
50
73
|
end
|
|
51
74
|
|
|
75
|
+
def test_marks_streaming_chat_requests
|
|
76
|
+
stub_request(:post, "https://api.openai.com/v1/chat/completions")
|
|
77
|
+
.to_return(
|
|
78
|
+
status: 200,
|
|
79
|
+
headers: { "Content-Type" => "application/json" },
|
|
80
|
+
body: {
|
|
81
|
+
id: "chatcmpl-123",
|
|
82
|
+
model: "gpt-4o-mini",
|
|
83
|
+
choices: [{ index: 0, message: { role: "assistant", content: "Hi" }, finish_reason: "stop" }],
|
|
84
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }
|
|
85
|
+
}.to_json
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
chat = RubyLLM.chat(model: "gpt-4o-mini")
|
|
89
|
+
chat.ask("Hi") { |_chunk| }
|
|
90
|
+
|
|
91
|
+
span = EXPORTER.finished_spans.first
|
|
92
|
+
assert_equal true, span.attributes["gen_ai.request.stream"]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def test_records_prompt_cache_tokens
|
|
96
|
+
# RubyLLM's OpenAI provider maps `cached_tokens` ← `cache_read_tokens(usage)`
|
|
97
|
+
# and `cache_creation_tokens` ← `cache_write_tokens(usage)`, both surfaced
|
|
98
|
+
# on `Message#cached_tokens` / `Message#cache_creation_tokens`.
|
|
99
|
+
stub_request(:post, "https://api.openai.com/v1/chat/completions")
|
|
100
|
+
.to_return(
|
|
101
|
+
status: 200,
|
|
102
|
+
headers: { "Content-Type" => "application/json" },
|
|
103
|
+
body: {
|
|
104
|
+
id: "chatcmpl-cache",
|
|
105
|
+
model: "gpt-4o-mini",
|
|
106
|
+
choices: [{ index: 0, message: { role: "assistant", content: "Hello!" }, finish_reason: "stop" }],
|
|
107
|
+
usage: {
|
|
108
|
+
prompt_tokens: 100,
|
|
109
|
+
completion_tokens: 5,
|
|
110
|
+
total_tokens: 105,
|
|
111
|
+
prompt_tokens_details: { cached_tokens: 75, cache_write_tokens: 20 }
|
|
112
|
+
}
|
|
113
|
+
}.to_json
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
chat = RubyLLM.chat(model: "gpt-4o-mini")
|
|
117
|
+
chat.ask("Hi")
|
|
118
|
+
|
|
119
|
+
span = EXPORTER.finished_spans.first
|
|
120
|
+
assert_equal 75, span.attributes["gen_ai.usage.cache_read.input_tokens"]
|
|
121
|
+
assert_equal 20, span.attributes["gen_ai.usage.cache_creation.input_tokens"]
|
|
122
|
+
end
|
|
123
|
+
|
|
52
124
|
def test_records_error_on_api_failure
|
|
53
125
|
stub_request(:post, "https://api.openai.com/v1/chat/completions")
|
|
54
126
|
.to_return(status: 500, body: "Internal Server Error")
|
|
@@ -168,13 +240,57 @@ class InstrumentationTest < Minitest::Test
|
|
|
168
240
|
tool_span = tool_spans.first
|
|
169
241
|
assert_equal OpenTelemetry::Trace::SpanKind::INTERNAL, tool_span.kind
|
|
170
242
|
assert_equal "execute_tool calculator", tool_span.name
|
|
243
|
+
assert_equal "execute_tool", tool_span.attributes["gen_ai.operation.name"]
|
|
171
244
|
assert_equal "calculator", tool_span.attributes["gen_ai.tool.name"]
|
|
245
|
+
assert_equal "Performs math", tool_span.attributes["gen_ai.tool.description"]
|
|
172
246
|
assert_equal '{"expression":"2+2"}', tool_span.attributes["gen_ai.tool.call.arguments"]
|
|
173
247
|
assert_equal "4", tool_span.attributes["gen_ai.tool.call.result"]
|
|
174
248
|
assert_equal "call_abc123", tool_span.attributes["gen_ai.tool.call.id"]
|
|
175
249
|
assert_equal "function", tool_span.attributes["gen_ai.tool.type"]
|
|
176
250
|
end
|
|
177
251
|
|
|
252
|
+
def test_records_error_when_tool_raises
|
|
253
|
+
boom = Class.new(RubyLLM::Tool) do
|
|
254
|
+
def self.name = "boom"
|
|
255
|
+
description "Always raises"
|
|
256
|
+
|
|
257
|
+
def execute
|
|
258
|
+
raise ArgumentError, "tool failure"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
stub_request(:post, "https://api.openai.com/v1/chat/completions")
|
|
263
|
+
.to_return(
|
|
264
|
+
status: 200,
|
|
265
|
+
headers: { "Content-Type" => "application/json" },
|
|
266
|
+
body: {
|
|
267
|
+
id: "chatcmpl-boom",
|
|
268
|
+
model: "gpt-4o-mini",
|
|
269
|
+
choices: [{
|
|
270
|
+
index: 0,
|
|
271
|
+
message: {
|
|
272
|
+
role: "assistant",
|
|
273
|
+
content: nil,
|
|
274
|
+
tool_calls: [{
|
|
275
|
+
id: "call_x",
|
|
276
|
+
type: "function",
|
|
277
|
+
function: { name: "boom", arguments: "{}" }
|
|
278
|
+
}]
|
|
279
|
+
},
|
|
280
|
+
finish_reason: "tool_calls"
|
|
281
|
+
}],
|
|
282
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }
|
|
283
|
+
}.to_json
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
chat = RubyLLM.chat(model: "gpt-4o-mini").with_tool(boom)
|
|
287
|
+
assert_raises(ArgumentError) { chat.ask("trigger") }
|
|
288
|
+
|
|
289
|
+
tool_span = EXPORTER.finished_spans.find { |s| s.name.start_with?("execute_tool ") }
|
|
290
|
+
assert_equal "ArgumentError", tool_span.attributes["error.type"]
|
|
291
|
+
assert_equal OpenTelemetry::Trace::Status::ERROR, tool_span.status.code
|
|
292
|
+
end
|
|
293
|
+
|
|
178
294
|
def test_does_not_capture_content_by_default
|
|
179
295
|
stub_request(:post, "https://api.openai.com/v1/chat/completions")
|
|
180
296
|
.to_return(
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: opentelemetry-instrumentation-ruby_llm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Clarissa Borges
|
|
@@ -48,6 +48,7 @@ files:
|
|
|
48
48
|
- ".github/workflows/dynamic-security.yml"
|
|
49
49
|
- ".github/workflows/main.yml"
|
|
50
50
|
- ".gitignore"
|
|
51
|
+
- Appraisals
|
|
51
52
|
- CODEOWNERS
|
|
52
53
|
- CODE_OF_CONDUCT.md
|
|
53
54
|
- Gemfile
|
|
@@ -60,6 +61,8 @@ files:
|
|
|
60
61
|
- example/trace_demonstration_with_langfuse_and_tools.rb
|
|
61
62
|
- example/trace_demonstration_with_langfuse_ingredient_search.rb
|
|
62
63
|
- example/trace_demonstration_with_tools.rb
|
|
64
|
+
- gemfiles/ruby_llm_1.8.0.gemfile
|
|
65
|
+
- gemfiles/ruby_llm_1_latest.gemfile
|
|
63
66
|
- lib/opentelemetry-instrumentation-ruby_llm.rb
|
|
64
67
|
- lib/opentelemetry/instrumentation/ruby_llm/instrumentation.rb
|
|
65
68
|
- lib/opentelemetry/instrumentation/ruby_llm/patches/chat.rb
|