opentelemetry-instrumentation-ruby_llm 0.1.0 → 0.3.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: 9e85590961ea3d4ac30828dbc035d692040a2a66758ba6dab9decab2114af3f3
4
- data.tar.gz: a8a701fa288ec93109bfb6a4d3fdcc5962c40abc98d9a9269d12a9e430848f78
3
+ metadata.gz: dfb17d4d74d31d2a53792683e0d5d070cd25e9160ee3c5018c0a1dbfb5bbeb4b
4
+ data.tar.gz: 91c3b7ac277d43ddf80a6fc49e2228d8005bf40f9321cd10cd5ac2539c96dfc7
5
5
  SHA512:
6
- metadata.gz: 60672653bad15a0e24a97a60ea94433f82df8300370987f656f135a5eb1339b731afbf3644dddbe4a867b3f9970d2b757e06ca7862a4b7d0d36798dff3f9aa58
7
- data.tar.gz: d4536fbcba4b6439df8c8a4a30198072eea8cc61265ee1572a5c2b0dbd6f775260381d727d06ebc13604d755dc98f44f24f7ee552d218bb50b2695aec6ad3873
6
+ metadata.gz: df479838c253aa695c1b36b393b4f7eac31dd041a581df3d04d9f9e9ca1253d026b8582b32d2cdf398e812d53459ef0c012406654342d37a9ae45c45dc68c5e7
7
+ data.tar.gz: 9c18a05253193ce23af8bc0f342cd726778c6daccff7d1010602a63b36f20d07eb13852ea6f01d6e40bf2439236fbf4b495e32e144e701d521aaeec18972f6a4
data/.gitignore CHANGED
@@ -1 +1,2 @@
1
1
  *.gem
2
+ Gemfile.lock
data/README.md CHANGED
@@ -30,6 +30,35 @@ OpenTelemetry::SDK.configure do |c|
30
30
  end
31
31
  ```
32
32
 
33
+ ## Configuration
34
+
35
+ ### Content capture
36
+
37
+ By default, message content is **not captured**. To enable it:
38
+
39
+ ```ruby
40
+ OpenTelemetry::SDK.configure do |c|
41
+ c.use 'OpenTelemetry::Instrumentation::RubyLLM', capture_content: true
42
+ end
43
+ ```
44
+
45
+ Or set the environment variable:
46
+
47
+ ```bash
48
+ export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
49
+ ```
50
+
51
+ When enabled, the following attributes are added to chat spans:
52
+
53
+ | Attribute | Description |
54
+ |-----------|-------------|
55
+ | `gen_ai.system_instructions` | System instructions provided via `with_instructions` |
56
+ | `gen_ai.input.messages` | Input messages sent to the model |
57
+ | `gen_ai.output.messages` | Final output messages from the model |
58
+
59
+ > [!WARNING]
60
+ > Captured content may include sensitive or personally identifiable information (PII). Use with caution in production environments.
61
+
33
62
  ## What's traced?
34
63
 
35
64
  | Feature | Status |
@@ -37,9 +66,10 @@ end
37
66
  | Chat completions | Supported |
38
67
  | Tool calls | Supported |
39
68
  | Error handling | Supported |
69
+ | Opt-in input/output content capture | Supported |
40
70
  | Conversation tracking (`gen_ai.conversation.id`) | Planned |
41
- | Opt-in input/output content capture | Planned |
42
71
  | System instructions capture | Planned |
72
+ | Custom attributes on traces and spans | Planned |
43
73
  | Embeddings | Planned |
44
74
  | Streaming | Planned |
45
75
 
@@ -26,7 +26,7 @@ OpenTelemetry::SDK.configure do |c|
26
26
  )
27
27
  )
28
28
  )
29
- c.use "OpenTelemetry::Instrumentation::RubyLLM"
29
+ c.use "OpenTelemetry::Instrumentation::RubyLLM", capture_content: true
30
30
  end
31
31
 
32
32
  RubyLLM.configure do |c|
@@ -35,6 +35,7 @@ RubyLLM.configure do |c|
35
35
  end
36
36
 
37
37
  chat = RubyLLM.chat
38
+ chat.with_instructions("You are a helpful assistant that provides concise answers.")
38
39
  response = chat.ask("What is the meaning of life?")
39
40
  puts "\nResponse: #{response.content}"
40
41
 
@@ -26,7 +26,7 @@ OpenTelemetry::SDK.configure do |c|
26
26
  )
27
27
  )
28
28
  )
29
- c.use "OpenTelemetry::Instrumentation::RubyLLM"
29
+ c.use "OpenTelemetry::Instrumentation::RubyLLM", capture_content: true
30
30
  end
31
31
 
32
32
  RubyLLM.configure do |c|
@@ -44,9 +44,12 @@ class Calculator < RubyLLM::Tool
44
44
  end
45
45
 
46
46
  chat = RubyLLM.chat
47
+ chat.with_instructions("You are a helpful assistant that provides concise answers.")
47
48
  chat.with_tool(Calculator)
48
49
  response = chat.ask("Use the calculator tool to compute 123 * 456")
49
50
  puts "\nResponse: #{response.content}"
51
+ response = chat.ask("Use the tool again to compute 789 + 1011")
52
+ puts "\nResponse: #{response.content}"
50
53
 
51
54
  # This line is only necessary in short-lived scripts. In a long-running application, spans will be flushed automatically.
52
55
  OpenTelemetry.tracer_provider.force_flush
@@ -7,6 +7,8 @@ module OpenTelemetry
7
7
  instrumentation_name "OpenTelemetry::Instrumentation::RubyLLM"
8
8
  instrumentation_version VERSION
9
9
 
10
+ option :capture_content, default: false, validate: :boolean
11
+
10
12
  present do
11
13
  defined?(::RubyLLM)
12
14
  end
@@ -5,7 +5,7 @@ module OpenTelemetry
5
5
  module RubyLLM
6
6
  module Patches
7
7
  module Chat
8
- def ask(message, &block)
8
+ def complete(&)
9
9
  provider = @model&.provider || "unknown"
10
10
  model_id = @model&.id || "unknown"
11
11
 
@@ -18,22 +18,34 @@ module OpenTelemetry
18
18
  tracer.in_span("chat #{model_id}", attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::CLIENT) do |span|
19
19
  begin
20
20
  result = super
21
-
22
- if @messages.last
23
- response = @messages.last
24
- span.set_attribute("gen_ai.response.model", response.model_id) if response.model_id
25
- span.set_attribute("gen_ai.usage.input_tokens", response.input_tokens) if response.input_tokens
26
- span.set_attribute("gen_ai.usage.output_tokens", response.output_tokens) if response.output_tokens
27
- span.set_attribute("gen_ai.request.temperature", @temperature) if @temperature
28
- end
29
-
30
- result
31
21
  rescue => e
32
22
  span.record_exception(e)
33
23
  span.status = OpenTelemetry::Trace::Status.error(e.message)
34
24
  span.set_attribute("error.type", e.class.name)
35
25
  raise
36
26
  end
27
+
28
+ if @messages.last
29
+ response = @messages.last
30
+ span.set_attribute("gen_ai.response.model", response.model_id) if response.model_id
31
+ span.set_attribute("gen_ai.usage.input_tokens", response.input_tokens) if response.input_tokens
32
+ span.set_attribute("gen_ai.usage.output_tokens", response.output_tokens) if response.output_tokens
33
+ span.set_attribute("gen_ai.request.temperature", @temperature) if @temperature
34
+
35
+ if capture_content?
36
+ system_messages = @messages.select { |m| m.role == :system }
37
+ input_messages = @messages[0..-2].reject { |m| m.role == :system }
38
+
39
+ unless system_messages.empty?
40
+ span.set_attribute("gen_ai.system_instructions", format_system_instructions(system_messages))
41
+ end
42
+
43
+ span.set_attribute("gen_ai.input.messages", format_messages(input_messages))
44
+ span.set_attribute("gen_ai.output.messages", format_messages([response]))
45
+ end
46
+ end
47
+
48
+ result
37
49
  end
38
50
  rescue StandardError => e
39
51
  OpenTelemetry.handle_error(exception: e)
@@ -61,6 +73,39 @@ module OpenTelemetry
61
73
 
62
74
  private
63
75
 
76
+ def capture_content?
77
+ env_value = ENV["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"]
78
+ return env_value.to_s.strip.casecmp("true").zero? unless env_value.nil?
79
+
80
+ RubyLLM::Instrumentation.instance.config[:capture_content]
81
+ end
82
+
83
+ def format_messages(messages)
84
+ messages.map { |m| format_message(m) }.to_json
85
+ end
86
+
87
+ def format_message(message)
88
+ msg = { role: message.role.to_s, parts: [] }
89
+
90
+ if message.content
91
+ msg[:parts] << { type: "text", content: message.content.to_s }
92
+ end
93
+
94
+ if message.tool_calls&.any?
95
+ message.tool_calls.each_value do |tc|
96
+ msg[:parts] << { type: "tool_call", id: tc.id, name: tc.name, arguments: tc.arguments }
97
+ end
98
+ end
99
+
100
+ msg[:tool_call_id] = message.tool_call_id if message.tool_call_id
101
+
102
+ msg
103
+ end
104
+
105
+ def format_system_instructions(system_messages)
106
+ system_messages.map { |m| { type: "text", content: m.content.to_s } }.to_json
107
+ end
108
+
64
109
  def tracer
65
110
  RubyLLM::Instrumentation.instance.tracer
66
111
  end
@@ -3,7 +3,7 @@
3
3
  module OpenTelemetry
4
4
  module Instrumentation
5
5
  module RubyLLM
6
- VERSION = "0.1.0"
6
+ VERSION = "0.3.0"
7
7
  end
8
8
  end
9
9
  end
@@ -67,7 +67,7 @@ class InstrumentationTest < Minitest::Test
67
67
  assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code
68
68
  end
69
69
 
70
- def test_ask_still_works_when_instrumentation_fails
70
+ def test_complete_still_works_when_instrumentation_fails
71
71
  stub_request(:post, "https://api.openai.com/v1/chat/completions")
72
72
  .to_return(
73
73
  status: 200,
@@ -92,6 +92,39 @@ class InstrumentationTest < Minitest::Test
92
92
  assert_equal "Hello!", response.content
93
93
  end
94
94
 
95
+ def test_instruments_complete_called_directly
96
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
97
+ .to_return(
98
+ status: 200,
99
+ headers: { "Content-Type" => "application/json" },
100
+ body: {
101
+ id: "chatcmpl-123",
102
+ object: "chat.completion",
103
+ model: "gpt-4o-mini",
104
+ choices: [{
105
+ index: 0,
106
+ message: { role: "assistant", content: "Hello, world!" },
107
+ finish_reason: "stop"
108
+ }],
109
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
110
+ }.to_json
111
+ )
112
+
113
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
114
+ chat.add_message(role: :user, content: "Hi")
115
+ chat.complete
116
+
117
+ spans = EXPORTER.finished_spans
118
+ assert_equal 1, spans.length
119
+
120
+ span = spans.first
121
+ assert_equal "chat gpt-4o-mini", span.name
122
+ assert_equal "chat", span.attributes["gen_ai.operation.name"]
123
+ assert_equal "openai", span.attributes["gen_ai.provider.name"]
124
+ assert_equal 10, span.attributes["gen_ai.usage.input_tokens"]
125
+ assert_equal 5, span.attributes["gen_ai.usage.output_tokens"]
126
+ end
127
+
95
128
  def test_creates_span_for_tool_call
96
129
  calculator = Class.new(RubyLLM::Tool) do
97
130
  def self.name = "calculator"
@@ -155,7 +188,7 @@ class InstrumentationTest < Minitest::Test
155
188
  chat_spans = spans.select { |s| s.name.include?("chat ") }
156
189
 
157
190
  assert_equal 1, tool_spans.length
158
- assert_equal 1, chat_spans.length
191
+ assert_equal 2, chat_spans.length
159
192
 
160
193
  tool_span = tool_spans.first
161
194
  assert_equal OpenTelemetry::Trace::SpanKind::INTERNAL, tool_span.kind
@@ -228,4 +261,108 @@ class InstrumentationTest < Minitest::Test
228
261
  response = chat.ask("What is 2+2?")
229
262
  assert_equal "The answer is 4", response.content
230
263
  end
264
+
265
+ def test_does_not_capture_content_by_default
266
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
267
+ .to_return(
268
+ status: 200,
269
+ headers: { "Content-Type" => "application/json" },
270
+ body: {
271
+ id: "chatcmpl-123",
272
+ object: "chat.completion",
273
+ model: "gpt-4o-mini",
274
+ choices: [{
275
+ index: 0,
276
+ message: { role: "assistant", content: "Hello, world!" },
277
+ finish_reason: "stop"
278
+ }],
279
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
280
+ }.to_json
281
+ )
282
+
283
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
284
+ chat.with_instructions("You are helpful")
285
+ chat.ask("Hi")
286
+
287
+ span = EXPORTER.finished_spans.first
288
+ assert_nil span.attributes["gen_ai.system_instructions"]
289
+ assert_nil span.attributes["gen_ai.input.messages"]
290
+ assert_nil span.attributes["gen_ai.output.messages"]
291
+ end
292
+
293
+ def test_captures_content_when_enabled
294
+ OpenTelemetry::Instrumentation::RubyLLM::Instrumentation.instance.config[:capture_content] = true
295
+
296
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
297
+ .to_return(
298
+ status: 200,
299
+ headers: { "Content-Type" => "application/json" },
300
+ body: {
301
+ id: "chatcmpl-123",
302
+ object: "chat.completion",
303
+ model: "gpt-4o-mini",
304
+ choices: [{
305
+ index: 0,
306
+ message: { role: "assistant", content: "Hello, world!" },
307
+ finish_reason: "stop"
308
+ }],
309
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
310
+ }.to_json
311
+ )
312
+
313
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
314
+ chat.with_instructions("You are helpful")
315
+ chat.ask("Hi")
316
+
317
+ span = EXPORTER.finished_spans.first
318
+
319
+ system_instructions = JSON.parse(span.attributes["gen_ai.system_instructions"])
320
+ assert_equal [{ "type" => "text", "content" => "You are helpful" }], system_instructions
321
+
322
+ input_messages = JSON.parse(span.attributes["gen_ai.input.messages"])
323
+ assert_equal 1, input_messages.length
324
+ assert_equal "user", input_messages[0]["role"]
325
+ assert_equal [{ "type" => "text", "content" => "Hi" }], input_messages[0]["parts"]
326
+
327
+ output_messages = JSON.parse(span.attributes["gen_ai.output.messages"])
328
+ assert_equal 1, output_messages.length
329
+ assert_equal "assistant", output_messages[0]["role"]
330
+ assert_equal [{ "type" => "text", "content" => "Hello, world!" }], output_messages[0]["parts"]
331
+ ensure
332
+ OpenTelemetry::Instrumentation::RubyLLM::Instrumentation.instance.config[:capture_content] = false
333
+ end
334
+
335
+ def test_captures_content_when_enabled_via_env_var
336
+ ENV["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"
337
+
338
+ stub_request(:post, "https://api.openai.com/v1/chat/completions")
339
+ .to_return(
340
+ status: 200,
341
+ headers: { "Content-Type" => "application/json" },
342
+ body: {
343
+ id: "chatcmpl-123",
344
+ object: "chat.completion",
345
+ model: "gpt-4o-mini",
346
+ choices: [{
347
+ index: 0,
348
+ message: { role: "assistant", content: "Hello, world!" },
349
+ finish_reason: "stop"
350
+ }],
351
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
352
+ }.to_json
353
+ )
354
+
355
+ chat = RubyLLM.chat(model: "gpt-4o-mini")
356
+ chat.ask("Hi")
357
+
358
+ span = EXPORTER.finished_spans.first
359
+
360
+ input_messages = JSON.parse(span.attributes["gen_ai.input.messages"])
361
+ assert_equal "user", input_messages[0]["role"]
362
+
363
+ output_messages = JSON.parse(span.attributes["gen_ai.output.messages"])
364
+ assert_equal "assistant", output_messages[0]["role"]
365
+ ensure
366
+ ENV.delete("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT")
367
+ end
231
368
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opentelemetry-instrumentation-ruby_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Clarissa Borges
@@ -51,7 +51,6 @@ files:
51
51
  - CODEOWNERS
52
52
  - CODE_OF_CONDUCT.md
53
53
  - Gemfile
54
- - Gemfile.lock
55
54
  - LICENSE
56
55
  - README.md
57
56
  - Rakefile
data/Gemfile.lock DELETED
@@ -1,105 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- opentelemetry-instrumentation-ruby_llm (0.1.0)
5
- opentelemetry-api (~> 1.0)
6
- opentelemetry-instrumentation-base (~> 0.23)
7
-
8
- GEM
9
- remote: https://rubygems.org/
10
- specs:
11
- addressable (2.8.8)
12
- public_suffix (>= 2.0.2, < 8.0)
13
- base64 (0.3.0)
14
- bigdecimal (4.0.1)
15
- crack (1.0.1)
16
- bigdecimal
17
- rexml
18
- event_stream_parser (1.0.0)
19
- faraday (2.14.0)
20
- faraday-net_http (>= 2.0, < 3.5)
21
- json
22
- logger
23
- faraday-multipart (1.2.0)
24
- multipart-post (~> 2.0)
25
- faraday-net_http (3.4.2)
26
- net-http (~> 0.5)
27
- faraday-retry (2.4.0)
28
- faraday (~> 2.0)
29
- google-protobuf (4.33.4)
30
- bigdecimal
31
- rake (>= 13)
32
- google-protobuf (4.33.4-arm64-darwin)
33
- bigdecimal
34
- rake (>= 13)
35
- googleapis-common-protos-types (1.22.0)
36
- google-protobuf (~> 4.26)
37
- hashdiff (1.2.1)
38
- json (2.18.0)
39
- logger (1.7.0)
40
- marcel (1.1.0)
41
- minitest (6.0.1)
42
- prism (~> 1.5)
43
- multipart-post (2.4.1)
44
- net-http (0.9.1)
45
- uri (>= 0.11.1)
46
- opentelemetry-api (1.7.0)
47
- opentelemetry-common (0.23.0)
48
- opentelemetry-api (~> 1.0)
49
- opentelemetry-exporter-otlp (0.31.1)
50
- google-protobuf (>= 3.18)
51
- googleapis-common-protos-types (~> 1.3)
52
- opentelemetry-api (~> 1.1)
53
- opentelemetry-common (~> 0.20)
54
- opentelemetry-sdk (~> 1.10)
55
- opentelemetry-semantic_conventions
56
- opentelemetry-instrumentation-base (0.25.0)
57
- opentelemetry-api (~> 1.7)
58
- opentelemetry-common (~> 0.21)
59
- opentelemetry-registry (~> 0.1)
60
- opentelemetry-registry (0.4.0)
61
- opentelemetry-api (~> 1.1)
62
- opentelemetry-sdk (1.10.0)
63
- opentelemetry-api (~> 1.1)
64
- opentelemetry-common (~> 0.20)
65
- opentelemetry-registry (~> 0.2)
66
- opentelemetry-semantic_conventions
67
- opentelemetry-semantic_conventions (1.36.0)
68
- opentelemetry-api (~> 1.0)
69
- prism (1.9.0)
70
- public_suffix (7.0.2)
71
- rake (13.3.1)
72
- rexml (3.4.4)
73
- ruby_llm (1.11.0)
74
- base64
75
- event_stream_parser (~> 1)
76
- faraday (>= 1.10.0)
77
- faraday-multipart (>= 1)
78
- faraday-net_http (>= 1)
79
- faraday-retry (>= 1)
80
- marcel (~> 1.0)
81
- ruby_llm-schema (~> 0.2.1)
82
- zeitwerk (~> 2)
83
- ruby_llm-schema (0.2.5)
84
- uri (1.1.1)
85
- webmock (3.26.1)
86
- addressable (>= 2.8.0)
87
- crack (>= 0.3.2)
88
- hashdiff (>= 0.4.0, < 2.0.0)
89
- zeitwerk (2.7.4)
90
-
91
- PLATFORMS
92
- arm64-darwin-24
93
- ruby
94
-
95
- DEPENDENCIES
96
- minitest
97
- opentelemetry-exporter-otlp
98
- opentelemetry-instrumentation-ruby_llm!
99
- opentelemetry-sdk
100
- rake
101
- ruby_llm
102
- webmock
103
-
104
- BUNDLED WITH
105
- 2.7.1