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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07574aa081ca21455a0e4384ee35109310b70f878be921a6567b3126e9809602
4
- data.tar.gz: 5a42c5a5f695c9b72cdf6a29caf5cd8d8ad8cf06895aebc0e170b9b4a02892c3
3
+ metadata.gz: c7dafd7bb3a8ccbb99ed902b457f203d0814a155c0604ea56ef68bdff17030a9
4
+ data.tar.gz: 21c19ccdf21c94db540dda4d557f3aa97406898f5dc17e4b3a18ee6d42ba9a69
5
5
  SHA512:
6
- metadata.gz: 27d39b8ca21c044fc793c4e4d4b093b3b71d8010aecc52f518da5687943c7819f4eba144e1b4d10de889ab668344b0c952cbca5ac6f67e69e0110701b7f771ad
7
- data.tar.gz: db4b50b51fb6f93a60cdee7738d9dbc181e3d18aec04fc0e6dda1a684b8e0c887be67b6bb1636c3f67580aa4decc3077b926128b40f45c216d8c4bd52ea99c7f
6
+ metadata.gz: ab14d77a71b7a964b98d92073dd3146f6b2c4917a393b25e524e67fdb101ef14b43a58b4e72384b1c77d5f08f9f21c3bf53e6ff71c2dcb30736fbc24fda2dc4d
7
+ data.tar.gz: 294d6307dbc72957bde11f5ef11b026d3575bce9794888fe788f73f3c95c06e7fbeb369feda88c5245db92974101e289c8ef0ed36c5263cb38895fd938c54317
@@ -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
@@ -1,2 +1,3 @@
1
1
  *.gem
2
2
  Gemfile.lock
3
+ gemfiles/*.gemfile.lock
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
@@ -9,7 +9,8 @@ gem "opentelemetry-sdk"
9
9
  gem "opentelemetry-exporter-otlp"
10
10
 
11
11
  group :test do
12
+ gem "appraisal", "~> 2.5"
12
13
  gem "minitest"
13
- gem "webmock"
14
14
  gem "rake"
15
+ gem "webmock"
15
16
  end
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 | Planned |
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
- result = super
69
- result_str = result.is_a?(::RubyLLM::Tool::Halt) ? result.content.to_s : result.to_s
70
- span.set_attribute("gen_ai.tool.call.result", result_str[0..500])
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
@@ -3,7 +3,7 @@
3
3
  module OpenTelemetry
4
4
  module Instrumentation
5
5
  module RubyLLM
6
- VERSION = "0.5.0"
6
+ VERSION = "0.6.0"
7
7
  end
8
8
  end
9
9
  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.5.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