opentelemetry-instrumentation-ruby_llm 0.6.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 +1 -0
- data/Appraisals +4 -0
- data/Gemfile +2 -0
- data/README.md +92 -1
- data/gemfiles/ruby_llm_1.12.1.gemfile +18 -0
- data/gemfiles/ruby_llm_1.8.0.gemfile +2 -0
- data/gemfiles/ruby_llm_1_latest.gemfile +2 -0
- data/lib/opentelemetry/instrumentation/ruby_llm/instrumentation.rb +26 -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 +15 -28
- 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 +194 -236
- data/test/test_helper.rb +30 -0
- metadata +7 -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
data/Appraisals
CHANGED
data/Gemfile
CHANGED
data/README.md
CHANGED
|
@@ -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,15 +95,95 @@ 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
189
|
| Embeddings | Supported |
|
|
@@ -106,6 +196,7 @@ This gem follows the [OpenTelemetry GenAI Semantic Conventions](https://opentele
|
|
|
106
196
|
This gem is tested against the following `ruby_llm` versions:
|
|
107
197
|
|
|
108
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)
|
|
109
200
|
- `~> 1.8` (latest 1.x release)
|
|
110
201
|
|
|
111
202
|
The Ruby matrix covers Ruby 3.1, 3.2, 3.3, and 3.4.
|
|
@@ -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: "../"
|
|
@@ -5,11 +5,13 @@ module OpenTelemetry
|
|
|
5
5
|
module RubyLLM
|
|
6
6
|
class Instrumentation < OpenTelemetry::Instrumentation::Base
|
|
7
7
|
MINIMUM_RUBY_LLM_VERSION = "1.8.0"
|
|
8
|
+
AGENT_MINIMUM_RUBY_LLM_VERSION = "1.12.1"
|
|
8
9
|
|
|
9
10
|
instrumentation_name "OpenTelemetry::Instrumentation::RubyLLM"
|
|
10
11
|
instrumentation_version VERSION
|
|
11
12
|
|
|
12
13
|
option :capture_content, default: false, validate: :boolean
|
|
14
|
+
option :tool_result_max_length, default: 500, validate: :integer
|
|
13
15
|
|
|
14
16
|
present do
|
|
15
17
|
defined?(::RubyLLM)
|
|
@@ -32,10 +34,34 @@ module OpenTelemetry
|
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
install do |_config|
|
|
37
|
+
require_relative "message_formatter"
|
|
35
38
|
require_relative "patches/chat"
|
|
36
39
|
require_relative "patches/embedding"
|
|
37
40
|
::RubyLLM::Chat.prepend(Patches::Chat)
|
|
38
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
|
|
39
65
|
end
|
|
40
66
|
end
|
|
41
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,8 @@ 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
|
|
22
31
|
# Per GenAI semconv: set `gen_ai.request.stream` if and only if
|
|
23
32
|
# the request is streaming. Absence means non-streaming.
|
|
24
33
|
attributes["gen_ai.request.stream"] = true if block_given?
|
|
@@ -55,11 +64,11 @@ module OpenTelemetry
|
|
|
55
64
|
input_messages = @messages[0..-2].reject { |m| m.role == :system }
|
|
56
65
|
|
|
57
66
|
unless system_messages.empty?
|
|
58
|
-
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))
|
|
59
68
|
end
|
|
60
69
|
|
|
61
|
-
span.set_attribute("gen_ai.input.messages",
|
|
62
|
-
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]))
|
|
63
72
|
end
|
|
64
73
|
end
|
|
65
74
|
|
|
@@ -91,7 +100,7 @@ module OpenTelemetry
|
|
|
91
100
|
|
|
92
101
|
# `RubyLLM::Tool::Halt#to_s` returns `@content.to_s`, so a single
|
|
93
102
|
# `to_s` covers both the Halt and plain-result cases.
|
|
94
|
-
span.set_attribute("gen_ai.tool.call.result", result.to_s[0
|
|
103
|
+
span.set_attribute("gen_ai.tool.call.result", result.to_s[0, tool_result_max_length])
|
|
95
104
|
|
|
96
105
|
result
|
|
97
106
|
end
|
|
@@ -106,30 +115,8 @@ module OpenTelemetry
|
|
|
106
115
|
RubyLLM::Instrumentation.instance.config[:capture_content]
|
|
107
116
|
end
|
|
108
117
|
|
|
109
|
-
def
|
|
110
|
-
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def format_message(message)
|
|
114
|
-
msg = { role: message.role.to_s, parts: [] }
|
|
115
|
-
|
|
116
|
-
if message.content
|
|
117
|
-
msg[:parts] << { type: "text", content: message.content.to_s }
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
if message.tool_calls&.any?
|
|
121
|
-
message.tool_calls.each_value do |tc|
|
|
122
|
-
msg[:parts] << { type: "tool_call", id: tc.id, name: tc.name, arguments: tc.arguments }
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
msg[:tool_call_id] = message.tool_call_id if message.tool_call_id
|
|
127
|
-
|
|
128
|
-
msg
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def format_system_instructions(system_messages)
|
|
132
|
-
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]
|
|
133
120
|
end
|
|
134
121
|
|
|
135
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
|