opentelemetry-instrumentation-ruby_llm 0.5.0 → 0.7.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 +9 -1
- data/.gitignore +1 -0
- data/Appraisals +18 -0
- data/Gemfile +4 -1
- data/README.md +103 -3
- data/gemfiles/ruby_llm_1.12.1.gemfile +18 -0
- data/gemfiles/ruby_llm_1.8.0.gemfile +18 -0
- data/gemfiles/ruby_llm_1_latest.gemfile +18 -0
- data/lib/opentelemetry/instrumentation/ruby_llm/instrumentation.rb +44 -0
- data/lib/opentelemetry/instrumentation/ruby_llm/message_formatter.rb +97 -0
- data/lib/opentelemetry/instrumentation/ruby_llm/patches/agent.rb +84 -0
- data/lib/opentelemetry/instrumentation/ruby_llm/patches/chat.rb +44 -32
- data/lib/opentelemetry/instrumentation/ruby_llm/patches/chat_methods.rb +17 -0
- data/lib/opentelemetry/instrumentation/ruby_llm/version.rb +1 -1
- data/test/active_record_chat_methods_test.rb +195 -0
- data/test/agent_instrumentation_test.rb +245 -0
- data/test/instrumentation_test.rb +264 -190
- data/test/test_helper.rb +30 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 41a9d9a93e7b336c7256c49918b535ee43de7fdb4f5ecdea78b03e31fad68749
|
|
4
|
+
data.tar.gz: 02bca3602ca2715d2c5fa4351a9dde621edf8886a521b2ffef9c94a0ce64ee50
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 881eec27d1ff083de355052b9bfdce866118c6a3000ec480a4e23cd7aeaf9ce33060b32c48d8f040b3cd26d071e2fa66a774c8097d053ad9616eabdc5a0f79e3
|
|
7
|
+
data.tar.gz: a582e32144ba0cb3781b3f5a3e1d3aa42e5109abdf0b1fd8044cba527465ad12f4ba783c4c9c305fd226bcfb848cda09214d9db96088fda5f6da83178bfd4fe4
|
data/.github/workflows/main.yml
CHANGED
|
@@ -9,14 +9,22 @@ 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.12.1.gemfile
|
|
24
|
+
- gemfiles/ruby_llm_1_latest.gemfile
|
|
25
|
+
|
|
26
|
+
env:
|
|
27
|
+
BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
|
|
20
28
|
|
|
21
29
|
steps:
|
|
22
30
|
- uses: actions/checkout@v4
|
data/.gitignore
CHANGED
data/Appraisals
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
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.12.1" do
|
|
13
|
+
gem "ruby_llm", "1.12.1"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
appraise "ruby_llm-1-latest" do
|
|
17
|
+
gem "ruby_llm", "~> 1.8"
|
|
18
|
+
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,16 @@ 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
|
+
### Tool result length
|
|
63
|
+
|
|
64
|
+
Tool call results are recorded on `execute_tool` spans via `gen_ai.tool.call.result`, truncated to 500 characters by default. Adjust the limit with `tool_result_max_length`:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
OpenTelemetry::SDK.configure do |c|
|
|
68
|
+
c.use 'OpenTelemetry::Instrumentation::RubyLLM', tool_result_max_length: 1000
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
62
72
|
### Custom attributes
|
|
63
73
|
|
|
64
74
|
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:
|
|
@@ -85,22 +95,112 @@ chat.with_otel_attributes(
|
|
|
85
95
|
|
|
86
96
|
Attributes persist across calls on the same chat instance and the method returns `self` for chaining.
|
|
87
97
|
|
|
98
|
+
### Conversation and user tracking
|
|
99
|
+
|
|
100
|
+
When a chat is a persisted `acts_as_chat` record from
|
|
101
|
+
[RubyLLM's Rails integration](https://rubyllm.com/rails/), its chat spans automatically
|
|
102
|
+
carry `gen_ai.conversation.id` set to the record's id — multi-turn conversations
|
|
103
|
+
correlate (and group as sessions in backends like Langfuse) with no extra code:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
chat_record = Chat.create!(model: "gpt-4o-mini")
|
|
107
|
+
chat_record.ask("Hi")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
This applies to the modern `acts_as` API (`config.use_new_acts_as = true`). Everywhere
|
|
111
|
+
else — plain `RubyLLM.chat`, the legacy `acts_as` API, or a conversation store outside
|
|
112
|
+
ActiveRecord — set `gen_ai.conversation.id` via `with_otel_attributes` using a real
|
|
113
|
+
conversation/session identifier from your application. An id you set this way always
|
|
114
|
+
wins over the automatic one:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
chat.with_otel_attributes("gen_ai.conversation.id" => session.id)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The instrumentation does not generate one for you. Per the
|
|
121
|
+
[GenAI semantic conventions](https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-spans.md),
|
|
122
|
+
when no conversation identifier is available, instrumentations should not populate the
|
|
123
|
+
attribute — a fabricated value such as a random UUID should not be used as a fallback.
|
|
124
|
+
|
|
125
|
+
You can attach user identity the same way, using the OpenTelemetry
|
|
126
|
+
[`user.*`](https://opentelemetry.io/docs/specs/semconv/registry/attributes/user/) registry
|
|
127
|
+
attributes (the GenAI conventions do not define a user attribute):
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
chat.with_otel_attributes(
|
|
131
|
+
"gen_ai.conversation.id" => session.id,
|
|
132
|
+
"user.id" => current_user.id,
|
|
133
|
+
"user.email" => current_user.email
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Agent tracing
|
|
138
|
+
|
|
139
|
+
On `ruby_llm` >= 1.12.1, invoking a `RubyLLM::Agent` subclass wraps the whole run —
|
|
140
|
+
including tool loops and their follow-up completions — in a single `invoke_agent` span:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
class ResearchAgent < RubyLLM::Agent
|
|
144
|
+
model "gpt-4o-mini"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
ResearchAgent.new.ask("Find recent papers on prompt caching")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
This produces one trace rooted at `invoke_agent ResearchAgent`
|
|
151
|
+
(`gen_ai.operation.name` = `invoke_agent`, `gen_ai.agent.name` = `ResearchAgent`), with
|
|
152
|
+
the chat and tool spans nested beneath it.
|
|
153
|
+
|
|
154
|
+
When the agent's chat is a persisted `acts_as_chat` record on the modern `acts_as` API,
|
|
155
|
+
the `invoke_agent` span and the chat spans nested beneath it all carry
|
|
156
|
+
`gen_ai.conversation.id` set to the record's id, so multi-turn conversations correlate
|
|
157
|
+
across jobs and requests without any extra code.
|
|
158
|
+
|
|
159
|
+
With `capture_content` enabled, the `invoke_agent` span also records
|
|
160
|
+
`gen_ai.input.messages` (the conversation history going in) and
|
|
161
|
+
`gen_ai.output.messages` (the final response), so backends that read the trace root
|
|
162
|
+
— like Langfuse — show the run's input and output at the trace level.
|
|
163
|
+
|
|
164
|
+
Only agent *instances* are wrapped. Class-level entry points that return the chat
|
|
165
|
+
record itself (`ResearchAgent.find(id)`, `ResearchAgent.create!`) bypass the agent
|
|
166
|
+
span — wrap the record with `ResearchAgent.new(chat: record)` to get one.
|
|
167
|
+
|
|
168
|
+
`with_otel_attributes` works on agents too — attributes are set on the `invoke_agent`
|
|
169
|
+
span (the trace root) and forwarded to the underlying chat:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
agent = ResearchAgent.new(chat: chat_record)
|
|
173
|
+
agent.with_otel_attributes("user.id" => current_user.id)
|
|
174
|
+
agent.ask("...")
|
|
175
|
+
```
|
|
176
|
+
|
|
88
177
|
## What's traced?
|
|
89
178
|
|
|
90
179
|
| Feature | Status |
|
|
91
180
|
|---------|--------|
|
|
92
181
|
| Chat completions | Supported |
|
|
93
182
|
| Tool calls | Supported |
|
|
183
|
+
| Agent invocations (`invoke_agent` spans) | Supported (`ruby_llm` >= 1.12.1) |
|
|
94
184
|
| Error handling | Supported |
|
|
95
185
|
| Opt-in input/output content capture | Supported |
|
|
96
|
-
| Conversation tracking (`gen_ai.conversation.id`) |
|
|
186
|
+
| Conversation tracking (`gen_ai.conversation.id`) | Supported (automatic for persisted `acts_as_chat` records, or set your own id via `with_otel_attributes`) |
|
|
97
187
|
| System instructions capture | Supported (via `capture_content`) |
|
|
98
188
|
| Custom attributes on traces and spans | Supported (via `with_otel_attributes`) |
|
|
99
|
-
| Embeddings |
|
|
189
|
+
| Embeddings | Supported |
|
|
100
190
|
| Streaming | Planned |
|
|
101
191
|
|
|
102
192
|
This gem follows the [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/).
|
|
103
193
|
|
|
194
|
+
## Compatibility
|
|
195
|
+
|
|
196
|
+
This gem is tested against the following `ruby_llm` versions:
|
|
197
|
+
|
|
198
|
+
- `1.8.0` (minimum supported)
|
|
199
|
+
- `1.12.1` (agent tracing floor — `RubyLLM::Agent` shipped in 1.12.0, but only loads outside Rails from 1.12.1)
|
|
200
|
+
- `~> 1.8` (latest 1.x release)
|
|
201
|
+
|
|
202
|
+
The Ruby matrix covers Ruby 3.1, 3.2, 3.3, and 3.4.
|
|
203
|
+
|
|
104
204
|
## License
|
|
105
205
|
|
|
106
206
|
Copyright (c) Clarissa Borges and thoughtbot, inc.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# This file was generated by Appraisal
|
|
2
|
+
|
|
3
|
+
source "https://rubygems.org"
|
|
4
|
+
|
|
5
|
+
gem "ruby_llm", "1.12.1"
|
|
6
|
+
gem "opentelemetry-sdk"
|
|
7
|
+
gem "opentelemetry-exporter-otlp"
|
|
8
|
+
|
|
9
|
+
group :test do
|
|
10
|
+
gem "activerecord"
|
|
11
|
+
gem "appraisal", "~> 2.5"
|
|
12
|
+
gem "minitest"
|
|
13
|
+
gem "rake"
|
|
14
|
+
gem "sqlite3"
|
|
15
|
+
gem "webmock"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,18 @@
|
|
|
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 "activerecord"
|
|
11
|
+
gem "appraisal", "~> 2.5"
|
|
12
|
+
gem "minitest"
|
|
13
|
+
gem "rake"
|
|
14
|
+
gem "sqlite3"
|
|
15
|
+
gem "webmock"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
gemspec path: "../"
|
|
@@ -0,0 +1,18 @@
|
|
|
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 "activerecord"
|
|
11
|
+
gem "appraisal", "~> 2.5"
|
|
12
|
+
gem "minitest"
|
|
13
|
+
gem "rake"
|
|
14
|
+
gem "sqlite3"
|
|
15
|
+
gem "webmock"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
gemspec path: "../"
|
|
@@ -4,20 +4,64 @@ 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
|
+
AGENT_MINIMUM_RUBY_LLM_VERSION = "1.12.1"
|
|
9
|
+
|
|
7
10
|
instrumentation_name "OpenTelemetry::Instrumentation::RubyLLM"
|
|
8
11
|
instrumentation_version VERSION
|
|
9
12
|
|
|
10
13
|
option :capture_content, default: false, validate: :boolean
|
|
14
|
+
option :tool_result_max_length, default: 500, validate: :integer
|
|
11
15
|
|
|
12
16
|
present do
|
|
13
17
|
defined?(::RubyLLM)
|
|
14
18
|
end
|
|
15
19
|
|
|
20
|
+
compatible do
|
|
21
|
+
# The embedding patch calls `RubyLLM::Models.resolve` (class-method delegation added in 1.8.0);
|
|
22
|
+
# Anything older than 1.8.0 would NoMethodError / NameError at install or first use.
|
|
23
|
+
compatible = Gem::Version.new(::RubyLLM::VERSION) >= Gem::Version.new(MINIMUM_RUBY_LLM_VERSION)
|
|
24
|
+
|
|
25
|
+
unless compatible
|
|
26
|
+
OpenTelemetry.logger.warn(
|
|
27
|
+
"[OpenTelemetry::Instrumentation::RubyLLM] ruby_llm " \
|
|
28
|
+
"#{::RubyLLM::VERSION} is below the required minimum " \
|
|
29
|
+
"#{MINIMUM_RUBY_LLM_VERSION}; instrumentation will not be installed."
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
compatible
|
|
34
|
+
end
|
|
35
|
+
|
|
16
36
|
install do |_config|
|
|
37
|
+
require_relative "message_formatter"
|
|
17
38
|
require_relative "patches/chat"
|
|
18
39
|
require_relative "patches/embedding"
|
|
19
40
|
::RubyLLM::Chat.prepend(Patches::Chat)
|
|
20
41
|
::RubyLLM::Embedding.singleton_class.prepend(Patches::Embedding)
|
|
42
|
+
|
|
43
|
+
if Gem::Version.new(::RubyLLM::VERSION) >= Gem::Version.new(AGENT_MINIMUM_RUBY_LLM_VERSION)
|
|
44
|
+
require_relative "patches/agent"
|
|
45
|
+
::RubyLLM::Agent.prepend(Patches::Agent)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
require "active_support/lazy_load_hooks"
|
|
50
|
+
|
|
51
|
+
::ActiveSupport.on_load(:active_record) do
|
|
52
|
+
require "ruby_llm/active_record/chat_methods"
|
|
53
|
+
require_relative "patches/chat_methods"
|
|
54
|
+
::RubyLLM::ActiveRecord::ChatMethods.prepend(Patches::ChatMethods)
|
|
55
|
+
rescue LoadError
|
|
56
|
+
OpenTelemetry.logger.warn(
|
|
57
|
+
"[OpenTelemetry::Instrumentation::RubyLLM] could not load " \
|
|
58
|
+
"ruby_llm/active_record/chat_methods; gen_ai.conversation.id " \
|
|
59
|
+
"will not be set automatically on persisted chat records."
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
rescue LoadError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
21
65
|
end
|
|
22
66
|
end
|
|
23
67
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTelemetry
|
|
4
|
+
module Instrumentation
|
|
5
|
+
module RubyLLM
|
|
6
|
+
# Converts `RubyLLM` messages and content into the JSON shape defined by
|
|
7
|
+
# the GenAI semantic conventions for input/output messages and system
|
|
8
|
+
# instructions:
|
|
9
|
+
#
|
|
10
|
+
# https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-input-messages.json
|
|
11
|
+
# https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-output-messages.json
|
|
12
|
+
# https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-system-instructions.json
|
|
13
|
+
#
|
|
14
|
+
# Kept separate from the `RubyLLM::Chat` patch so the formatting logic
|
|
15
|
+
# does not pollute the patched class.
|
|
16
|
+
module MessageFormatter
|
|
17
|
+
def self.format_input_messages(messages)
|
|
18
|
+
messages.map { |m| format_message(m) }.to_json
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.format_output_messages(messages)
|
|
22
|
+
messages.map { |m| format_message(m) }.to_json
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.format_system_instructions(messages)
|
|
26
|
+
messages.flat_map { |m| format_content(m.content) }.to_json
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private_class_method def self.format_message(message)
|
|
30
|
+
msg = { role: message.role.to_s, parts: [] }
|
|
31
|
+
|
|
32
|
+
if message.content
|
|
33
|
+
msg[:parts].concat(format_content(message.content))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if message.tool_calls&.any?
|
|
37
|
+
message.tool_calls.each_value do |tc|
|
|
38
|
+
msg[:parts] << { type: "tool_call", id: tc.id, name: tc.name, arguments: tc.arguments }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
msg[:tool_call_id] = message.tool_call_id if message.tool_call_id
|
|
43
|
+
|
|
44
|
+
msg
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Maps a `RubyLLM::Content`/`RubyLLM::Content::Raw` onto an array of
|
|
48
|
+
# GenAI message parts.
|
|
49
|
+
private_class_method def self.format_content(content)
|
|
50
|
+
# `RubyLLM::Content::Raw` was added in ruby_llm 1.9.0, so guard the
|
|
51
|
+
# constant rather than referencing it in a `case`/`when`.
|
|
52
|
+
if defined?(::RubyLLM::Content::Raw) && content.is_a?(::RubyLLM::Content::Raw)
|
|
53
|
+
# Serialize the provider-specific payload to JSON so consumers
|
|
54
|
+
# (e.g. Langfuse) render it as readable text rather than
|
|
55
|
+
# `[object Object]`.
|
|
56
|
+
[{ type: "raw", content: content.value.to_json }]
|
|
57
|
+
elsif content.is_a?(::RubyLLM::Content)
|
|
58
|
+
parts = []
|
|
59
|
+
parts << { type: "text", content: content.text } unless content.text.nil?
|
|
60
|
+
content.attachments.each do |attachment|
|
|
61
|
+
parts << format_attachment(attachment)
|
|
62
|
+
end
|
|
63
|
+
parts
|
|
64
|
+
else
|
|
65
|
+
[{ type: "text", content: content.to_s }]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Maps a `RubyLLM::Attachment` onto a GenAI message part.
|
|
70
|
+
private_class_method def self.format_attachment(attachment)
|
|
71
|
+
part = { modality: attachment_modality(attachment) }
|
|
72
|
+
part[:mime_type] = attachment.mime_type if attachment.mime_type
|
|
73
|
+
|
|
74
|
+
if attachment.url?
|
|
75
|
+
part[:type] = "uri"
|
|
76
|
+
part[:uri] = attachment.source.to_s
|
|
77
|
+
else
|
|
78
|
+
part[:type] = "blob"
|
|
79
|
+
part[:content] = attachment.source.to_s
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
part
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Maps a `RubyLLM::Attachment#type` onto a GenAI modality.
|
|
86
|
+
private_class_method def self.attachment_modality(attachment)
|
|
87
|
+
case attachment.type
|
|
88
|
+
when :image then "image"
|
|
89
|
+
when :video then "video"
|
|
90
|
+
when :audio then "audio"
|
|
91
|
+
else "document"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTelemetry
|
|
4
|
+
module Instrumentation
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Patches
|
|
7
|
+
module Agent
|
|
8
|
+
def with_otel_attributes(attributes)
|
|
9
|
+
@otel_attributes = attributes
|
|
10
|
+
llm_chat.with_otel_attributes(attributes)
|
|
11
|
+
self
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def ask(...)
|
|
15
|
+
in_invoke_agent_span { super }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def say(...)
|
|
19
|
+
in_invoke_agent_span { super }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def complete(...)
|
|
23
|
+
in_invoke_agent_span { super }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def in_invoke_agent_span
|
|
29
|
+
agent_name = self.class.name
|
|
30
|
+
attributes = { "gen_ai.operation.name" => "invoke_agent" }
|
|
31
|
+
attributes["gen_ai.agent.name"] = agent_name if agent_name
|
|
32
|
+
conversation_id = llm_chat.otel_conversation_id if llm_chat.respond_to?(:otel_conversation_id)
|
|
33
|
+
attributes["gen_ai.conversation.id"] = conversation_id if conversation_id
|
|
34
|
+
|
|
35
|
+
span_name = agent_name ? "invoke_agent #{agent_name}" : "invoke_agent"
|
|
36
|
+
|
|
37
|
+
tracer.in_span(span_name, attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::INTERNAL) do |span|
|
|
38
|
+
result = yield
|
|
39
|
+
capture_messages(span)
|
|
40
|
+
result
|
|
41
|
+
rescue => e
|
|
42
|
+
span.set_attribute("error.type", e.class.name)
|
|
43
|
+
raise
|
|
44
|
+
ensure
|
|
45
|
+
set_custom_attributes(span)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def capture_messages(span)
|
|
50
|
+
return unless capture_content?
|
|
51
|
+
|
|
52
|
+
messages = llm_chat.messages
|
|
53
|
+
return if messages.empty?
|
|
54
|
+
|
|
55
|
+
input_messages = messages[0..-2].reject { |m| m.role == :system }
|
|
56
|
+
span.set_attribute("gen_ai.input.messages", MessageFormatter.format_input_messages(input_messages))
|
|
57
|
+
span.set_attribute("gen_ai.output.messages", MessageFormatter.format_output_messages([messages.last]))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def llm_chat
|
|
61
|
+
chat.respond_to?(:to_llm) ? chat.to_llm : chat
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def capture_content?
|
|
65
|
+
env_value = ENV["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"]
|
|
66
|
+
return env_value.to_s.strip.casecmp("true").zero? unless env_value.nil?
|
|
67
|
+
|
|
68
|
+
RubyLLM::Instrumentation.instance.config[:capture_content]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def set_custom_attributes(span)
|
|
72
|
+
@otel_attributes&.each { |key, value| span.set_attribute(key, value.respond_to?(:call) ? value.call : value) }
|
|
73
|
+
rescue => e
|
|
74
|
+
OpenTelemetry.handle_error(exception: e)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def tracer
|
|
78
|
+
RubyLLM::Instrumentation.instance.tracer
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -5,6 +5,13 @@ module OpenTelemetry
|
|
|
5
5
|
module RubyLLM
|
|
6
6
|
module Patches
|
|
7
7
|
module Chat
|
|
8
|
+
attr_writer :otel_conversation_id
|
|
9
|
+
|
|
10
|
+
def otel_conversation_id
|
|
11
|
+
id = @otel_attributes&.[]("gen_ai.conversation.id") || @otel_conversation_id
|
|
12
|
+
id.respond_to?(:call) ? id.call : id
|
|
13
|
+
end
|
|
14
|
+
|
|
8
15
|
def with_otel_attributes(attributes)
|
|
9
16
|
@otel_attributes = attributes
|
|
10
17
|
self
|
|
@@ -19,6 +26,11 @@ module OpenTelemetry
|
|
|
19
26
|
"gen_ai.provider.name" => provider,
|
|
20
27
|
"gen_ai.request.model" => model_id,
|
|
21
28
|
}
|
|
29
|
+
conversation_id = otel_conversation_id
|
|
30
|
+
attributes["gen_ai.conversation.id"] = conversation_id if conversation_id
|
|
31
|
+
# Per GenAI semconv: set `gen_ai.request.stream` if and only if
|
|
32
|
+
# the request is streaming. Absence means non-streaming.
|
|
33
|
+
attributes["gen_ai.request.stream"] = true if block_given?
|
|
22
34
|
|
|
23
35
|
tracer.in_span("chat #{model_id}", attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::CLIENT) do |span|
|
|
24
36
|
begin
|
|
@@ -37,16 +49,26 @@ module OpenTelemetry
|
|
|
37
49
|
span.set_attribute("gen_ai.usage.output_tokens", response.output_tokens) if response.output_tokens
|
|
38
50
|
span.set_attribute("gen_ai.request.temperature", @temperature) if @temperature
|
|
39
51
|
|
|
52
|
+
# Prompt-cache token accessors were added in ruby_llm 1.9.0 (commit 869a755f).
|
|
53
|
+
if response.respond_to?(:cached_tokens) && response.cached_tokens
|
|
54
|
+
span.set_attribute("gen_ai.usage.cache_read.input_tokens", response.cached_tokens)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Prompt-cache token accessors were added in ruby_llm 1.9.0 (commit 869a755f).
|
|
58
|
+
if response.respond_to?(:cache_creation_tokens) && response.cache_creation_tokens
|
|
59
|
+
span.set_attribute("gen_ai.usage.cache_creation.input_tokens", response.cache_creation_tokens)
|
|
60
|
+
end
|
|
61
|
+
|
|
40
62
|
if capture_content?
|
|
41
63
|
system_messages = @messages.select { |m| m.role == :system }
|
|
42
64
|
input_messages = @messages[0..-2].reject { |m| m.role == :system }
|
|
43
65
|
|
|
44
66
|
unless system_messages.empty?
|
|
45
|
-
span.set_attribute("gen_ai.system_instructions", format_system_instructions(system_messages))
|
|
67
|
+
span.set_attribute("gen_ai.system_instructions", MessageFormatter.format_system_instructions(system_messages))
|
|
46
68
|
end
|
|
47
69
|
|
|
48
|
-
span.set_attribute("gen_ai.input.messages",
|
|
49
|
-
span.set_attribute("gen_ai.output.messages",
|
|
70
|
+
span.set_attribute("gen_ai.input.messages", MessageFormatter.format_input_messages(input_messages))
|
|
71
|
+
span.set_attribute("gen_ai.output.messages", MessageFormatter.format_output_messages([response]))
|
|
50
72
|
end
|
|
51
73
|
end
|
|
52
74
|
|
|
@@ -58,16 +80,28 @@ module OpenTelemetry
|
|
|
58
80
|
|
|
59
81
|
def execute_tool(tool_call)
|
|
60
82
|
attributes = {
|
|
83
|
+
"gen_ai.operation.name" => "execute_tool",
|
|
61
84
|
"gen_ai.tool.name" => tool_call.name,
|
|
62
85
|
"gen_ai.tool.call.id" => tool_call.id,
|
|
63
86
|
"gen_ai.tool.call.arguments" => tool_call.arguments.to_json,
|
|
64
|
-
"gen_ai.tool.type" => "function"
|
|
65
|
-
|
|
87
|
+
"gen_ai.tool.type" => "function",
|
|
88
|
+
"gen_ai.tool.description" => tools[tool_call.name.to_sym]&.description
|
|
89
|
+
}.compact
|
|
66
90
|
|
|
67
91
|
tracer.in_span("execute_tool #{tool_call.name}", attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::INTERNAL) do |span|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
92
|
+
begin
|
|
93
|
+
result = super
|
|
94
|
+
rescue => e
|
|
95
|
+
span.record_exception(e)
|
|
96
|
+
span.status = OpenTelemetry::Trace::Status.error(e.message)
|
|
97
|
+
span.set_attribute("error.type", e.class.name)
|
|
98
|
+
raise
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# `RubyLLM::Tool::Halt#to_s` returns `@content.to_s`, so a single
|
|
102
|
+
# `to_s` covers both the Halt and plain-result cases.
|
|
103
|
+
span.set_attribute("gen_ai.tool.call.result", result.to_s[0, tool_result_max_length])
|
|
104
|
+
|
|
71
105
|
result
|
|
72
106
|
end
|
|
73
107
|
end
|
|
@@ -81,30 +115,8 @@ module OpenTelemetry
|
|
|
81
115
|
RubyLLM::Instrumentation.instance.config[:capture_content]
|
|
82
116
|
end
|
|
83
117
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def format_message(message)
|
|
89
|
-
msg = { role: message.role.to_s, parts: [] }
|
|
90
|
-
|
|
91
|
-
if message.content
|
|
92
|
-
msg[:parts] << { type: "text", content: message.content.to_s }
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
if message.tool_calls&.any?
|
|
96
|
-
message.tool_calls.each_value do |tc|
|
|
97
|
-
msg[:parts] << { type: "tool_call", id: tc.id, name: tc.name, arguments: tc.arguments }
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
msg[:tool_call_id] = message.tool_call_id if message.tool_call_id
|
|
102
|
-
|
|
103
|
-
msg
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def format_system_instructions(system_messages)
|
|
107
|
-
system_messages.map { |m| { type: "text", content: m.content.to_s } }.to_json
|
|
118
|
+
def tool_result_max_length
|
|
119
|
+
RubyLLM::Instrumentation.instance.config[:tool_result_max_length]
|
|
108
120
|
end
|
|
109
121
|
|
|
110
122
|
def tracer
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTelemetry
|
|
4
|
+
module Instrumentation
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Patches
|
|
7
|
+
module ChatMethods
|
|
8
|
+
def to_llm(...)
|
|
9
|
+
chat = super
|
|
10
|
+
chat.otel_conversation_id = id.to_s if persisted?
|
|
11
|
+
chat
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|