opentelemetry-instrumentation-ruby_llm 0.4.0 → 0.5.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/README.md +28 -2
- data/example/trace_demonstration_with_langfuse.rb +4 -0
- data/example/trace_demonstration_with_langfuse_and_tools.rb +4 -0
- data/example/trace_demonstration_with_langfuse_ingredient_search.rb +110 -0
- data/lib/opentelemetry/instrumentation/ruby_llm/patches/chat.rb +7 -0
- data/lib/opentelemetry/instrumentation/ruby_llm/version.rb +1 -1
- data/test/instrumentation_test.rb +108 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07574aa081ca21455a0e4384ee35109310b70f878be921a6567b3126e9809602
|
|
4
|
+
data.tar.gz: 5a42c5a5f695c9b72cdf6a29caf5cd8d8ad8cf06895aebc0e170b9b4a02892c3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 27d39b8ca21c044fc793c4e4d4b093b3b71d8010aecc52f518da5687943c7819f4eba144e1b4d10de889ab668344b0c952cbca5ac6f67e69e0110701b7f771ad
|
|
7
|
+
data.tar.gz: db4b50b51fb6f93a60cdee7738d9dbc181e3d18aec04fc0e6dda1a684b8e0c887be67b6bb1636c3f67580aa4decc3077b926128b40f45c216d8c4bd52ea99c7f
|
data/README.md
CHANGED
|
@@ -59,6 +59,32 @@ 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
|
+
### Custom attributes
|
|
63
|
+
|
|
64
|
+
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:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
chat = RubyLLM.chat
|
|
68
|
+
chat.with_otel_attributes(
|
|
69
|
+
"langfuse.observation.prompt.name" => "supplement-assistant",
|
|
70
|
+
"langfuse.observation.prompt.version" => 1,
|
|
71
|
+
"langfuse.trace.tags" => ["vitamins"],
|
|
72
|
+
"langfuse.trace.metadata" => { category: "health" }.to_json
|
|
73
|
+
)
|
|
74
|
+
chat.ask("What are the side effects of Vitamin D3?")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Values can also be callables (Procs/lambdas) that are evaluated after each completion, giving access to response data:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
chat.with_otel_attributes(
|
|
81
|
+
"langfuse.observation.prompt.name" => "supplement-assistant",
|
|
82
|
+
"langfuse.observation.output" => -> { chat.messages.last&.content.to_s }
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Attributes persist across calls on the same chat instance and the method returns `self` for chaining.
|
|
87
|
+
|
|
62
88
|
## What's traced?
|
|
63
89
|
|
|
64
90
|
| Feature | Status |
|
|
@@ -68,8 +94,8 @@ When enabled, the following attributes are added to chat spans:
|
|
|
68
94
|
| Error handling | Supported |
|
|
69
95
|
| Opt-in input/output content capture | Supported |
|
|
70
96
|
| Conversation tracking (`gen_ai.conversation.id`) | Planned |
|
|
71
|
-
| System instructions capture |
|
|
72
|
-
| Custom attributes on traces and spans |
|
|
97
|
+
| System instructions capture | Supported (via `capture_content`) |
|
|
98
|
+
| Custom attributes on traces and spans | Supported (via `with_otel_attributes`) |
|
|
73
99
|
| Embeddings | Planned |
|
|
74
100
|
| Streaming | Planned |
|
|
75
101
|
|
|
@@ -36,6 +36,10 @@ end
|
|
|
36
36
|
|
|
37
37
|
chat = RubyLLM.chat
|
|
38
38
|
chat.with_instructions("You are a helpful assistant that provides concise answers.")
|
|
39
|
+
chat.with_otel_attributes(
|
|
40
|
+
"langfuse.observation.prompt.name" => "helpful-assistant",
|
|
41
|
+
"langfuse.observation.prompt.version" => 1
|
|
42
|
+
)
|
|
39
43
|
response = chat.ask("What is the meaning of life?")
|
|
40
44
|
puts "\nResponse: #{response.content}"
|
|
41
45
|
|
|
@@ -46,6 +46,10 @@ end
|
|
|
46
46
|
chat = RubyLLM.chat
|
|
47
47
|
chat.with_instructions("You are a helpful assistant that provides concise answers.")
|
|
48
48
|
chat.with_tool(Calculator)
|
|
49
|
+
chat.with_otel_attributes(
|
|
50
|
+
"langfuse.observation.prompt.name" => "helpful-assistant",
|
|
51
|
+
"langfuse.observation.prompt.version" => 1
|
|
52
|
+
)
|
|
49
53
|
response = chat.ask("Use the calculator tool to compute 123 * 456")
|
|
50
54
|
puts "\nResponse: #{response.content}"
|
|
51
55
|
response = chat.ask("Use the tool again to compute 789 + 1011")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/inline"
|
|
4
|
+
|
|
5
|
+
gemfile(true) do
|
|
6
|
+
source "https://rubygems.org"
|
|
7
|
+
gem "ruby_llm"
|
|
8
|
+
gem "opentelemetry-api"
|
|
9
|
+
gem "opentelemetry-sdk"
|
|
10
|
+
gem "opentelemetry-exporter-otlp"
|
|
11
|
+
gem "opentelemetry-instrumentation-ruby_llm", path: "../"
|
|
12
|
+
gem "base64"
|
|
13
|
+
gem "dotenv"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
require "base64"
|
|
17
|
+
require "dotenv/load"
|
|
18
|
+
|
|
19
|
+
credentials = Base64.strict_encode64("#{ENV['LANGFUSE_PUBLIC_KEY']}:#{ENV['LANGFUSE_SECRET_KEY']}")
|
|
20
|
+
|
|
21
|
+
OpenTelemetry::SDK.configure do |c|
|
|
22
|
+
c.service_name = "ruby_llm-demo"
|
|
23
|
+
c.add_span_processor(
|
|
24
|
+
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
25
|
+
OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
26
|
+
endpoint: "https://us.cloud.langfuse.com/api/public/otel/v1/traces",
|
|
27
|
+
headers: { "Authorization" => "Basic #{credentials}" }
|
|
28
|
+
)
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
c.use "OpenTelemetry::Instrumentation::RubyLLM", capture_content: true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
RubyLLM.configure do |c|
|
|
35
|
+
c.openai_api_key = ENV["OPENAI_API_KEY"]
|
|
36
|
+
c.default_model = "gpt-5-nano"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
INGREDIENT_DATABASE = {
|
|
40
|
+
"vitamin d3" => {
|
|
41
|
+
name: "Vitamin D3 (Cholecalciferol)",
|
|
42
|
+
common_doses: "1,000-5,000 IU daily",
|
|
43
|
+
side_effects: ["Nausea", "Vomiting", "Constipation", "Loss of appetite", "Excessive thirst", "Frequent urination", "Kidney stones (at very high doses)"],
|
|
44
|
+
interactions: ["Corticosteroids", "Orlistat", "Statins", "Thiazide diuretics"],
|
|
45
|
+
notes: "Fat-soluble vitamin. Toxicity risk at sustained doses above 10,000 IU/day."
|
|
46
|
+
},
|
|
47
|
+
"magnesium glycinate" => {
|
|
48
|
+
name: "Magnesium Glycinate",
|
|
49
|
+
common_doses: "200-400 mg daily",
|
|
50
|
+
side_effects: ["Diarrhea", "Nausea", "Abdominal cramping"],
|
|
51
|
+
interactions: ["Antibiotics (tetracyclines, quinolones)", "Bisphosphonates", "Diuretics"],
|
|
52
|
+
notes: "Better absorbed and gentler on the stomach than magnesium oxide."
|
|
53
|
+
},
|
|
54
|
+
"zinc" => {
|
|
55
|
+
name: "Zinc",
|
|
56
|
+
common_doses: "15-30 mg daily",
|
|
57
|
+
side_effects: ["Nausea", "Metallic taste", "Headache", "Copper deficiency (long-term use)"],
|
|
58
|
+
interactions: ["Antibiotics", "Penicillamine", "Copper supplements"],
|
|
59
|
+
notes: "Best taken with food to reduce nausea. Long-term use above 40 mg/day may deplete copper."
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class SearchForIngredientDetails < RubyLLM::Tool
|
|
64
|
+
description "Searches a database for detailed information about a supplement ingredient, including side effects, interactions, and dosage"
|
|
65
|
+
param :ingredient_name, type: "string", desc: "The name of the ingredient to search for (e.g., 'vitamin d3', 'magnesium glycinate')"
|
|
66
|
+
|
|
67
|
+
def execute(ingredient_name:)
|
|
68
|
+
key = ingredient_name.downcase.strip
|
|
69
|
+
match = INGREDIENT_DATABASE.find { |k, _| key.include?(k) || k.include?(key) }
|
|
70
|
+
|
|
71
|
+
if match
|
|
72
|
+
_, details = match
|
|
73
|
+
details.map { |k, v| "#{k}: #{Array(v).join(', ')}" }.join("\n")
|
|
74
|
+
else
|
|
75
|
+
"No information found for '#{ingredient_name}'. Available ingredients: #{INGREDIENT_DATABASE.keys.join(', ')}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
chat = RubyLLM.chat
|
|
81
|
+
chat.with_instructions("You are a knowledgeable health supplement assistant. Use the search tool to look up ingredient details before answering questions.")
|
|
82
|
+
chat.with_tool(SearchForIngredientDetails)
|
|
83
|
+
|
|
84
|
+
questions = [
|
|
85
|
+
{ text: "What are the side effects of Vitamin D3?", ingredient: "vitamin d3" },
|
|
86
|
+
{ text: "What are the common interactions with magnesium glycinate?", ingredient: "magnesium glycinate" },
|
|
87
|
+
{ text: "What is the recommended dosage for zinc?", ingredient: "zinc" },
|
|
88
|
+
{ text: "Are there any interactions I should be aware of with zinc?", ingredient: "zinc" }
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
questions.each do |q|
|
|
92
|
+
puts "\n---\n\n"
|
|
93
|
+
puts "Question: #{q[:text]}\n\n"
|
|
94
|
+
|
|
95
|
+
chat.with_otel_attributes(
|
|
96
|
+
"langfuse.observation.prompt.name" => "supplement-assistant",
|
|
97
|
+
"langfuse.observation.prompt.version" => 1,
|
|
98
|
+
"langfuse.observation.input" => q[:text],
|
|
99
|
+
"langfuse.observation.output" => -> { chat.messages.last&.content.to_s },
|
|
100
|
+
"langfuse.observation.metadata" => { ingredient: q[:ingredient] }.to_json,
|
|
101
|
+
"langfuse.trace.metadata" => { ingredient: q[:ingredient] }.to_json,
|
|
102
|
+
"langfuse.trace.tags" => [q[:ingredient]]
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
response = chat.ask(q[:text])
|
|
106
|
+
puts "\nResponse: #{response.content}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# This line is only necessary in short-lived scripts. In a long-running application, spans will be flushed automatically.
|
|
110
|
+
OpenTelemetry.tracer_provider.force_flush
|
|
@@ -5,6 +5,11 @@ module OpenTelemetry
|
|
|
5
5
|
module RubyLLM
|
|
6
6
|
module Patches
|
|
7
7
|
module Chat
|
|
8
|
+
def with_otel_attributes(attributes)
|
|
9
|
+
@otel_attributes = attributes
|
|
10
|
+
self
|
|
11
|
+
end
|
|
12
|
+
|
|
8
13
|
def complete(&)
|
|
9
14
|
provider = @model&.provider || "unknown"
|
|
10
15
|
model_id = @model&.id || "unknown"
|
|
@@ -45,6 +50,8 @@ module OpenTelemetry
|
|
|
45
50
|
end
|
|
46
51
|
end
|
|
47
52
|
|
|
53
|
+
@otel_attributes&.each { |key, value| span.set_attribute(key, value.respond_to?(:call) ? value.call : value) }
|
|
54
|
+
|
|
48
55
|
result
|
|
49
56
|
end
|
|
50
57
|
end
|
|
@@ -292,6 +292,114 @@ class InstrumentationTest < Minitest::Test
|
|
|
292
292
|
assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code
|
|
293
293
|
end
|
|
294
294
|
|
|
295
|
+
def test_with_otel_attributes_sets_span_attributes
|
|
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!" },
|
|
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_otel_attributes(
|
|
315
|
+
"langfuse.trace.tags" => ["vitamin_d3"],
|
|
316
|
+
"custom.category" => "supplements"
|
|
317
|
+
)
|
|
318
|
+
chat.ask("Hi")
|
|
319
|
+
|
|
320
|
+
span = EXPORTER.finished_spans.first
|
|
321
|
+
assert_equal ["vitamin_d3"], span.attributes["langfuse.trace.tags"]
|
|
322
|
+
assert_equal "supplements", span.attributes["custom.category"]
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def test_with_otel_attributes_returns_self_for_chaining
|
|
326
|
+
stub_request(:post, "https://api.openai.com/v1/chat/completions")
|
|
327
|
+
.to_return(
|
|
328
|
+
status: 200,
|
|
329
|
+
headers: { "Content-Type" => "application/json" },
|
|
330
|
+
body: {
|
|
331
|
+
id: "chatcmpl-123",
|
|
332
|
+
object: "chat.completion",
|
|
333
|
+
model: "gpt-4o-mini",
|
|
334
|
+
choices: [{
|
|
335
|
+
index: 0,
|
|
336
|
+
message: { role: "assistant", content: "Hello!" },
|
|
337
|
+
finish_reason: "stop"
|
|
338
|
+
}],
|
|
339
|
+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
|
|
340
|
+
}.to_json
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
chat = RubyLLM.chat(model: "gpt-4o-mini")
|
|
344
|
+
result = chat.with_otel_attributes("custom.category" => "test")
|
|
345
|
+
|
|
346
|
+
assert_same chat, result
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def test_with_otel_attributes_evaluates_callables
|
|
350
|
+
stub_request(:post, "https://api.openai.com/v1/chat/completions")
|
|
351
|
+
.to_return(
|
|
352
|
+
status: 200,
|
|
353
|
+
headers: { "Content-Type" => "application/json" },
|
|
354
|
+
body: {
|
|
355
|
+
id: "chatcmpl-123",
|
|
356
|
+
object: "chat.completion",
|
|
357
|
+
model: "gpt-4o-mini",
|
|
358
|
+
choices: [{
|
|
359
|
+
index: 0,
|
|
360
|
+
message: { role: "assistant", content: "Hello!" },
|
|
361
|
+
finish_reason: "stop"
|
|
362
|
+
}],
|
|
363
|
+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
|
|
364
|
+
}.to_json
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
chat = RubyLLM.chat(model: "gpt-4o-mini")
|
|
368
|
+
chat.with_otel_attributes(
|
|
369
|
+
"custom.last_role" => -> { chat.messages.last&.role.to_s },
|
|
370
|
+
"custom.static" => "fixed"
|
|
371
|
+
)
|
|
372
|
+
chat.ask("Hi")
|
|
373
|
+
|
|
374
|
+
span = EXPORTER.finished_spans.first
|
|
375
|
+
assert_equal "assistant", span.attributes["custom.last_role"]
|
|
376
|
+
assert_equal "fixed", span.attributes["custom.static"]
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def test_works_without_otel_attributes
|
|
380
|
+
stub_request(:post, "https://api.openai.com/v1/chat/completions")
|
|
381
|
+
.to_return(
|
|
382
|
+
status: 200,
|
|
383
|
+
headers: { "Content-Type" => "application/json" },
|
|
384
|
+
body: {
|
|
385
|
+
id: "chatcmpl-123",
|
|
386
|
+
object: "chat.completion",
|
|
387
|
+
model: "gpt-4o-mini",
|
|
388
|
+
choices: [{
|
|
389
|
+
index: 0,
|
|
390
|
+
message: { role: "assistant", content: "Hello!" },
|
|
391
|
+
finish_reason: "stop"
|
|
392
|
+
}],
|
|
393
|
+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }
|
|
394
|
+
}.to_json
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
chat = RubyLLM.chat(model: "gpt-4o-mini")
|
|
398
|
+
response = chat.ask("Hi")
|
|
399
|
+
|
|
400
|
+
assert_equal "Hello!", response.content
|
|
401
|
+
end
|
|
402
|
+
|
|
295
403
|
def test_captures_content_when_enabled_via_env_var
|
|
296
404
|
ENV["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"
|
|
297
405
|
|
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.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Clarissa Borges
|
|
@@ -58,6 +58,7 @@ files:
|
|
|
58
58
|
- example/trace_demonstration.rb
|
|
59
59
|
- example/trace_demonstration_with_langfuse.rb
|
|
60
60
|
- example/trace_demonstration_with_langfuse_and_tools.rb
|
|
61
|
+
- example/trace_demonstration_with_langfuse_ingredient_search.rb
|
|
61
62
|
- example/trace_demonstration_with_tools.rb
|
|
62
63
|
- lib/opentelemetry-instrumentation-ruby_llm.rb
|
|
63
64
|
- lib/opentelemetry/instrumentation/ruby_llm/instrumentation.rb
|