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 +4 -4
- data/.github/workflows/main.yml +8 -1
- data/.gitignore +1 -0
- data/Appraisals +14 -0
- data/Gemfile +2 -1
- data/README.md +39 -4
- data/example/trace_demonstration_with_langfuse.rb +4 -0
- data/example/trace_demonstration_with_langfuse_and_tools.rb +4 -0
- data/example/trace_demonstration_with_langfuse_ingredient_search.rb +110 -0
- 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 +37 -5
- data/lib/opentelemetry/instrumentation/ruby_llm/version.rb +1 -1
- data/test/instrumentation_test.rb +224 -0
- metadata +5 -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`.
|
|
@@ -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 |
|
|
72
|
-
| Custom attributes on traces and spans |
|
|
73
|
-
| Embeddings |
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
@@ -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
|
+
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
|