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.
@@ -0,0 +1,195 @@
1
+ require "active_record"
2
+ require_relative "test_helper"
3
+
4
+ gem_dir = Gem.loaded_specs.fetch("ruby_llm").full_gem_path
5
+ Dir[File.join(gem_dir, "lib/ruby_llm/active_record/*.rb")].sort.each { |file| require file }
6
+
7
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
8
+ ActiveRecord::Base.include RubyLLM::ActiveRecord::ActsAs
9
+
10
+ RubyLLM::Models.class_eval do
11
+ if method_defined?(:load_models)
12
+ def load_models
13
+ read_from_json
14
+ end
15
+ else
16
+ def self.load_models(file = RubyLLM.config.model_registry_file)
17
+ read_from_json(file)
18
+ end
19
+ end
20
+ end
21
+
22
+ ActiveRecord::Schema.verbose = false
23
+ ActiveRecord::Schema.define do
24
+ create_table :models do |t|
25
+ t.string :model_id, null: false
26
+ t.string :name, null: false
27
+ t.string :provider, null: false
28
+ t.string :family
29
+ t.datetime :model_created_at
30
+ t.integer :context_window
31
+ t.integer :max_output_tokens
32
+ t.date :knowledge_cutoff
33
+ t.json :modalities, default: {}
34
+ t.json :capabilities, default: []
35
+ t.json :pricing, default: {}
36
+ t.json :metadata, default: {}
37
+ t.timestamps
38
+ end
39
+
40
+ create_table :chats do |t|
41
+ t.references :model
42
+ t.timestamps
43
+ end
44
+
45
+ create_table :messages do |t|
46
+ t.string :role, null: false
47
+ t.text :content
48
+ t.json :content_raw
49
+ t.text :thinking_text
50
+ t.text :thinking_signature
51
+ t.integer :thinking_tokens
52
+ t.integer :input_tokens
53
+ t.integer :output_tokens
54
+ t.integer :cached_tokens
55
+ t.integer :cache_creation_tokens
56
+ t.references :chat, null: false
57
+ t.references :model
58
+ t.references :tool_call
59
+ t.timestamps
60
+ end
61
+
62
+ create_table :tool_calls do |t|
63
+ t.string :tool_call_id, null: false
64
+ t.string :name, null: false
65
+ t.text :thought_signature
66
+ t.json :arguments, default: {}
67
+ t.references :message, null: false
68
+ t.timestamps
69
+ end
70
+ end
71
+
72
+ class Model < ActiveRecord::Base
73
+ acts_as_model
74
+ end
75
+
76
+ class Chat < ActiveRecord::Base
77
+ acts_as_chat
78
+ end
79
+
80
+ class Message < ActiveRecord::Base
81
+ acts_as_message
82
+ end
83
+
84
+ class ToolCall < ActiveRecord::Base
85
+ acts_as_tool_call
86
+ end
87
+
88
+ if defined?(RubyLLM::Agent)
89
+ class SupportAgent < RubyLLM::Agent
90
+ end
91
+ end
92
+
93
+ class ActiveRecordChatMethodsTest < Minitest::Test
94
+ include ChatCompletionStubs
95
+
96
+ def setup
97
+ EXPORTER.reset
98
+
99
+ RubyLLM.configure do |c|
100
+ c.openai_api_key = "fake-key-for-testing"
101
+ end
102
+ end
103
+
104
+ def find_or_create_model
105
+ Model.find_or_create_by!(model_id: "gpt-4o-mini", provider: "openai") do |m|
106
+ m.name = "GPT-4o Mini"
107
+ end
108
+ end
109
+
110
+ def create_chat_record
111
+ Chat.create!(model: find_or_create_model)
112
+ end
113
+
114
+ def find_chat_span
115
+ EXPORTER.finished_spans.find { |s| s.name.start_with?("chat ") }
116
+ end
117
+
118
+ def find_agent_span
119
+ EXPORTER.finished_spans.find { |s| s.name.start_with?("invoke_agent") }
120
+ end
121
+
122
+ def test_persisted_record_ask_stamps_conversation_id_on_chat_span
123
+ stub_chat_completion
124
+
125
+ chat_record = create_chat_record
126
+ chat_record.ask("Hi")
127
+
128
+ assert_equal chat_record.id.to_s, find_chat_span.attributes["gen_ai.conversation.id"]
129
+ end
130
+
131
+ def test_plain_chat_span_carries_no_conversation_id
132
+ stub_chat_completion
133
+
134
+ RubyLLM.chat(model: "gpt-4o-mini").ask("Hi")
135
+
136
+ assert_nil find_chat_span.attributes["gen_ai.conversation.id"]
137
+ end
138
+
139
+ def test_unpersisted_record_chat_is_not_stamped_until_persisted
140
+ chat_record = Chat.new(model: find_or_create_model)
141
+
142
+ assert_nil chat_record.to_llm.otel_conversation_id
143
+
144
+ chat_record.save!
145
+
146
+ assert_equal chat_record.id.to_s, chat_record.to_llm.otel_conversation_id
147
+ end
148
+
149
+ def test_user_otel_attributes_are_preserved_alongside_conversation_id
150
+ stub_chat_completion
151
+
152
+ chat_record = create_chat_record
153
+ chat_record.to_llm.with_otel_attributes("enduser.id" => "user-1")
154
+ chat_record.ask("Hi")
155
+
156
+ span = find_chat_span
157
+ assert_equal "user-1", span.attributes["enduser.id"]
158
+ assert_equal chat_record.id.to_s, span.attributes["gen_ai.conversation.id"]
159
+ end
160
+
161
+ def test_user_supplied_conversation_id_wins_over_record_id
162
+ stub_chat_completion
163
+
164
+ chat_record = create_chat_record
165
+ chat_record.to_llm.with_otel_attributes("gen_ai.conversation.id" => "custom-id")
166
+ chat_record.ask("Hi")
167
+
168
+ assert_equal "custom-id", find_chat_span.attributes["gen_ai.conversation.id"]
169
+ end
170
+
171
+ if defined?(RubyLLM::Agent)
172
+ def test_agent_flow_carries_conversation_id_on_root_and_chat_spans
173
+ stub_chat_completion
174
+
175
+ chat_record = create_chat_record
176
+ agent = SupportAgent.new(chat: chat_record)
177
+ agent.ask("Hi")
178
+
179
+ assert_equal chat_record.id.to_s, find_agent_span.attributes["gen_ai.conversation.id"]
180
+ assert_equal chat_record.id.to_s, find_chat_span.attributes["gen_ai.conversation.id"]
181
+ end
182
+
183
+ def test_user_supplied_conversation_id_wins_on_agent_and_chat_spans
184
+ stub_chat_completion
185
+
186
+ chat_record = create_chat_record
187
+ chat_record.to_llm.with_otel_attributes("gen_ai.conversation.id" => "custom-id")
188
+ agent = SupportAgent.new(chat: chat_record)
189
+ agent.ask("Hi")
190
+
191
+ assert_equal "custom-id", find_agent_span.attributes["gen_ai.conversation.id"]
192
+ assert_equal "custom-id", find_chat_span.attributes["gen_ai.conversation.id"]
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,245 @@
1
+ require "test_helper"
2
+
3
+ if defined?(RubyLLM::Agent)
4
+ class ResearchAgent < RubyLLM::Agent
5
+ model "gpt-4o-mini"
6
+ end
7
+
8
+ class AgentInstrumentationTest < Minitest::Test
9
+ include ChatCompletionStubs
10
+
11
+ def setup
12
+ EXPORTER.reset
13
+
14
+ RubyLLM.configure do |c|
15
+ c.openai_api_key = "fake-key-for-testing"
16
+ end
17
+ end
18
+
19
+ def test_wraps_ask_in_invoke_agent_span_with_chat_span_as_child
20
+ stub_chat_completion
21
+
22
+ agent = ResearchAgent.new
23
+ agent.ask("Hi")
24
+
25
+ spans = EXPORTER.finished_spans
26
+ agent_span = spans.find { |s| s.name.start_with?("invoke_agent") }
27
+ chat_span = spans.find { |s| s.name.start_with?("chat ") }
28
+
29
+ assert_equal "invoke_agent ResearchAgent", agent_span.name
30
+ assert_equal OpenTelemetry::Trace::SpanKind::INTERNAL, agent_span.kind
31
+ assert_equal "invoke_agent", agent_span.attributes["gen_ai.operation.name"]
32
+ assert_equal "ResearchAgent", agent_span.attributes["gen_ai.agent.name"]
33
+ assert_equal agent_span.span_id, chat_span.parent_span_id
34
+ assert_equal agent_span.trace_id, chat_span.trace_id
35
+ end
36
+
37
+ def test_wraps_complete_called_directly_in_invoke_agent_span
38
+ stub_chat_completion
39
+
40
+ agent = ResearchAgent.new
41
+ agent.add_message(role: :user, content: "Hi")
42
+ agent.complete
43
+
44
+ spans = EXPORTER.finished_spans
45
+ agent_span = spans.find { |s| s.name.start_with?("invoke_agent") }
46
+ chat_span = spans.find { |s| s.name.start_with?("chat ") }
47
+
48
+ assert_equal "invoke_agent ResearchAgent", agent_span.name
49
+ assert_equal agent_span.span_id, chat_span.parent_span_id
50
+ end
51
+
52
+ def test_omits_agent_name_for_anonymous_agent_classes
53
+ stub_chat_completion
54
+
55
+ agent_class = Class.new(RubyLLM::Agent) do
56
+ model "gpt-4o-mini"
57
+ end
58
+
59
+ agent_class.new.ask("Hi")
60
+
61
+ agent_span = EXPORTER.finished_spans.find { |s| s.name.start_with?("invoke_agent") }
62
+
63
+ assert_equal "invoke_agent", agent_span.name
64
+ assert_nil agent_span.attributes["gen_ai.agent.name"]
65
+ end
66
+
67
+ def test_sets_conversation_id_stamped_on_the_chat
68
+ stub_chat_completion
69
+
70
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
71
+ chat.otel_conversation_id = "42"
72
+
73
+ agent = ResearchAgent.new(chat: chat)
74
+ agent.ask("Hi")
75
+
76
+ agent_span = EXPORTER.finished_spans.find { |s| s.name.start_with?("invoke_agent") }
77
+ assert_equal "42", agent_span.attributes["gen_ai.conversation.id"]
78
+ end
79
+
80
+ def test_omits_conversation_id_for_unpersisted_chats
81
+ stub_chat_completion
82
+
83
+ ResearchAgent.new.ask("Hi")
84
+
85
+ agent_span = EXPORTER.finished_spans.find { |s| s.name.start_with?("invoke_agent") }
86
+ assert_nil agent_span.attributes["gen_ai.conversation.id"]
87
+ end
88
+
89
+ def test_records_error_on_agent_span_when_ask_fails
90
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
91
+ .to_return(status: 500, body: "Internal Server Error")
92
+
93
+ agent = ResearchAgent.new
94
+
95
+ assert_raises do
96
+ agent.ask("Hi")
97
+ end
98
+
99
+ agent_span = EXPORTER.finished_spans.find { |s| s.name.start_with?("invoke_agent") }
100
+ assert agent_span.attributes["error.type"]
101
+ assert_equal OpenTelemetry::Trace::Status::ERROR, agent_span.status.code
102
+
103
+ exception_events = agent_span.events.select { |e| e.name == "exception" }
104
+ assert_equal 1, exception_events.length
105
+ end
106
+
107
+ def test_sets_otel_attributes_on_agent_span_when_ask_fails
108
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
109
+ .to_return(status: 500, body: "Internal Server Error")
110
+
111
+ agent = ResearchAgent.new
112
+ agent.with_otel_attributes("langfuse.session.id" => "session-1")
113
+
114
+ assert_raises do
115
+ agent.ask("Hi")
116
+ end
117
+
118
+ agent_span = EXPORTER.finished_spans.find { |s| s.name.start_with?("invoke_agent") }
119
+ assert_equal "session-1", agent_span.attributes["langfuse.session.id"]
120
+ end
121
+
122
+ def test_with_otel_attributes_sets_attributes_on_agent_and_chat_spans
123
+ stub_chat_completion
124
+
125
+ agent = ResearchAgent.new
126
+ result = agent.with_otel_attributes("langfuse.session.id" => "session-1")
127
+ agent.ask("Hi")
128
+
129
+ assert_same agent, result
130
+
131
+ spans = EXPORTER.finished_spans
132
+ agent_span = spans.find { |s| s.name.start_with?("invoke_agent") }
133
+ chat_span = spans.find { |s| s.name.start_with?("chat ") }
134
+
135
+ assert_equal "session-1", agent_span.attributes["langfuse.session.id"]
136
+ assert_equal "session-1", chat_span.attributes["langfuse.session.id"]
137
+ end
138
+
139
+ def test_with_otel_attributes_forwards_through_to_llm_for_chat_records
140
+ stub_chat_completion
141
+
142
+ llm_chat = RubyLLM.chat(model: "gpt-4o-mini")
143
+ llm_chat.otel_conversation_id = "7"
144
+ record = Object.new
145
+ record.define_singleton_method(:to_llm) { llm_chat }
146
+ record.define_singleton_method(:ask) { |message| llm_chat.ask(message) }
147
+
148
+ agent = ResearchAgent.new(chat: record)
149
+ agent.with_otel_attributes("user.id" => "u1")
150
+ agent.ask("Hi")
151
+
152
+ spans = EXPORTER.finished_spans
153
+ agent_span = spans.find { |s| s.name.start_with?("invoke_agent") }
154
+ chat_span = spans.find { |s| s.name.start_with?("chat ") }
155
+
156
+ assert_equal "7", agent_span.attributes["gen_ai.conversation.id"]
157
+ assert_equal "u1", agent_span.attributes["user.id"]
158
+ assert_equal "u1", chat_span.attributes["user.id"]
159
+ end
160
+
161
+ def test_wraps_say_in_invoke_agent_span
162
+ stub_chat_completion
163
+
164
+ ResearchAgent.new.say("Hi")
165
+
166
+ agent_span = EXPORTER.finished_spans.find { |s| s.name.start_with?("invoke_agent") }
167
+ assert_equal "invoke_agent ResearchAgent", agent_span.name
168
+ end
169
+
170
+ def test_creates_one_agent_span_for_ask_with_tool_loop
171
+ calculator = Class.new(RubyLLM::Tool) do
172
+ def self.name = "calculator"
173
+ description "Performs math"
174
+ param :expression, type: "string", desc: "Math expression"
175
+
176
+ def execute(expression:)
177
+ eval(expression).to_s
178
+ end
179
+ end
180
+
181
+ stub_chat_completion(
182
+ chat_completion_body(
183
+ content: nil,
184
+ tool_calls: [{
185
+ id: "call_abc123",
186
+ type: "function",
187
+ function: { name: "calculator", arguments: '{"expression":"2+2"}' }
188
+ }]
189
+ ),
190
+ chat_completion_body(
191
+ content: "The answer is 4",
192
+ usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 }
193
+ )
194
+ )
195
+
196
+ agent = ResearchAgent.new
197
+ agent.with_tool(calculator)
198
+ agent.ask("What is 2+2?")
199
+
200
+ spans = EXPORTER.finished_spans
201
+ agent_spans = spans.select { |s| s.name.start_with?("invoke_agent") }
202
+ chat_spans = spans.select { |s| s.name.start_with?("chat ") }
203
+ tool_spans = spans.select { |s| s.name.start_with?("execute_tool ") }
204
+
205
+ assert_equal 1, agent_spans.length
206
+ assert_equal 2, chat_spans.length
207
+ assert_equal 1, tool_spans.length
208
+
209
+ agent_span = agent_spans.first
210
+ assert(chat_spans.all? { |s| s.trace_id == agent_span.trace_id })
211
+ assert_equal agent_span.trace_id, tool_spans.first.trace_id
212
+ end
213
+
214
+ def test_captures_input_and_output_on_agent_span_when_enabled
215
+ OpenTelemetry::Instrumentation::RubyLLM::Instrumentation.instance.config[:capture_content] = true
216
+ stub_chat_completion
217
+
218
+ ResearchAgent.new.ask("Hi")
219
+
220
+ agent_span = EXPORTER.finished_spans.find { |s| s.name.start_with?("invoke_agent") }
221
+
222
+ input_messages = JSON.parse(agent_span.attributes["gen_ai.input.messages"])
223
+ assert_equal 1, input_messages.length
224
+ assert_equal "user", input_messages[0]["role"]
225
+ assert_equal [{ "type" => "text", "content" => "Hi" }], input_messages[0]["parts"]
226
+
227
+ output_messages = JSON.parse(agent_span.attributes["gen_ai.output.messages"])
228
+ assert_equal 1, output_messages.length
229
+ assert_equal "assistant", output_messages[0]["role"]
230
+ assert_equal [{ "type" => "text", "content" => "Hello, world!" }], output_messages[0]["parts"]
231
+ ensure
232
+ OpenTelemetry::Instrumentation::RubyLLM::Instrumentation.instance.config[:capture_content] = false
233
+ end
234
+
235
+ def test_does_not_capture_content_on_agent_span_by_default
236
+ stub_chat_completion
237
+
238
+ ResearchAgent.new.ask("Hi")
239
+
240
+ agent_span = EXPORTER.finished_spans.find { |s| s.name.start_with?("invoke_agent") }
241
+ assert_nil agent_span.attributes["gen_ai.input.messages"]
242
+ assert_nil agent_span.attributes["gen_ai.output.messages"]
243
+ end
244
+ end
245
+ end