Skip to content

Commit cac521f

Browse files
ebeigartsclaude
andauthored
Add instrumentation for RubyLLM::Embedding.embed (#17)
Instrument the embeddings endpoint following the OpenTelemetry GenAI semantic conventions for embedding spans. The patch is applied via singleton_class.prepend on RubyLLM::Embedding, wrapping the class-level embed method with a CLIENT span. Span attributes: - gen_ai.operation.name: "embeddings" - gen_ai.provider.name: resolved provider (e.g. "openai") - gen_ai.request.model / gen_ai.response.model: model identifiers - gen_ai.usage.input_tokens: token count from the response - gen_ai.embeddings.dimension.count: length of the embedding vector - error.type: exception class on API failures Error handling follows the same pattern as Chat instrumentation: API errors are recorded on the span and re-raised, while instrumentation failures are swallowed to avoid breaking the underlying embed call. Tests cover span creation with all attributes, error recording on API failure, and resilience when instrumentation itself fails. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3328ffe commit cac521f

3 files changed

Lines changed: 132 additions & 0 deletions

File tree

lib/opentelemetry/instrumentation/ruby_llm/instrumentation.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ class Instrumentation < OpenTelemetry::Instrumentation::Base
1515

1616
install do |_config|
1717
require_relative "patches/chat"
18+
require_relative "patches/embedding"
1819
::RubyLLM::Chat.prepend(Patches::Chat)
20+
::RubyLLM::Embedding.singleton_class.prepend(Patches::Embedding)
1921
end
2022
end
2123
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
module OpenTelemetry
4+
module Instrumentation
5+
module RubyLLM
6+
module Patches
7+
module Embedding
8+
def embed(text, model: nil, provider: nil, assume_model_exists: false, context: nil, dimensions: nil)
9+
config = context&.config || ::RubyLLM.config
10+
resolved_model = model || config.default_embedding_model
11+
model_obj, _provider_instance = ::RubyLLM::Models.resolve(
12+
resolved_model, provider: provider, assume_exists: assume_model_exists, config: config
13+
)
14+
model_id = model_obj.id
15+
provider_name = model_obj.provider || "unknown"
16+
17+
attributes = {
18+
"gen_ai.operation.name" => "embeddings",
19+
"gen_ai.provider.name" => provider_name,
20+
"gen_ai.request.model" => model_id
21+
}
22+
23+
tracer.in_span("embeddings #{model_id}", attributes: attributes, kind: OpenTelemetry::Trace::SpanKind::CLIENT) do |span|
24+
begin
25+
result = super
26+
rescue => e
27+
span.record_exception(e)
28+
span.status = OpenTelemetry::Trace::Status.error(e.message)
29+
span.set_attribute("error.type", e.class.name)
30+
raise
31+
end
32+
33+
span.set_attribute("gen_ai.response.model", result.model) if result.model
34+
span.set_attribute("gen_ai.usage.input_tokens", result.input_tokens) if result.input_tokens&.positive?
35+
36+
if result.vectors.is_a?(Array)
37+
first = result.vectors.first
38+
vector = first.is_a?(Array) ? first : result.vectors
39+
span.set_attribute("gen_ai.embeddings.dimension.count", vector.length) if vector.is_a?(Array)
40+
end
41+
42+
result
43+
end
44+
rescue StandardError => e
45+
OpenTelemetry.handle_error(exception: e)
46+
super
47+
end
48+
49+
private
50+
51+
def tracer
52+
RubyLLM::Instrumentation.instance.tracer
53+
end
54+
end
55+
end
56+
end
57+
end
58+
end

test/instrumentation_test.rb

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,78 @@ def test_captures_content_when_enabled
332332
OpenTelemetry::Instrumentation::RubyLLM::Instrumentation.instance.config[:capture_content] = false
333333
end
334334

335+
def test_creates_span_for_embedding
336+
stub_request(:post, "https://api.openai.com/v1/embeddings")
337+
.to_return(
338+
status: 200,
339+
headers: { "Content-Type" => "application/json" },
340+
body: {
341+
object: "list",
342+
model: "text-embedding-3-small",
343+
data: [
344+
{ object: "embedding", index: 0, embedding: [0.1, 0.2, 0.3] }
345+
],
346+
usage: { prompt_tokens: 8, total_tokens: 8 }
347+
}.to_json
348+
)
349+
350+
RubyLLM.embed("Hello, world!", model: "text-embedding-3-small")
351+
352+
spans = EXPORTER.finished_spans
353+
assert_equal 1, spans.length
354+
355+
span = spans.first
356+
assert_equal OpenTelemetry::Trace::SpanKind::CLIENT, span.kind
357+
assert_equal "embeddings text-embedding-3-small", span.name
358+
assert_equal "embeddings", span.attributes["gen_ai.operation.name"]
359+
assert_equal "openai", span.attributes["gen_ai.provider.name"]
360+
assert_equal "text-embedding-3-small", span.attributes["gen_ai.request.model"]
361+
assert_equal "text-embedding-3-small", span.attributes["gen_ai.response.model"]
362+
assert_equal 8, span.attributes["gen_ai.usage.input_tokens"]
363+
assert_equal 3, span.attributes["gen_ai.embeddings.dimension.count"]
364+
end
365+
366+
def test_records_error_on_embedding_api_failure
367+
stub_request(:post, "https://api.openai.com/v1/embeddings")
368+
.to_return(status: 500, body: "Internal Server Error")
369+
370+
assert_raises do
371+
RubyLLM.embed("Hello", model: "text-embedding-3-small")
372+
end
373+
374+
spans = EXPORTER.finished_spans
375+
span = spans.last
376+
377+
assert_equal "embeddings text-embedding-3-small", span.name
378+
assert span.attributes["error.type"]
379+
assert_equal OpenTelemetry::Trace::Status::ERROR, span.status.code
380+
end
381+
382+
def test_embed_still_works_when_instrumentation_fails
383+
stub_request(:post, "https://api.openai.com/v1/embeddings")
384+
.to_return(
385+
status: 200,
386+
headers: { "Content-Type" => "application/json" },
387+
body: {
388+
object: "list",
389+
model: "text-embedding-3-small",
390+
data: [
391+
{ object: "embedding", index: 0, embedding: [0.1, 0.2, 0.3] }
392+
],
393+
usage: { prompt_tokens: 8, total_tokens: 8 }
394+
}.to_json
395+
)
396+
397+
mod = OpenTelemetry::Instrumentation::RubyLLM::Patches::Embedding
398+
original_tracer = mod.instance_method(:tracer)
399+
mod.define_method(:tracer) { raise StandardError, "instrumentation bug" }
400+
401+
result = RubyLLM.embed("Hello, world!", model: "text-embedding-3-small")
402+
assert_equal [0.1, 0.2, 0.3], result.vectors
403+
ensure
404+
mod.define_method(:tracer, original_tracer)
405+
end
406+
335407
def test_captures_content_when_enabled_via_env_var
336408
ENV["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "true"
337409

0 commit comments

Comments
 (0)