Observability with Arize Phoenix and OpenTelemetry
Atomic Agents ships an optional OpenInference instrumentor. Once it
is installed, every AtomicAgent run and every BaseTool run becomes a span that
Arize Phoenix, Arize AX, Langfuse, or any other OpenTelemetry backend can
display. Nothing changes for applications that do not opt in.
What you get
Call |
Span |
Attributes |
|---|---|---|
|
|
|
|
|
|
Spans carry session.id, user.id, metadata and tags when you wrap calls in the OpenInference context
managers (using_session, using_user, using_metadata, using_attributes). Exceptions set the span status to
ERROR and are recorded as span events. Streaming spans record the final response when the stream completes,
or the last partial plus atomic_agents.stream.closed_early = true when the consumer stops early or the task
is cancelled (a cancelled stream is not reported as an error).
LLM spans are not produced by Atomic Agents. Install exactly one LLM instrumentor for the provider SDK you use
(openinference-instrumentation-openai, -anthropic, -litellm, …). Installing both a provider instrumentor and
openinference-instrumentation-instructor gives you two layers of LLM spans. Because the agent span is the current
span while the provider call runs, the LLM span (with prompts, tokens and the model name) nests under it. Instructor
retries show up as several LLM spans under one agent span.
Install
pip install "atomic-agents[observability]" arize-phoenix-otel openinference-instrumentation-openai
The observability extra installs openinference-instrumentation, openinference-semantic-conventions and
opentelemetry-api. The Phoenix path below works without the extra because arize-phoenix-otel already depends on
those packages; the manual path needs the extra plus an OpenTelemetry SDK and exporter:
pip install "atomic-agents[observability]" opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
Setup with Phoenix (automatic)
from phoenix.otel import register
tracer_provider = register(project_name="my-app", auto_instrument=True)
register(auto_instrument=True) discovers the Atomic Agents instrumentor through the openinference_instrumentor
entry point and activates it together with every other OpenInference instrumentor in the environment. Do this once,
at import time or in your service’s startup hook, before any agent runs. Phoenix reads PHOENIX_COLLECTOR_ENDPOINT,
PHOENIX_PROJECT (alias PHOENIX_PROJECT_NAME) and PHOENIX_API_KEY from the environment. With no endpoint set,
register() exports over gRPC to localhost:4317; set PHOENIX_COLLECTOR_ENDPOINT=http://localhost:6006 to send
HTTP to the port the Phoenix UI serves on.
Two things to know about the automatic path:
Phoenix imports
atomic_agentsto load the entry point. That import is the same as anyimport atomic_agentsin your application, including the Instructor streaming patch the framework applies at import.Existing
auto_instrument=Trueusers start exporting agent and tool payloads when they upgrade to a version with the instrumentor. Use the privacy controls below if that is not wanted.
Setup with any OpenTelemetry backend (manual)
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from openinference.instrumentation import TraceConfig
from openinference.instrumentation.openai import OpenAIInstrumentor
from atomic_agents.observability import AtomicAgentsInstrumentor
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter("http://localhost:6006/v1/traces")))
AtomicAgentsInstrumentor().instrument(tracer_provider=tracer_provider, config=TraceConfig())
OpenAIInstrumentor().instrument(tracer_provider=tracer_provider)
instrument() is idempotent. Calling it again with a different provider or config logs a warning and keeps the first
installation; call uninstrument() first to change it. uninstrument() restores the original methods on
AtomicAgent and on every tool class.
Grouping agent and tool calls into one trace
Atomic Agents has no tool-calling loop: your code calls agent.run(...), then tool.run(...), then another agent.
Each of those is a root span, so without a parent they become separate traces. Open one parent span per unit of work:
tracer = tracer_provider.get_tracer(__name__)
with tracer.start_as_current_span("answer_question", openinference_span_kind="chain") as turn:
turn.set_input(question)
plan = planner.run(BasicChatInputSchema(chat_message=question))
calculation = calculator.run(CalculatorToolInputSchema(expression=plan.expression))
answer = answerer.run(CalculationResultSchema(question=question, expression=plan.expression, result=calculation.result))
turn.set_output(answer.chat_message)
The phoenix-tracing example is exactly this flow.
Sessions, users and metadata
from openinference.instrumentation import using_session, using_user, using_metadata
with using_session(session_id=conversation_id), using_user(user_id=user.id), using_metadata({"tenant": tenant}):
response = agent.run(user_message)
Phoenix groups spans that share a session.id into a conversation view. For a streaming endpoint, put the context
manager inside the generator that yields the chunks, not around the endpoint function; the generator runs after the
endpoint returned, so a context manager around the endpoint body no longer applies.
Privacy
Agent and tool payloads are exported as input.value / output.value. To redact them:
On the automatic Phoenix path, set
OPENINFERENCE_HIDE_INPUTS=trueand/orOPENINFERENCE_HIDE_OUTPUTS=true.TraceConfig()reads these variables.On the manual path, pass
TraceConfig(hide_inputs=True, hide_outputs=True)toinstrument().
Redacted values are exported as __REDACTED__. atomic_agents.system_prompt.sha256 stays available either way. It
hashes the static sections of a SystemPromptGenerator (background, steps, output instructions) and ignores what
context providers add per call, so it identifies the prompt version even when inputs are hidden. Multimodal inputs (images, audio, PDFs) are serialized into
input.value in full; cap attribute size with OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT if that is too much.
Services and async
Call
register(...)(orinstrument(...)) at import or in the startup hook so every worker process is instrumented.Use
register(batch=True)(or aBatchSpanProcessor) in services so span export does not block request handling.Concurrent
run_asynccalls, includingasyncio.gatherover several agents, get independent spans.Streaming token counts need
model_api_parameters={"stream_options": {"include_usage": True}}for OpenAI.
Limitations
Methods bound before instrumentation stay untraced:
callback = agent.runtaken beforeinstrument()keeps calling the original.An
AtomicAgentsubclass that overridesrun(or a stream method) without callingsuper()bypasses the wrapper and emits no span; add@tracer.agentto such overrides yourself.Tool methods that are async generators are left untraced.
A plain
ThreadPoolExecutorstarts its worker with an empty context, so a tool that submits work to one loses the current span and the re-entrancy guard inside the pool. Submit through a copied context instead, as the forge tools do:executor.submit(contextvars.copy_context().run, asyncio.run, self.run_async(params)).asyncio.to_threadandasyncio.runalready copy the context.The AGENT span records the final (or last partial) response of a stream, not every chunk.
The system prompt text is not on the AGENT span; it is the first message on the LLM span. The AGENT span carries its SHA-256 instead.