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_llm 2.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 ERROR status and error.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 the project_name resource attribute. Check exporter logs for HTTP failures.
  • NoMethodError mentioning model_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_CONTENT or 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 -9 cannot 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/output

Set 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
end

Two 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 exits

Run 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

CaseWhat you get
chat.askOne 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 callOne 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::AgentOne invoke_agent <Class> span as trace root, with the chat and tool spans below it. Needs ruby_llm 1.12.1 or newer.
Provider errorThe 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 usersession.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_llm 2.x. Use the native route above when migrating to 2.1.
  • In a tool loop the outer chat span reports the token counts of the final response, the same numbers as the follow-up chat span 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_agent spans show as type unknown in Future AGI: the instrumentation sets no fi.span.kind.
  • The instrumentation exports gen_ai.usage.cache_creation.input_tokens=0 when the provider reports none. Future AGI tokens are unaffected.

Legacy troubleshooting

  • NoMethodError ... 'model_id' on chat.ask. You are on ruby_llm 2.x. Pin ruby_llm to 1.x.
  • No traces for the project. project_name must be a resource attribute, which setup sets. A missing FI_PROJECT_NAME falls back to ruby-llm: look there.
  • KeyError: key not found: "FI_API_KEY". Both FI_API_KEY and FI_SECRET_KEY are required.
  • HTTP errors from the exporter in the log. Check the endpoint (FI_BASE_URL is 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.shutdown before exit, and do not kill -9 the process.
  • Content shows up when you did not opt in. Check that FI_CAPTURE_CONTENT is unset and that you are using SanitizingExporter through setup. OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true turns the instrumentation’s content on, but the exporter still drops it unless you opted in here.
  • RubyLLM::ModelNotFoundError: Unknown model. Pass provider: :openai, assume_model_exists: true for a model RubyLLM does not list, such as a model behind an OpenAI-compatible endpoint.
Was this page helpful?

Questions & Discussion