All docs
EvaluationGet scoringOpenTelemetrycode

OpenTelemetry

One line at start-up sends the traces you already produce — from ADK, Agent Engine or any GenAI-instrumented framework — and every agent run is evaluated.

What you'll have

Your OpenTelemetry traces evaluated alongside your observability backend, with each agent run scored as a step of one session.

You'll need
  • A project and its API key — generate one under API Keys
  • pip install "variably-sdk[otel]"
  • Agent, model or tool spans (GenAI semantic conventions, or Google ADK tracing)

How it works

Your app
  1. Traces as it already does
  2. Calls instrument() once at start-up
  3. Runs at full speed
The SDK
  1. Adds a background batch exporter
  2. Keeps your other exporters
  3. Retries and backs off on its own
variA/Bly
  1. Each trace becomes a session
  2. Scored like SDK traffic
  3. Same dashboards

Already tracing your application with OpenTelemetry? One line at start-up sends the same traces to variA/Bly, and every agent run in them is evaluated — trajectory, tool use, loops and, when the content is recorded, grounding. Your existing tracing backend keeps receiving everything.

1. Install

pip install "variably-sdk[otel]"

This adds the OpenTelemetry SDK and its standard OTLP exporter.

2. Set two environment variables

VARIABLY_API_KEY=vbl_your_key_here
VARIABLY_BASE_URL=https://api.variably.dev

The API key is scoped to one project — everything it sends lands there and nowhere else.

3. Add one line at start-up

from variably.integrations.otel import instrument

instrument()

Call it once at start-up, before your application handles requests. It adds a batching processor to the global tracer provider — or creates one if none is set — that exports spans in the background. If you set up your own provider, do that first and pass it in (below): OpenTelemetry keeps the first global provider and ignores a later one. Calling it twice is harmless.

Already have a tracer provider? Pass it, and your other processors and exporters are untouched:

from opentelemetry.sdk.trace import TracerProvider
from variably.integrations.otel import instrument

provider = TracerProvider(resource=resource)
provider.add_span_processor(your_existing_processor)
instrument(provider)

Want to wire the exporter yourself? It is a standard OTLP span exporter:

from opentelemetry.sdk.trace.export import BatchSpanProcessor
from variably.integrations.otel import span_exporter

provider.add_span_processor(BatchSpanProcessor(span_exporter(), max_queue_size=4096))

If the API key or base URL is missing, instrument() raises at start-up rather than failing silently later. In a short script, call .force_flush() on the processor it returns before exiting.

4. Make sure your spans carry what is evaluated

variA/Bly reads spans that follow the OpenTelemetry GenAI semantic conventions and Google ADK's native spans; everything else in your traces is ignored.

Google ADK and Vertex AI Agent Engine

ADK emits these spans itself, with message content, so nothing else is needed:

from variably.integrations.otel import instrument
instrument()                     # before the Runner is created

runner = Runner(agent=root_agent, app_name="claims-assistant", session_service=session_service)

Other frameworks and model SDKs

Use an instrumentation that emits GenAI spans — for example the OpenAI one — and switch on content capture, which is off by default for privacy:

pip install opentelemetry-instrumentation-openai-v2
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor
from variably.integrations.otel import instrument

instrument()
OpenAIInstrumentor().instrument()

Without content capture, structure is still evaluated — trajectory, tool use and loops — but there is no answer text, so grounding is not.

Your own agents

If your agents are hand-written, wrap each agent run and each tool call in a span with these attributes — this is exactly what is read:

import json
from opentelemetry import trace
from variably.integrations.otel import instrument

instrument()
tracer = trace.get_tracer("my-agents")

def run_researcher(question, session_id):
    with tracer.start_as_current_span("invoke_agent researcher") as agent:
        agent.set_attribute("gen_ai.operation.name", "invoke_agent")
        agent.set_attribute("gen_ai.agent.name", "researcher")
        agent.set_attribute("gen_ai.conversation.id", session_id)   # groups runs into one session

        with tracer.start_as_current_span("execute_tool search_policy") as tool:
            tool.set_attribute("gen_ai.operation.name", "execute_tool")
            tool.set_attribute("gen_ai.tool.name", "search_policy")
            tool.set_attribute("gen_ai.tool.call.arguments", json.dumps({"query": question}))
            result = search_policy(question)
            tool.set_attribute("gen_ai.tool.call.result", json.dumps(result))

        with tracer.start_as_current_span("chat") as llm:
            llm.set_attribute("gen_ai.operation.name", "chat")
            llm.set_attribute("gen_ai.request.model", "gpt-4o")
            answer = write_answer(question, result)
            llm.set_attribute("gen_ai.output.messages", json.dumps(
                [{"role": "assistant", "parts": [{"type": "text", "content": answer}]}]))
        return answer

What gets evaluated

From your traceBecomes
An invoke_agent spanOne step, named by gen_ai.agent.name
Its model callsThe step's answer text, model and token counts
Its execute_tool spansTool calls with arguments, result or error — trajectory, tool correctness and loop detection
A transfer_to_agent call with no answer textA routing step — grounding is not applicable rather than zero
An agent run inside another agent's tool (ADK AgentTool)A sub-agent step attached to the same session
The top-level agent with the latest answerThe final step (aggregator) — its summary is checked against the others
gen_ai.conversation.id, or ADK's session idThe session; without one, each trace is its own session
Vertex AI Search, RAG Engine and Google Search grounding on the model responseThe evidence grounding is checked against
A tool result with a list under documents, results, chunks, passages, contexts or hits, each with content, text, snippet, page_content, chunk or passageEvidence too — your own retriever, recognised automatically
A trace with no agent spansOne step from its model and tool spans

Every step is tagged otlp and lands in the same dashboards, checks and sessions as SDK traffic.

When scores appear

A trace is evaluated about ten seconds after its last span ends, so late spans from the same run are included; a span arriving up to ten minutes late is still added to its session. Scores then follow within seconds, or longer when Max-Accuracy escalates a claim.

Never on your request path

Spans leave through OpenTelemetry's batch processor on a background thread. If variA/Bly is slow or unreachable your requests are unaffected: the exporter retries and backs off, or drops in the background. Under load we ask the exporter to retry later — spans are never accepted and then silently discarded.

What OpenTelemetry cannot carry

Traces describe what happened, not what should have happened. These need the ADK plugin or observe():

  • Expected tools — trajectory and tool correctness against the path you expect, rather than structure alone;
  • Allowed tools — flagging any call outside an allow-list;
  • Custom evidence — retriever results in a shape not listed above;
  • Reference documents for a document review.

Verify it

Run one request, then open the project's Observations experiment and its Sessions tab. Each agent run should appear as a step with its tools. If nothing arrives:

  • check that instrument() ran before your framework created its tracer, and that the process lived long enough to flush;
  • turn on Python logging at DEBUG for the opentelemetry logger to see export errors;
  • a step with no answer text means content capture is off.

Using an OpenTelemetry Collector rather than in-process exporting? We will set up the collector configuration with you — get in touch.