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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 25a2701b37bc9acecc70d4ff326230cbf5e073454f47c453744ced08d468b2f8
4
- data.tar.gz: a5885383024ecf52deb132bdf0ccb86b1cb9a0169561a714e6d3cabec8e3a64a
3
+ metadata.gz: 07574aa081ca21455a0e4384ee35109310b70f878be921a6567b3126e9809602
4
+ data.tar.gz: 5a42c5a5f695c9b72cdf6a29caf5cd8d8ad8cf06895aebc0e170b9b4a02892c3
5
5
  SHA512:
6
- metadata.gz: 023c166358bee510b9cd75f41764179e019eba7f3df1153c8d3806a8ce7488b7781688b708a2b315b4a2207306d822cf063c2e2125874cbbe3fc912176897edd
7
- data.tar.gz: ef4d632a49a4d22d66d751648a2c5edf6ba13d0223dc1d42901193f675e13eff065ea8dbf713bac7f29450b2af6fd510126e4bbf5a713138bf16b9a74152c642
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 | 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`) |
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
@@ -3,7 +3,7 @@
3
3
  module OpenTelemetry
4
4
  module Instrumentation
5
5
  module RubyLLM
6
- VERSION = "0.4.0"
6
+ VERSION = "0.5.0"
7
7
  end
8
8
  end
9
9
  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.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