OpenTelemetry Instrumentation
Trace agent execution, LLM calls, tool usage, and handoffs using OpenTelemetry. Compatible with Langfuse and any OTel-compatible backend.
Overview
The Agents::Instrumentation module produces OTel spans that give you full visibility into agent execution:
- LLM generation spans with model name, token counts, and input/output
- Tool execution spans with arguments and results
- Agent container spans grouping related LLM and tool calls
- Handoff events recording agent-to-agent transfers
Generation inputs include MIME types for RubyLLM message attachments. Their URLs, local paths, and image data are omitted. Structured image inputs also omit the image URL.
Spans follow the GenAI semantic conventions and include Langfuse-specific attributes for rich rendering in the Langfuse dashboard.
RubyLLM 2.0 events and Langfuse
RubyLLM 2.0 emits chat.ruby_llm, tool_call.ruby_llm, and usage.ruby_llm events. In Rails, these use ActiveSupport::Notifications. Outside Rails, configure a RubyLLM instrumenter to receive them. The events are useful for logging and per-attempt usage, but RubyLLM does not turn them into this runner’s Langfuse trace. Keep Agents::Instrumentation when you need the following mapping:
| Runner span or event | Data in Langfuse | RubyLLM event alone |
|---|---|---|
| Root span | Overall input and output, session, user, tags, and Chatwoot metadata | No runner-level input, output, or Chatwoot context |
| Agent span | Agent name and the generations and tools it owns | No agent handoff boundary |
| Generation span | Request messages, response, model, temperature, and response tokens | chat.ruby_llm has the model and response, but needs an OTLP and Langfuse attribute adapter |
| Tool span | Tool arguments, result, and error status | tool_call.ruby_llm has the call, but needs the same adapter |
| Handoff event | Source agent, target agent, and reason | No RubyLLM handoff event for this runner |
RubyLLM’s usage.ruby_llm event reports each provider attempt, including failed and cancelled attempts. The generation spans here use tokens from completed messages. Use a separate usage.ruby_llm subscriber for attempt-level accounting; do not add its tokens to the same generation spans or costs may be counted twice. See the RubyLLM instrumentation guide for event payloads.
For an app outside Rails, add activesupport and configure RubyLLM before creating a chat:
require "active_support"
require "active_support/notifications"
RubyLLM.configure do |config|
config.instrumenter = ActiveSupport::Notifications
end
ActiveSupport::Notifications.subscribe("usage.ruby_llm") do |event|
puts event.payload.inspect # Replace with your per-attempt usage collector.
end
This enables RubyLLM’s native events. The runner spans still require Agents::Instrumentation.install and an OTel exporter, as shown below.
Setup
1. Install dependencies
Add to your Gemfile:
gem "opentelemetry-sdk"
gem "opentelemetry-exporter-otlp"
Then run bundle install.
2. Configure the OTel SDK
require "opentelemetry-sdk"
require "opentelemetry-exporter-otlp"
OpenTelemetry::SDK.configure do |c|
c.add_span_processor(
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
OpenTelemetry::Exporter::OTLP::Exporter.new(
endpoint: "https://your-otel-endpoint/v1/traces",
headers: { "Authorization" => "Bearer YOUR_TOKEN" }
)
)
)
end
3. Install on a runner
require "agents/instrumentation"
tracer = OpenTelemetry.tracer_provider.tracer("my-app")
runner = Agents::Runner.with_agents(triage, billing, support)
Agents::Instrumentation.install(runner, tracer: tracer)
That’s it. Every runner.run(...) call now produces OTel spans.
Span Hierarchy
root (agents.run)
├── agent.Calculator # container span per agent (no model attr)
│ ├── agents.run.generation # GENERATION: model, tokens, I/O
│ ├── agents.run.tool.add # TOOL: arguments + result
│ └── agents.run.generation # second LLM call after tool result
├── agent.Support # after handoff
│ └── agents.run.generation
└── agents.run.handoff # point event on root span
Only GENERATION spans carry gen_ai.request.model. This prevents Langfuse from double-counting costs when it sums token usage across spans with a model attribute.
Configuration Options
trace_name
Custom name for the root span (default: "agents.run"):
Agents::Instrumentation.install(runner,
tracer: tracer,
trace_name: "customer_support.run"
)
Child spans derive their names: customer_support.run.generation, customer_support.run.tool.add_numbers, etc.
span_attributes
Static attributes applied to the root span:
Agents::Instrumentation.install(runner,
tracer: tracer,
span_attributes: {
"langfuse.trace.tags" => '["production","v2"]',
"langfuse.session.id" => session_id
}
)
attribute_provider
A lambda that receives the context wrapper and returns dynamic attributes:
Agents::Instrumentation.install(runner,
tracer: tracer,
attribute_provider: ->(ctx) {
{
"langfuse.user.id" => ctx.context[:user_id].to_s,
"langfuse.session.id" => ctx.context[:session_id].to_s
}
}
)
Langfuse Integration
Endpoint and Authentication
Langfuse accepts OTel traces at {LANGFUSE_HOST}/api/public/otel/v1/traces. Authentication uses HTTP Basic with your public and secret keys:
require "base64"
langfuse_host = ENV["LANGFUSE_HOST"] # e.g. "https://cloud.langfuse.com"
langfuse_pk = ENV["LANGFUSE_PUBLIC_KEY"]
langfuse_sk = ENV["LANGFUSE_SECRET_KEY"]
auth_token = Base64.strict_encode64("#{langfuse_pk}:#{langfuse_sk}")
OpenTelemetry::SDK.configure do |c|
c.add_span_processor(
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
OpenTelemetry::Exporter::OTLP::Exporter.new(
endpoint: "#{langfuse_host}/api/public/otel/v1/traces",
headers: { "Authorization" => "Basic #{auth_token}", "x-langfuse-ingestion-version" => "4" }
)
)
)
end
Attribute Mapping
The instrumentation sets Langfuse-specific attributes that map to the Langfuse UI:
| Attribute | Set On | Langfuse Display |
|---|---|---|
langfuse.trace.input | Root span | Legacy trace input for existing evaluators |
langfuse.trace.output | Root span | Legacy trace output for existing evaluators |
langfuse.observation.input | All spans | Observation input (sidebar click) |
langfuse.observation.output | All spans | Observation output (sidebar click) |
langfuse.observation.type | Tool spans | "tool" type indicator |
langfuse.user.id | Root span (via attribute_provider) | User filter/display |
langfuse.session.id | Root span (via attribute_provider) | Session grouping |
langfuse.trace.tags | Root span (via span_attributes) | Trace tags |
gen_ai.request.model | Generation spans only | Model name + cost calculation |
gen_ai.usage.input_tokens | Generation spans | Token usage |
gen_ai.usage.output_tokens | Generation spans | Token usage |
gen_ai.usage.reasoning.output_tokens | Generation spans, when reported | Reasoning tokens within output tokens |
langfuse.observation.metadata.reasoning_summary | Responses generation spans with thinking: { display: :summarized } | Returned summary, when provided |
Langfuse v4 reads overall input and output from langfuse.observation.input and langfuse.observation.output on the root span. This integration also keeps the deprecated trace input and output attributes for existing consumers. The Langfuse v4 ingestion path needs the x-langfuse-ingestion-version: 4 exporter header. See the Langfuse migration guide.
Reasoning tokens are already part of output tokens, so do not add the two counts. The summary field records only text returned for a requested Responses summary. Encrypted reasoning signatures stay in conversation history for stateless replay and are not added to Langfuse spans. Treat persisted conversation history as sensitive data.
EU vs US Cloud
- US:
https://cloud.langfuse.com - EU:
https://eu.cloud.langfuse.com
Set LANGFUSE_HOST accordingly. Self-hosted instances use your own URL.
Complete Example
require "agents"
require "agents/instrumentation"
require "opentelemetry-sdk"
require "opentelemetry-exporter-otlp"
require "base64"
# --- Configure Agents ---
Agents.configure do |config|
config.openai_api_key = ENV["OPENAI_API_KEY"]
config.default_model = "gpt-4o-mini"
end
# --- Configure OTel with Langfuse ---
langfuse_host = ENV.fetch("LANGFUSE_HOST", "https://cloud.langfuse.com")
auth_token = Base64.strict_encode64(
"#{ENV["LANGFUSE_PUBLIC_KEY"]}:#{ENV["LANGFUSE_SECRET_KEY"]}"
)
OpenTelemetry::SDK.configure do |c|
c.add_span_processor(
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
OpenTelemetry::Exporter::OTLP::Exporter.new(
endpoint: "#{langfuse_host}/api/public/otel/v1/traces",
headers: { "Authorization" => "Basic #{auth_token}", "x-langfuse-ingestion-version" => "4" }
)
)
)
end
tracer = OpenTelemetry.tracer_provider.tracer("my-app")
# --- Build agents ---
triage = Agents::Agent.new(name: "Triage", instructions: "Route users...")
billing = Agents::Agent.new(name: "Billing", instructions: "Handle billing...")
support = Agents::Agent.new(name: "Support", instructions: "Technical support...")
triage.register_handoffs(billing, support)
billing.register_handoffs(triage)
support.register_handoffs(triage)
# --- Create runner with instrumentation ---
runner = Agents::Runner.with_agents(triage, billing, support)
Agents::Instrumentation.install(runner,
tracer: tracer,
trace_name: "customer_support",
attribute_provider: ->(ctx) {
{
"langfuse.user.id" => ctx.context[:user_id].to_s,
"langfuse.session.id" => ctx.context[:session_id].to_s
}
}
)
# --- Run conversations ---
result = runner.run("I have a billing question",
context: { user_id: "user_123", session_id: "sess_456" })
puts result.output
# Ensure spans are flushed before exit
at_exit { OpenTelemetry.tracer_provider.force_flush }
Troubleshooting
“undefined” values in Langfuse
Langfuse renders empty string attributes as “undefined”. The instrumentation guards against this by not setting attributes when values are nil or empty. If you see “undefined”, check that your agents are producing output content.
Double-counted costs
If token costs appear inflated, verify that gen_ai.request.model is only set on GENERATION spans, not on container or root spans. Agents::Instrumentation handles this by default. If you set custom span_attributes that include gen_ai.request.model, costs will be double-counted.
Empty spans / missing data
- Ensure
opentelemetry-sdkis installed (not justopentelemetry-api) - Call
OpenTelemetry.tracer_provider.force_flushbefore process exit - Verify your OTLP endpoint is reachable and credentials are correct
- Check that
Agents::Instrumentation.installreturns the runner (returns nil if OTel is unavailable)
Spans not appearing in Langfuse
- Verify the endpoint includes
/api/public/otel/v1/traces - Check that the Authorization header uses
Basic(notBearer) with base64-encodedpk:sk - Use
BatchSpanProcessorfor production;SimpleSpanProcessorcan be useful for debugging - SSL CRL errors on Ruby 3.4+: The OTLP exporter silently fails when SSL certificate revocation list (CRL) checks fail. The exporter reports SUCCESS but no data arrives. Fix by passing
ssl_verify_mode: OpenSSL::SSL::VERIFY_NONEto the exporter in development, or ensure your system CA certificates are up to date
End-to-end check
The optional Langfuse check stubs the model response, exports real OTel spans to your Langfuse project, and reads them back through the observations API. It covers a handoff, token usage, session and user attributes, and a failed tool. Set LANGFUSE_HOST, LANGFUSE_PUBLIC_KEY, and LANGFUSE_SECRET_KEY, then run:
RUN_LANGFUSE_E2E=1 bundle exec rspec spec/integration/langfuse_e2e_spec.rb
The model call is stubbed, so this check does not require a provider API key. Use the separate live LLM smoke suite when you also need to check provider access.