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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7dafd7bb3a8ccbb99ed902b457f203d0814a155c0604ea56ef68bdff17030a9
4
- data.tar.gz: 21c19ccdf21c94db540dda4d557f3aa97406898f5dc17e4b3a18ee6d42ba9a69
3
+ metadata.gz: 41a9d9a93e7b336c7256c49918b535ee43de7fdb4f5ecdea78b03e31fad68749
4
+ data.tar.gz: 02bca3602ca2715d2c5fa4351a9dde621edf8886a521b2ffef9c94a0ce64ee50
5
5
  SHA512:
6
- metadata.gz: ab14d77a71b7a964b98d92073dd3146f6b2c4917a393b25e524e67fdb101ef14b43a58b4e72384b1c77d5f08f9f21c3bf53e6ff71c2dcb30736fbc24fda2dc4d
7
- data.tar.gz: 294d6307dbc72957bde11f5ef11b026d3575bce9794888fe788f73f3c95c06e7fbeb369feda88c5245db92974101e289c8ef0ed36c5263cb38895fd938c54317
6
+ metadata.gz: 881eec27d1ff083de355052b9bfdce866118c6a3000ec480a4e23cd7aeaf9ce33060b32c48d8f040b3cd26d071e2fa66a774c8097d053ad9616eabdc5a0f79e3
7
+ data.tar.gz: a582e32144ba0cb3781b3f5a3e1d3aa42e5109abdf0b1fd8044cba527465ad12f4ba783c4c9c305fd226bcfb848cda09214d9db96088fda5f6da83178bfd4fe4
@@ -20,6 +20,7 @@ jobs:
20
20
  - '3.1'
21
21
  gemfile:
22
22
  - gemfiles/ruby_llm_1.8.0.gemfile
23
+ - gemfiles/ruby_llm_1.12.1.gemfile
23
24
  - gemfiles/ruby_llm_1_latest.gemfile
24
25
 
25
26
  env:
data/Appraisals CHANGED
@@ -9,6 +9,10 @@ appraise "ruby_llm-1.8.0" do
9
9
  gem "ruby_llm", "1.8.0"
10
10
  end
11
11
 
12
+ appraise "ruby_llm-1.12.1" do
13
+ gem "ruby_llm", "1.12.1"
14
+ end
15
+
12
16
  appraise "ruby_llm-1-latest" do
13
17
  gem "ruby_llm", "~> 1.8"
14
18
  end
data/Gemfile CHANGED
@@ -9,8 +9,10 @@ gem "opentelemetry-sdk"
9
9
  gem "opentelemetry-exporter-otlp"
10
10
 
11
11
  group :test do
12
+ gem "activerecord"
12
13
  gem "appraisal", "~> 2.5"
13
14
  gem "minitest"
14
15
  gem "rake"
16
+ gem "sqlite3"
15
17
  gem "webmock"
16
18
  end
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`) | 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
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: "../"
@@ -7,9 +7,11 @@ gem "opentelemetry-sdk"
7
7
  gem "opentelemetry-exporter-otlp"
8
8
 
9
9
  group :test do
10
+ gem "activerecord"
10
11
  gem "appraisal", "~> 2.5"
11
12
  gem "minitest"
12
13
  gem "rake"
14
+ gem "sqlite3"
13
15
  gem "webmock"
14
16
  end
15
17
 
@@ -7,9 +7,11 @@ gem "opentelemetry-sdk"
7
7
  gem "opentelemetry-exporter-otlp"
8
8
 
9
9
  group :test do
10
+ gem "activerecord"
10
11
  gem "appraisal", "~> 2.5"
11
12
  gem "minitest"
12
13
  gem "rake"
14
+ gem "sqlite3"
13
15
  gem "webmock"
14
16
  end
15
17
 
@@ -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", format_messages(input_messages))
62
- 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]))
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..500])
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 format_messages(messages)
110
- messages.map { |m| format_message(m) }.to_json
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
@@ -3,7 +3,7 @@
3
3
  module OpenTelemetry
4
4
  module Instrumentation
5
5
  module RubyLLM
6
- VERSION = "0.6.0"
6
+ VERSION = "0.7.0"
7
7
  end
8
8
  end
9
9
  end