opentelemetry-instrumentation-ruby_llm 0.4.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: 25a2701b37bc9acecc70d4ff326230cbf5e073454f47c453744ced08d468b2f8
4
- data.tar.gz: a5885383024ecf52deb132bdf0ccb86b1cb9a0169561a714e6d3cabec8e3a64a
3
+ metadata.gz: c7dafd7bb3a8ccbb99ed902b457f203d0814a155c0604ea56ef68bdff17030a9
4
+ data.tar.gz: 21c19ccdf21c94db540dda4d557f3aa97406898f5dc17e4b3a18ee6d42ba9a69
5
5
  SHA512:
6
- metadata.gz: 023c166358bee510b9cd75f41764179e019eba7f3df1153c8d3806a8ce7488b7781688b708a2b315b4a2207306d822cf063c2e2125874cbbe3fc912176897edd
7
- data.tar.gz: ef4d632a49a4d22d66d751648a2c5edf6ba13d0223dc1d42901193f675e13eff065ea8dbf713bac7f29450b2af6fd510126e4bbf5a713138bf16b9a74152c642
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`.
@@ -59,6 +59,32 @@ When enabled, the following attributes are added to chat spans:
59
59
  > [!WARNING]
60
60
  > Captured content may include sensitive or personally identifiable information (PII). Use with caution in production environments.
61
61
 
62
+ ### Custom attributes
63
+
64
+ Use `with_otel_attributes` to add arbitrary attributes to the span for each request. This is useful for adding per-request metadata like Langfuse prompt linking or trace-level tags:
65
+
66
+ ```ruby
67
+ chat = RubyLLM.chat
68
+ chat.with_otel_attributes(
69
+ "langfuse.observation.prompt.name" => "supplement-assistant",
70
+ "langfuse.observation.prompt.version" => 1,
71
+ "langfuse.trace.tags" => ["vitamins"],
72
+ "langfuse.trace.metadata" => { category: "health" }.to_json
73
+ )
74
+ chat.ask("What are the side effects of Vitamin D3?")
75
+ ```
76
+
77
+ Values can also be callables (Procs/lambdas) that are evaluated after each completion, giving access to response data:
78
+
79
+ ```ruby
80
+ chat.with_otel_attributes(
81
+ "langfuse.observation.prompt.name" => "supplement-assistant",
82
+ "langfuse.observation.output" => -> { chat.messages.last&.content.to_s }
83
+ )
84
+ ```
85
+
86
+ Attributes persist across calls on the same chat instance and the method returns `self` for chaining.
87
+
62
88
  ## What's traced?
63
89
 
64
90
  | Feature | Status |
@@ -68,13 +94,22 @@ When enabled, the following attributes are added to chat spans:
68
94
  | Error handling | Supported |
69
95
  | Opt-in input/output content capture | Supported |
70
96
  | Conversation tracking (`gen_ai.conversation.id`) | Planned |
71
- | System instructions capture | Planned |
72
- | Custom attributes on traces and spans | Planned |
73
- | Embeddings | Planned |
97
+ | System instructions capture | Supported (via `capture_content`) |
98
+ | Custom attributes on traces and spans | Supported (via `with_otel_attributes`) |
99
+ | Embeddings | Supported |
74
100
  | Streaming | Planned |
75
101
 
76
102
  This gem follows the [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/).
77
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
+
78
113
  ## License
79
114
 
80
115
  Copyright (c) Clarissa Borges and thoughtbot, inc.
@@ -36,6 +36,10 @@ end
36
36
 
37
37
  chat = RubyLLM.chat
38
38
  chat.with_instructions("You are a helpful assistant that provides concise answers.")
39
+ chat.with_otel_attributes(
40
+ "langfuse.observation.prompt.name" => "helpful-assistant",
41
+ "langfuse.observation.prompt.version" => 1
42
+ )
39
43
  response = chat.ask("What is the meaning of life?")
40
44
  puts "\nResponse: #{response.content}"
41
45
 
@@ -46,6 +46,10 @@ end
46
46
  chat = RubyLLM.chat
47
47
  chat.with_instructions("You are a helpful assistant that provides concise answers.")
48
48
  chat.with_tool(Calculator)
49
+ chat.with_otel_attributes(
50
+ "langfuse.observation.prompt.name" => "helpful-assistant",
51
+ "langfuse.observation.prompt.version" => 1
52
+ )
49
53
  response = chat.ask("Use the calculator tool to compute 123 * 456")
50
54
  puts "\nResponse: #{response.content}"
51
55
  response = chat.ask("Use the tool again to compute 789 + 1011")
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/inline"
4
+
5
+ gemfile(true) do
6
+ source "https://rubygems.org"
7
+ gem "ruby_llm"
8
+ gem "opentelemetry-api"
9
+ gem "opentelemetry-sdk"
10
+ gem "opentelemetry-exporter-otlp"
11
+ gem "opentelemetry-instrumentation-ruby_llm", path: "../"
12
+ gem "base64"
13
+ gem "dotenv"
14
+ end
15
+
16
+ require "base64"
17
+ require "dotenv/load"
18
+
19
+ credentials = Base64.strict_encode64("#{ENV['LANGFUSE_PUBLIC_KEY']}:#{ENV['LANGFUSE_SECRET_KEY']}")
20
+
21
+ OpenTelemetry::SDK.configure do |c|
22
+ c.service_name = "ruby_llm-demo"
23
+ c.add_span_processor(
24
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
25
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
26
+ endpoint: "https://us.cloud.langfuse.com/api/public/otel/v1/traces",
27
+ headers: { "Authorization" => "Basic #{credentials}" }
28
+ )
29
+ )
30
+ )
31
+ c.use "OpenTelemetry::Instrumentation::RubyLLM", capture_content: true
32
+ end
33
+
34
+ RubyLLM.configure do |c|
35
+ c.openai_api_key = ENV["OPENAI_API_KEY"]
36
+ c.default_model = "gpt-5-nano"
37
+ end
38
+
39
+ INGREDIENT_DATABASE = {
40
+ "vitamin d3" => {
41
+ name: "Vitamin D3 (Cholecalciferol)",
42
+ common_doses: "1,000-5,000 IU daily",
43
+ side_effects: ["Nausea", "Vomiting", "Constipation", "Loss of appetite", "Excessive thirst", "Frequent urination", "Kidney stones (at very high doses)"],
44
+ interactions: ["Corticosteroids", "Orlistat", "Statins", "Thiazide diuretics"],
45
+ notes: "Fat-soluble vitamin. Toxicity risk at sustained doses above 10,000 IU/day."
46
+ },
47
+ "magnesium glycinate" => {
48
+ name: "Magnesium Glycinate",
49
+ common_doses: "200-400 mg daily",
50
+ side_effects: ["Diarrhea", "Nausea", "Abdominal cramping"],
51
+ interactions: ["Antibiotics (tetracyclines, quinolones)", "Bisphosphonates", "Diuretics"],
52
+ notes: "Better absorbed and gentler on the stomach than magnesium oxide."
53
+ },
54
+ "zinc" => {
55
+ name: "Zinc",
56
+ common_doses: "15-30 mg daily",
57
+ side_effects: ["Nausea", "Metallic taste", "Headache", "Copper deficiency (long-term use)"],
58
+ interactions: ["Antibiotics", "Penicillamine", "Copper supplements"],
59
+ notes: "Best taken with food to reduce nausea. Long-term use above 40 mg/day may deplete copper."
60
+ }
61
+ }
62
+
63
+ class SearchForIngredientDetails < RubyLLM::Tool
64
+ description "Searches a database for detailed information about a supplement ingredient, including side effects, interactions, and dosage"
65
+ param :ingredient_name, type: "string", desc: "The name of the ingredient to search for (e.g., 'vitamin d3', 'magnesium glycinate')"
66
+
67
+ def execute(ingredient_name:)
68
+ key = ingredient_name.downcase.strip
69
+ match = INGREDIENT_DATABASE.find { |k, _| key.include?(k) || k.include?(key) }
70
+
71
+ if match
72
+ _, details = match
73
+ details.map { |k, v| "#{k}: #{Array(v).join(', ')}" }.join("\n")
74
+ else
75
+ "No information found for '#{ingredient_name}'. Available ingredients: #{INGREDIENT_DATABASE.keys.join(', ')}"
76
+ end
77
+ end
78
+ end
79
+
80
+ chat = RubyLLM.chat
81
+ chat.with_instructions("You are a knowledgeable health supplement assistant. Use the search tool to look up ingredient details before answering questions.")
82
+ chat.with_tool(SearchForIngredientDetails)
83
+
84
+ questions = [
85
+ { text: "What are the side effects of Vitamin D3?", ingredient: "vitamin d3" },
86
+ { text: "What are the common interactions with magnesium glycinate?", ingredient: "magnesium glycinate" },
87
+ { text: "What is the recommended dosage for zinc?", ingredient: "zinc" },
88
+ { text: "Are there any interactions I should be aware of with zinc?", ingredient: "zinc" }
89
+ ]
90
+
91
+ questions.each do |q|
92
+ puts "\n---\n\n"
93
+ puts "Question: #{q[:text]}\n\n"
94
+
95
+ chat.with_otel_attributes(
96
+ "langfuse.observation.prompt.name" => "supplement-assistant",
97
+ "langfuse.observation.prompt.version" => 1,
98
+ "langfuse.observation.input" => q[:text],
99
+ "langfuse.observation.output" => -> { chat.messages.last&.content.to_s },
100
+ "langfuse.observation.metadata" => { ingredient: q[:ingredient] }.to_json,
101
+ "langfuse.trace.metadata" => { ingredient: q[:ingredient] }.to_json,
102
+ "langfuse.trace.tags" => [q[:ingredient]]
103
+ )
104
+
105
+ response = chat.ask(q[:text])
106
+ puts "\nResponse: #{response.content}"
107
+ end
108
+
109
+ # This line is only necessary in short-lived scripts. In a long-running application, spans will be flushed automatically.
110
+ OpenTelemetry.tracer_provider.force_flush
@@ -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"
@@ -5,6 +5,11 @@ module OpenTelemetry
5
5
  module RubyLLM
6
6
  module Patches
7
7
  module Chat
8
+ def with_otel_attributes(attributes)
9
+ @otel_attributes = attributes
10
+ self
11
+ end
12
+
8
13
  def complete(&)
9
14
  provider = @model&.provider || "unknown"
10
15
  model_id = @model&.id || "unknown"
@@ -14,6 +19,9 @@ module OpenTelemetry
14
19
  "gen_ai.provider.name" => provider,
15
20
  "gen_ai.request.model" => model_id,
16
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?
17
25
 
18
26
  tracer.in_span("chat #{model_id}", attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::CLIENT) do |span|
19
27
  begin
@@ -32,6 +40,16 @@ module OpenTelemetry
32
40
  span.set_attribute("gen_ai.usage.output_tokens", response.output_tokens) if response.output_tokens
33
41
  span.set_attribute("gen_ai.request.temperature", @temperature) if @temperature
34
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
+
35
53
  if capture_content?
36
54
  system_messages = @messages.select { |m| m.role == :system }
37
55
  input_messages = @messages[0..-2].reject { |m| m.role == :system }
@@ -45,22 +63,36 @@ module OpenTelemetry
45
63
  end
46
64
  end
47
65
 
66
+ @otel_attributes&.each { |key, value| span.set_attribute(key, value.respond_to?(:call) ? value.call : value) }
67
+
48
68
  result
49
69
  end
50
70
  end
51
71
 
52
72
  def execute_tool(tool_call)
53
73
  attributes = {
74
+ "gen_ai.operation.name" => "execute_tool",
54
75
  "gen_ai.tool.name" => tool_call.name,
55
76
  "gen_ai.tool.call.id" => tool_call.id,
56
77
  "gen_ai.tool.call.arguments" => tool_call.arguments.to_json,
57
- "gen_ai.tool.type" => "function"
58
- }
78
+ "gen_ai.tool.type" => "function",
79
+ "gen_ai.tool.description" => tools[tool_call.name.to_sym]&.description
80
+ }.compact
59
81
 
60
82
  tracer.in_span("execute_tool #{tool_call.name}", attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::INTERNAL) do |span|
61
- result = super
62
- result_str = result.is_a?(::RubyLLM::Tool::Halt) ? result.content.to_s : result.to_s
63
- 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
+
64
96
  result
65
97
  end
66
98
  end
@@ -3,7 +3,7 @@
3
3
  module OpenTelemetry
4
4
  module Instrumentation
5
5
  module RubyLLM
6
- VERSION = "0.4.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(
@@ -292,6 +408,114 @@ class InstrumentationTest < Minitest::Test
292
408
  assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code
293
409
  end
294
410
 
411
+ def test_with_otel_attributes_sets_span_attributes
412
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
413
+ .to_return(
414
+ status: 200,
415
+ headers: { "Content-Type" => "application/json" },
416
+ body: {
417
+ id: "chatcmpl-123",
418
+ object: "chat.completion",
419
+ model: "gpt-4o-mini",
420
+ choices: [{
421
+ index: 0,
422
+ message: { role: "assistant", content: "Hello!" },
423
+ finish_reason: "stop"
424
+ }],
425
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
426
+ }.to_json
427
+ )
428
+
429
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
430
+ chat.with_otel_attributes(
431
+ "langfuse.trace.tags" => ["vitamin_d3"],
432
+ "custom.category" => "supplements"
433
+ )
434
+ chat.ask("Hi")
435
+
436
+ span = EXPORTER.finished_spans.first
437
+ assert_equal ["vitamin_d3"], span.attributes["langfuse.trace.tags"]
438
+ assert_equal "supplements", span.attributes["custom.category"]
439
+ end
440
+
441
+ def test_with_otel_attributes_returns_self_for_chaining
442
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
443
+ .to_return(
444
+ status: 200,
445
+ headers: { "Content-Type" => "application/json" },
446
+ body: {
447
+ id: "chatcmpl-123",
448
+ object: "chat.completion",
449
+ model: "gpt-4o-mini",
450
+ choices: [{
451
+ index: 0,
452
+ message: { role: "assistant", content: "Hello!" },
453
+ finish_reason: "stop"
454
+ }],
455
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
456
+ }.to_json
457
+ )
458
+
459
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
460
+ result = chat.with_otel_attributes("custom.category" => "test")
461
+
462
+ assert_same chat, result
463
+ end
464
+
465
+ def test_with_otel_attributes_evaluates_callables
466
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
467
+ .to_return(
468
+ status: 200,
469
+ headers: { "Content-Type" => "application/json" },
470
+ body: {
471
+ id: "chatcmpl-123",
472
+ object: "chat.completion",
473
+ model: "gpt-4o-mini",
474
+ choices: [{
475
+ index: 0,
476
+ message: { role: "assistant", content: "Hello!" },
477
+ finish_reason: "stop"
478
+ }],
479
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
480
+ }.to_json
481
+ )
482
+
483
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
484
+ chat.with_otel_attributes(
485
+ "custom.last_role" => -> { chat.messages.last&.role.to_s },
486
+ "custom.static" => "fixed"
487
+ )
488
+ chat.ask("Hi")
489
+
490
+ span = EXPORTER.finished_spans.first
491
+ assert_equal "assistant", span.attributes["custom.last_role"]
492
+ assert_equal "fixed", span.attributes["custom.static"]
493
+ end
494
+
495
+ def test_works_without_otel_attributes
496
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
497
+ .to_return(
498
+ status: 200,
499
+ headers: { "Content-Type" => "application/json" },
500
+ body: {
501
+ id: "chatcmpl-123",
502
+ object: "chat.completion",
503
+ model: "gpt-4o-mini",
504
+ choices: [{
505
+ index: 0,
506
+ message: { role: "assistant", content: "Hello!" },
507
+ finish_reason: "stop"
508
+ }],
509
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
510
+ }.to_json
511
+ )
512
+
513
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
514
+ response = chat.ask("Hi")
515
+
516
+ assert_equal "Hello!", response.content
517
+ end
518
+
295
519
  def test_captures_content_when_enabled_via_env_var
296
520
  ENV["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"
297
521
 
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.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
@@ -58,7 +59,10 @@ files:
58
59
  - example/trace_demonstration.rb
59
60
  - example/trace_demonstration_with_langfuse.rb
60
61
  - example/trace_demonstration_with_langfuse_and_tools.rb
62
+ - example/trace_demonstration_with_langfuse_ingredient_search.rb
61
63
  - example/trace_demonstration_with_tools.rb
64
+ - gemfiles/ruby_llm_1.8.0.gemfile
65
+ - gemfiles/ruby_llm_1_latest.gemfile
62
66
  - lib/opentelemetry-instrumentation-ruby_llm.rb
63
67
  - lib/opentelemetry/instrumentation/ruby_llm/instrumentation.rb
64
68
  - lib/opentelemetry/instrumentation/ruby_llm/patches/chat.rb