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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07574aa081ca21455a0e4384ee35109310b70f878be921a6567b3126e9809602
4
- data.tar.gz: 5a42c5a5f695c9b72cdf6a29caf5cd8d8ad8cf06895aebc0e170b9b4a02892c3
3
+ metadata.gz: 41a9d9a93e7b336c7256c49918b535ee43de7fdb4f5ecdea78b03e31fad68749
4
+ data.tar.gz: 02bca3602ca2715d2c5fa4351a9dde621edf8886a521b2ffef9c94a0ce64ee50
5
5
  SHA512:
6
- metadata.gz: 27d39b8ca21c044fc793c4e4d4b093b3b71d8010aecc52f518da5687943c7819f4eba144e1b4d10de889ab668344b0c952cbca5ac6f67e69e0110701b7f771ad
7
- data.tar.gz: db4b50b51fb6f93a60cdee7738d9dbc181e3d18aec04fc0e6dda1a684b8e0c887be67b6bb1636c3f67580aa4decc3077b926128b40f45c216d8c4bd52ea99c7f
6
+ metadata.gz: 881eec27d1ff083de355052b9bfdce866118c6a3000ec480a4e23cd7aeaf9ce33060b32c48d8f040b3cd26d071e2fa66a774c8097d053ad9616eabdc5a0f79e3
7
+ data.tar.gz: a582e32144ba0cb3781b3f5a3e1d3aa42e5109abdf0b1fd8044cba527465ad12f4ba783c4c9c305fd226bcfb848cda09214d9db96088fda5f6da83178bfd4fe4
@@ -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
@@ -1,2 +1,3 @@
1
1
  *.gem
2
2
  Gemfile.lock
3
+ gemfiles/*.gemfile.lock
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
@@ -9,7 +9,10 @@ gem "opentelemetry-sdk"
9
9
  gem "opentelemetry-exporter-otlp"
10
10
 
11
11
  group :test do
12
+ gem "activerecord"
13
+ gem "appraisal", "~> 2.5"
12
14
  gem "minitest"
13
- gem "webmock"
14
15
  gem "rake"
16
+ gem "sqlite3"
17
+ gem "webmock"
15
18
  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,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`) | Planned |
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 | Planned |
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", format_messages(input_messages))
49
- span.set_attribute("gen_ai.output.messages", format_messages([response]))
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
- result = super
69
- result_str = result.is_a?(::RubyLLM::Tool::Halt) ? result.content.to_s : result.to_s
70
- span.set_attribute("gen_ai.tool.call.result", result_str[0..500])
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 format_messages(messages)
85
- messages.map { |m| format_message(m) }.to_json
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
@@ -3,7 +3,7 @@
3
3
  module OpenTelemetry
4
4
  module Instrumentation
5
5
  module RubyLLM
6
- VERSION = "0.5.0"
6
+ VERSION = "0.7.0"
7
7
  end
8
8
  end
9
9
  end