verica-observability

Two-line LLM tracing for Verica. The official openai gem has no auto-instrumentation anywhere: this gem ships it.

Install

gem install verica-observability

Use (official openai gem)

require 'verica'

Verica.init(token: ENV['VERICA_TOKEN'])
client = Verica.wrap_openai(OpenAI::Client.new)
# use `client` exactly like the original; chat completions are traced.

Use (ruby-openai gem: OpenAI, Gemini, Anthropic)

The community ruby-openai gem uses client.chat(parameters: { ... }) and returns a plain Hash. Wrap it with wrap_openai_compatible: the provider is inferred from the model (gpt-* → openai, gemini-* → google, claude-* → anthropic), so one wrapper traces OpenAI plus Gemini and Anthropic via their OpenAI-compatible endpoints.

require 'verica'
Verica.init(token: ENV['VERICA_TOKEN'])

# OpenAI
openai = Verica.wrap_openai_compatible(OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY']))
openai.chat(parameters: { model: 'gpt-4o-mini', messages: [{ role: 'user', content: 'Hello!' }] })

# Gemini (OpenAI-compatible endpoint)
gemini = Verica.wrap_openai_compatible(OpenAI::Client.new(
  access_token: ENV['GEMINI_API_KEY'],
  uri_base: 'https://generativelanguage.googleapis.com/v1beta/openai/'
))
gemini.chat(parameters: { model: 'gemini-2.5-flash', messages: [...] })

# Anthropic (OpenAI-compatible endpoint)
anthropic = Verica.wrap_openai_compatible(OpenAI::Client.new(
  access_token: ENV['ANTHROPIC_API_KEY'],
  uri_base: 'https://api.anthropic.com/v1/'
))
anthropic.chat(parameters: { model: 'claude-sonnet-4', messages: [...] })

wrap_ruby_openai is a back-compat alias of wrap_openai_compatible. Ruby has no native Gemini/Anthropic gem support; use the OpenAI-compatible endpoints above.

Per-request conversation id

Group the turns of one chat under a single gen_ai.conversation.id without a global setting. Verica.with_conversation(id) sets a thread-local override that both wrappers stamp on every span emitted inside the block; it takes precedence over the global conversation_id: from init, restores the previous value on exit (including when the block raises), and is safe to nest. id is coerced with to_s; a nil or empty id emits no conversation attribute. In a Rails chatbot controller:

class MessagesController < ApplicationController
  def create
    Verica.with_conversation("chat-#{conversation.id}") do
      reply = openai.chat(parameters: {
        model: 'gpt-4o-mini',
        messages: conversation.to_openai_messages
      })
      # ...persist and render reply...
    end
  end
end

with_conversation returns the block's value, so you can wrap an existing method body with it and keep the return unchanged. Each thread carries its own override, so concurrent requests never leak conversation ids into one another.

Resend history exactly as sent. Turns are stored as deltas: at ingest, Verica matches the history you resend against what previous turns already stored, and that match is exact (byte-identical text). If your app mutates prior messages between requests (for example, appending "Respond in JSON" to the last user message and stripping it from earlier turns when rebuilding the history), no prefix ever matches and every turn falls back to storing, and showing, the full conversation again. Keep injected instructions in the system prompt, or resend them exactly as originally sent.

Tags

Tags land on each trace (traces.tags): filter the workbench by them, and bind criteria to them so evaluation preselects the right criteria per tag.

Verica.init(token: ENV.fetch("VERICA_TOKEN"), tags: ["mtn-campus", "prod"])

Verica.with_tags(["mentor-chat"]) do
  client.chat(parameters: { ... })
end

Per-request tags UNION with the globals (dedup, order preserved); nested blocks accumulate; the scope is thread-local and restored even if the block raises. Values are coerced with to_s; the server caps at 20 tags x 120 chars.

Streaming (wrap_openai_compatible)

Streaming calls (a stream: proc in parameters) are fully captured: the wrapper accumulates the chunks as they flow to your proc, then annotates the span with the joined assistant output and the response model. Your proc still receives every chunk, in order, unchanged; instrumentation never interferes with the stream.

Tokens (and therefore cost) are only available when the provider sends a final usage chunk, which OpenAI does when you pass stream_options: { include_usage: true }:

client.chat(parameters: {
  model: 'gpt-4o-mini',
  messages: [{ role: 'user', content: 'Hello!' }],
  stream_options: { include_usage: true },
  stream: proc { |chunk| print chunk.dig('choices', 0, 'delta', 'content') }
})

If you'd rather not touch every call site, set Verica.init(..., stream_usage: true) and the wrapper injects stream_options: { include_usage: true } for you (merged into a private copy, never mutating your parameters hash). It is off by default because the extra usage chunk arrives with an empty choices array that some caller procs may not expect.

Tool calls are captured on both paths: a non-streamed response's message.tool_calls land on the span's output message, and streamed delta.tool_calls fragments are reassembled per index (arguments concatenated in arrival order). Tool-call-only turns (content null, tool_calls present), the shape of a tool-looping agent's intermediate turns, produce a span output too, so those turns never show an empty output in Verica.

Use (RubyLLM)

With RubyLLM plus its thoughtbot OpenTelemetry instrumentation, Verica.init alone is enough: the spans it emits are exported to Verica.

Serverless

Call Verica.flush (or Verica.shutdown) before the runtime freezes so the span batch is exported.

Options

Option / env var Default Notes
token: / VERICA_TOKEN (required) ingest-scoped API token
capture_content: / VERICA_CAPTURE_CONTENT true send prompt/response content
conversation_id: (none) stamps gen_ai.conversation.id
tags: (none) global tags; per-request: Verica.with_tags
service_name: / OTEL_SERVICE_NAME app resource service.name
stream_usage: / VERICA_STREAM_USAGE false inject stream_options for tokens
debug: / VERICA_DEBUG false log export errors

Fail-open by design: if Verica is unreachable or the token is invalid, spans are dropped and your app is never affected. Export errors are silent unless debug is on.