Skip to content

Commit caac9b7

Browse files
Add with_otel_attributes for custom observation attributes (#21)
Allows setting arbitrary OpenTelemetry attributes on chat spans or traces via `with_otel_attributes`. Values can be static or callables (Procs/ lambdas) that are evaluated after each completion for access to response data.
1 parent 87a0a48 commit caac9b7

6 files changed

Lines changed: 261 additions & 2 deletions

File tree

README.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,32 @@ When enabled, the following attributes are added to chat spans:
5959
> [!WARNING]
6060
> Captured content may include sensitive or personally identifiable information (PII). Use with caution in production environments.
6161
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+
6288
## What's traced?
6389

6490
| Feature | Status |
@@ -68,8 +94,8 @@ When enabled, the following attributes are added to chat spans:
6894
| Error handling | Supported |
6995
| Opt-in input/output content capture | Supported |
7096
| Conversation tracking (`gen_ai.conversation.id`) | Planned |
71-
| System instructions capture | Planned |
72-
| Custom attributes on traces and spans | Planned |
97+
| System instructions capture | Supported (via `capture_content`) |
98+
| Custom attributes on traces and spans | Supported (via `with_otel_attributes`) |
7399
| Embeddings | Planned |
74100
| Streaming | Planned |
75101

example/trace_demonstration_with_langfuse.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636

3737
chat = RubyLLM.chat
3838
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+
)
3943
response = chat.ask("What is the meaning of life?")
4044
puts "\nResponse: #{response.content}"
4145

example/trace_demonstration_with_langfuse_and_tools.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ def execute(expression:)
4646
chat = RubyLLM.chat
4747
chat.with_instructions("You are a helpful assistant that provides concise answers.")
4848
chat.with_tool(Calculator)
49+
chat.with_otel_attributes(
50+
"langfuse.observation.prompt.name" => "helpful-assistant",
51+
"langfuse.observation.prompt.version" => 1
52+
)
4953
response = chat.ask("Use the calculator tool to compute 123 * 456")
5054
puts "\nResponse: #{response.content}"
5155
response = chat.ask("Use the tool again to compute 789 + 1011")
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/opentelemetry/instrumentation/ruby_llm/patches/chat.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ module Instrumentation
55
module RubyLLM
66
module Patches
77
module Chat
8+
def with_otel_attributes(attributes)
9+
@otel_attributes = attributes
10+
self
11+
end
12+
813
def complete(&)
914
provider = @model&.provider || "unknown"
1015
model_id = @model&.id || "unknown"
@@ -45,6 +50,8 @@ def complete(&)
4550
end
4651
end
4752

53+
@otel_attributes&.each { |key, value| span.set_attribute(key, value.respond_to?(:call) ? value.call : value) }
54+
4855
result
4956
end
5057
end

test/instrumentation_test.rb

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,114 @@ def test_records_error_on_embedding_api_failure
292292
assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code
293293
end
294294

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+
295403
def test_captures_content_when_enabled_via_env_var
296404
ENV["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"
297405

0 commit comments

Comments
 (0)