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.
})
# ...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.