Ruby LLM tracing with Future AGI
Trace RubyLLM with its native OpenTelemetry integration. Send model, token usage, tool and error metadata to Future AGI without capturing message content.
Prerequisites
- Ruby 3.2 or newer and RubyLLM 2.1. This guide uses the released
ruby_llm2.1.1 gem. - Future AGI API and secret keys, and an OpenAI API key for the example.
Note
Native tracing in RubyLLM 2.1.1 sends metadata only: it does not export prompts, responses, tool arguments or results, or exception messages. It does not provide a content-capture option. If your existing 1.x application relies on content capture, agent root spans, or session and user attributes, read Existing RubyLLM 1.x installations before migrating.
Install
source "https://rubygems.org"
gem "ruby_llm", "2.1.1"
gem "opentelemetry-sdk", "1.10.0"
gem "opentelemetry-exporter-otlp", "0.32.0"
bundle install
The exporter version above supports Ruby 3.2. Exporter 0.33 and later requires Ruby 3.3 or newer. You do not need the third-party opentelemetry-instrumentation-ruby_llm gem for native tracing.
Configure tracing
export FI_API_KEY="YOUR_API_KEY"
export FI_SECRET_KEY="YOUR_SECRET_KEY"
export FI_PROJECT_NAME="ruby-llm"
export FI_BASE_URL="https://api.futureagi.com"
export OPENAI_API_KEY="YOUR_OPENAI_KEY"
For self-hosted Future AGI, set FI_BASE_URL to your instance’s origin without a path. The configuration below adds /tracer/v1/traces and sends both authentication headers. project_name must be a resource attribute, not a span attribute.
Save this standard OpenTelemetry configuration as tracing.rb and load it once at application startup, before making RubyLLM calls:
require "ruby_llm"
require "opentelemetry/sdk"
require "opentelemetry/exporter/otlp"
exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(
endpoint: "#{ENV.fetch('FI_BASE_URL').chomp('/')}/tracer/v1/traces",
headers: {
"X-Api-Key" => ENV.fetch("FI_API_KEY"),
"X-Secret-Key" => ENV.fetch("FI_SECRET_KEY")
},
compression: "none"
)
OpenTelemetry::SDK.configure do |config|
config.service_name = "ruby-llm-app"
config.resource = OpenTelemetry::SDK::Resources::Resource.create(
"project_name" => ENV.fetch("FI_PROJECT_NAME"),
"project_type" => "observe"
)
config.add_span_processor(
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
)
end
RubyLLM::OpenTelemetry.enable
at_exit { OpenTelemetry.tracer_provider.shutdown }
compression: "none" also works with self-hosted releases before v1.56.0, which reject gzip. Cloud and self-hosted v1.56.0 or later also accept compression: "gzip". The Ruby exporter sends OTLP/HTTP protobuf.
If your application already configures OpenTelemetry, add the exporter, processor and project resource to that existing setup rather than configuring a second provider. Then call RubyLLM::OpenTelemetry.enable once.
Warning
Do not enable the third-party instrumentation gem and native tracing together. When migrating, remove the old gem and its config.use "OpenTelemetry::Instrumentation::RubyLLM" call, stop loading futureagi_tracing.rb, and restart the process. Removing the configuration without restarting does not undo an already installed patch.
Run a chat
Save chat.rb next to tracing.rb:
require_relative "tracing"
RubyLLM.configure { |config| config.openai_api_key = ENV.fetch("OPENAI_API_KEY") }
chat = RubyLLM.chat(model: "gpt-4o-mini")
response = chat.ask("What is OpenTelemetry?")
puts response.content
bundle exec ruby chat.rb
Open Observe in Future AGI and select the project named by FI_PROJECT_NAME. Look for chat gpt-4o-mini and inspect its model, provider, duration and token usage. Prompt and response panels remain empty because native RubyLLM 2.1.1 does not export content.
The exit hook flushes pending spans on a normal process exit. For a managed server, call OpenTelemetry.tracer_provider.shutdown in its stop hook. Do not shut down the provider after each request.
What native tracing captures
- Chat and streaming: a
chat <model>span for each model request, with model/provider metadata and token usage when reported by the provider. Streaming produces one span for the request, not one per chunk. - Tools:
execute_tool <name>spans with the tool name and call ID, without arguments, results or descriptions. Follow-up model requests are separate chat spans. - Errors: an
ERRORstatus anderror.type, without the provider error message or exception stack trace.
These privacy defaults apply to RubyLLM’s native spans, not to attributes or events added by your application or other instrumentation. Use non-sensitive tool and workflow names because names are exported.
Native RubyLLM 2.1.1 does not add an invoke_agent root or session and user attributes, and does not expose the third-party with_otel_attributes API. Do not expect a 1.x application’s content capture or session grouping to carry over automatically. Follow the RubyLLM upgrade guide for application API changes as well.
Troubleshooting
- No traces: confirm both Future AGI keys, the origin in
FI_BASE_URL, and theproject_nameresource attribute. Check exporter logs for HTTP failures. NoMethodErrormentioningmodel_id: the old third-party patch is still active with RubyLLM 2.x. Remove it and restart the process before using native tracing.- No prompt or response text: expected for native tracing in 2.1.1. Setting
FI_CAPTURE_CONTENTor the third-party capture environment variable does not enable native content capture. - Missing spans at exit: allow the shutdown hook to complete. Forced termination such as
kill -9cannot flush queued spans.
Existing RubyLLM 1.x installations
Keep an existing 1.x setup on its pinned dependencies until you can migrate its application APIs and tracing behavior together. The legacy route below retains opt-in content capture, with_otel_attributes, and invoke_agent spans; the native 2.1.1 route is not a drop-in replacement for those features. Its exporter safeguard is still required: the legacy instrumentation’s capture_content: false does not suppress tool data or exception events by itself.
Legacy reference for existing 1.x applications
This reference is for applications already using FutureAGITracing. For new applications, use the native setup above. Do not combine the two configurations.
Legacy dependencies
source "https://rubygems.org"
gem "ruby_llm", "1.16.0"
gem "opentelemetry-instrumentation-ruby_llm", "0.7.1"
gem "opentelemetry-sdk"
gem "opentelemetry-exporter-otlp"Keep the OpenTelemetry version pins already working in your 1.x application. The instrumentation gem depends only on opentelemetry-api and opentelemetry-instrumentation-base; the SDK and exporter remain separate dependencies. Keep ruby_llm below 2.0 ("~> 1.16").
Legacy environment variables
export FI_API_KEY="YOUR_API_KEY"
export FI_SECRET_KEY="YOUR_SECRET_KEY"
export FI_PROJECT_NAME="my-chatbot" # the Observe project the traces land in
export OPENAI_API_KEY="YOUR_OPENAI_KEY" # model provider key
# Set this to your self-hosted Future AGI origin.
export FI_BASE_URL="http://localhost:4318" # replace with your instance
# export FI_CAPTURE_CONTENT=1 # opt in to prompts, answers and tool input/outputSet FI_BASE_URL to the origin only. The code adds /tracer/v1/traces.
Keep the legacy exporter safeguard
The instrumentation gem sets no endpoint, headers or project. The OpenTelemetry SDK you configure does, and Future AGI needs all three: the OTLP/HTTP endpoint, both X-Api-Key and X-Secret-Key, and the resource attribute project_name (a span attribute of that name is not a substitute). Keep the following safeguard in your existing futureagi_tracing.rb:
# frozen_string_literal: true
# Future AGI tracing for ruby_llm. This file is the whole integration: it configures the
# OpenTelemetry Ruby SDK, enables opentelemetry-instrumentation-ruby_llm, and (by default)
# keeps only metadata on every span, because that instrumentation exports tool arguments and
# results even when capture_content is off.
#
# require_relative "futureagi_tracing"
# FutureAGITracing.setup # once, at boot
# ...
# FutureAGITracing.shutdown # before the process exits (also registered with at_exit)
#
# Environment:
# FI_API_KEY, FI_SECRET_KEY required
# FI_PROJECT_NAME Observe project the traces land in (default "ruby-llm")
# FI_BASE_URL self-hosted origin only; default https://api.futureagi.com
# FI_CAPTURE_CONTENT=1 opt in to prompts, answers, tool arguments and tool results
require "opentelemetry/sdk"
require "opentelemetry/exporter/otlp"
require "opentelemetry-instrumentation-ruby_llm"
module FutureAGITracing
# Attributes that describe a call without containing what was said or done. Anything not
# listed is dropped unless content is opted in, so an attribute added by a later release of
# the instrumentation (or one your code sets with with_otel_attributes) stays private until
# you add it here. opentelemetry-instrumentation-ruby_llm 0.7.1 writes tool arguments,
# tool results and tool descriptions onto execute_tool spans even with capture_content off
# (patches/chat.rb, execute_tool), so its own switch is not enough.
METADATA_ATTRIBUTES = %w[
gen_ai.operation.name
gen_ai.provider.name
gen_ai.tool.name
gen_ai.tool.call.id
gen_ai.tool.type
gen_ai.agent.name
gen_ai.conversation.id
error.type
session.id
user.id
].freeze
METADATA_PREFIXES = %w[gen_ai.request. gen_ai.response. gen_ai.usage.].freeze
# Wraps an exporter. Always drops span events (the instrumentation records exception
# messages and stack traces there, which contain local paths and raw provider text). Unless
# content is opted in, also keeps only METADATA_ATTRIBUTES, and keeps the ERROR status
# without its text.
class SanitizingExporter
def initialize(inner, keep_content:)
@inner = inner
@keep_content = keep_content
end
def export(span_datas, timeout: nil)
@inner.export(span_datas.map { |sd| sanitize(sd) }, timeout: timeout)
end
def force_flush(timeout: nil) = @inner.force_flush(timeout: timeout)
def shutdown(timeout: nil) = @inner.shutdown(timeout: timeout)
private
def metadata?(key)
METADATA_ATTRIBUTES.include?(key) || METADATA_PREFIXES.any? { |prefix| key.start_with?(prefix) }
end
def sanitize(span_data)
copy = span_data.dup
copy.events = nil
copy.total_recorded_events = 0
unless @keep_content
copy.attributes = (span_data.attributes || {}).select { |key, _| metadata?(key) }
status = span_data.status
copy.status = OpenTelemetry::Trace::Status.error if status && status.code == OpenTelemetry::Trace::Status::ERROR
end
copy
end
end
class << self
# Configure the SDK. Pass exporter: to replace the OTLP exporter (tests do).
def setup(project_name: ENV.fetch("FI_PROJECT_NAME", "ruby-llm"),
capture_content: ENV["FI_CAPTURE_CONTENT"] == "1",
exporter: nil, batch: true, flush_at_exit: true)
exporter ||= otlp_exporter
exporter = SanitizingExporter.new(exporter, keep_content: capture_content)
processor = if batch
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
else
OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(exporter)
end
OpenTelemetry::SDK.configure do |c|
c.service_name = ENV.fetch("OTEL_SERVICE_NAME", project_name)
c.resource = OpenTelemetry::SDK::Resources::Resource.create(
"project_name" => project_name,
"project_type" => "observe"
)
c.add_span_processor(processor)
c.use "OpenTelemetry::Instrumentation::RubyLLM", capture_content: capture_content
end
at_exit { shutdown } if flush_at_exit
self
end
# Flush and stop. Safe to call twice.
def shutdown
return if @shut_down
@shut_down = true
OpenTelemetry.tracer_provider.shutdown
end
def endpoint
"#{ENV.fetch('FI_BASE_URL', 'https://api.futureagi.com').chomp('/')}/tracer/v1/traces"
end
private
def otlp_exporter
OpenTelemetry::Exporter::OTLP::Exporter.new(
endpoint: endpoint,
headers: { "X-Api-Key" => ENV.fetch("FI_API_KEY"), "X-Secret-Key" => ENV.fetch("FI_SECRET_KEY") },
compression: "none" # the exporter gzips by default; do not
)
end
end
endTwo details in it matter. compression: "none" is there because the Ruby OTLP exporter gzips by default and self-hosted releases before v1.56.0 answered gzip with HTTP 400. Future AGI cloud and self-hosted v1.56.0 and later accept gzip, so the line is optional there. The SanitizingExporter is there because of what the instrumentation exports, described under privacy below.
Legacy application setup
Existing applications call setup once at boot, before any RubyLLM call:
require_relative "futureagi_tracing"
require "ruby_llm"
FutureAGITracing.setup
RubyLLM.configure { |c| c.openai_api_key = ENV.fetch("OPENAI_API_KEY") }
chat = RubyLLM.chat(model: "gpt-4o-mini")
chat.ask("What is OpenTelemetry?")
FutureAGITracing.shutdown # flush before the process exitsRun ruby chat.rb, then open the project in Observe to inspect the trace and its spans.
setup also registers at_exit { shutdown }, so a normal exit flushes. A short script that is killed (kill -9) loses the last batch. In Rails, call FutureAGITracing.setup from an initializer and FutureAGITracing.shutdown from your server’s stop hook if it does not exit normally.
To tie several calls to one session and one user, set the attributes on the chat:
chat = RubyLLM.chat(model: "gpt-4o-mini")
.with_otel_attributes("session.id" => conversation.id.to_s, "user.id" => current_user.id.to_s)
chat.ask("Hi")
chat.ask("And now?")The legacy spans include the same session.id and user.id on both calls for session grouping. RubyLLM spans carry no session or user unless you set them. The instrumentation sets gen_ai.conversation.id only for persisted acts_as_chat records or when you set it yourself; gen_ai.conversation.id on its own is outside this guide’s coverage as a session key.
Legacy captured data
| Case | What you get |
|---|---|
chat.ask | One llm span chat <model> with the request and response model, provider (openai), and input and output token counts. |
Streaming (chat.ask with a block) | One span for the whole stream, not one per chunk, with token counts (the provider reports usage on the last chunk) and gen_ai.request.stream=true. |
| Tool call | One execute_tool <name> span of type tool (tool name and call id), a child of the chat span that ran it. The follow-up completion is a second chat span under it. Tool arguments, results and the tool description are not exported by default. |
RubyLLM::Agent | One invoke_agent <Class> span as trace root, with the chat and tool spans below it. Needs ruby_llm 1.12.1 or newer. |
| Provider error | The chat span has status ERROR and error.type (for example RubyLLM::BadRequestError for an HTTP 400, and the base RubyLLM::Error for OpenAI’s 404 on an unknown model id). The error text and stack trace are not exported. |
| Session and user | session.id and user.id when you set them as above. |
Everything RubyLLM does not report is absent, not zero.
Legacy privacy settings
With no opt-in, Future AGI receives model, provider, token counts, span names and timing, tool names, agent names, status and error.type, and the session.id, user.id and gen_ai.conversation.id you set. It does not receive prompts, answers, system instructions, tool arguments, tool results, tool descriptions, error messages, stack traces, local paths, host names, or any other attribute you set with with_otel_attributes.
The instrumentation’s capture_content switch alone does not suppress tool arguments, results, descriptions or exception events. Keep SanitizingExporter in place to remove them before export when content capture is off.
To opt in, set FI_CAPTURE_CONTENT=1 or call FutureAGITracing.setup(capture_content: true). That turns on gen_ai.input.messages, gen_ai.output.messages and gen_ai.system_instructions (prompts and answers), keeps the tool arguments, results and descriptions, and keeps every other attribute you set. Span events, and so exception messages and stack traces, are never exported, in either mode. Content arrives as span attributes and may not appear in the trace’s Input and Output panels.
Legacy limitations
- The legacy instrumentation does not support
ruby_llm2.x. Use the native route above when migrating to 2.1. - In a tool loop the outer
chatspan reports the token counts of the final response, the same numbers as the follow-upchatspan under it, so summed token counts for the trace are inflated. This comes from the instrumentation: it reads the last message after the nested completion returns. invoke_agentspans show as typeunknownin Future AGI: the instrumentation sets nofi.span.kind.- The instrumentation exports
gen_ai.usage.cache_creation.input_tokens=0when the provider reports none. Future AGI tokens are unaffected.
Legacy troubleshooting
NoMethodError ... 'model_id'onchat.ask. You are onruby_llm2.x. Pinruby_llmto 1.x.- No traces for the project.
project_namemust be a resource attribute, whichsetupsets. A missingFI_PROJECT_NAMEfalls back toruby-llm: look there. KeyError: key not found: "FI_API_KEY". BothFI_API_KEYandFI_SECRET_KEYare required.- HTTP errors from the exporter in the log. Check the endpoint (
FI_BASE_URLis the origin, no path). If gzip is back on: releases before v1.56.0 answered HTTP 400; current releases accept gzip. - Short script, zero spans. Call
FutureAGITracing.shutdownbefore exit, and do notkill -9the process. - Content shows up when you did not opt in. Check that
FI_CAPTURE_CONTENTis unset and that you are usingSanitizingExporterthroughsetup.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=trueturns the instrumentation’s content on, but the exporter still drops it unless you opted in here. RubyLLM::ModelNotFoundError: Unknown model. Passprovider: :openai, assume_model_exists: truefor a model RubyLLM does not list, such as a model behind an OpenAI-compatible endpoint.
Questions & Discussion