Arize AX¶
TraceVerde exports to Arize AX over plain OTLP.
There is no Arize integration in this library. There is no adapter, no
arize-otel, and no openinference-instrumentation-* package. Arize AX
consumes the OpenTelemetry GenAI semantic conventions natively, and TraceVerde
emits them, so the two interoperate through OTLP without a translation layer on
either side.
The entire configuration is four standard OpenTelemetry environment variables.
Quick Start¶
Get an API key and Space ID from app.arize.com
(Settings -> API Keys). ARIZE_SPACE_ID needs the base64 Space ID, not the
space name.
import os
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "https://otlp.arize.com"
os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = "http/protobuf"
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = (
f"space_id={os.environ['ARIZE_SPACE_ID']},api_key={os.environ['ARIZE_API_KEY']}"
)
os.environ["OTEL_RESOURCE_ATTRIBUTES"] = "openinference.project.name=my-project"
import genai_otel
genai_otel.instrument()
from openai import OpenAI
client = OpenAI()
client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What is OpenTelemetry?"}],
)
The project is created in Arize on first export. A runnable version of this is
in examples/arize_ax/.
Set the variables as real environment variables rather than in Python if you prefer - none of this needs to be in code:
export OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.arize.com
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
export OTEL_EXPORTER_OTLP_HEADERS=space_id=<id>,api_key=<key>
export OTEL_RESOURCE_ATTRIBUTES=openinference.project.name=<project>
Regions¶
| Region | Endpoint |
|---|---|
| US (default) | https://otlp.arize.com |
| EU | https://otlp.eu-west-1a.arize.com |
| Canada | https://otlp.ca-central-1a.arize.com |
Two details that are easy to get wrong¶
The endpoint has no path. The OTLP HTTP exporter appends /v1/traces
itself, so the base is the bare host. https://otlp.arize.com/v1 produces a
request to /v1/v1/traces and nothing arrives.
The project name is required. Arize rejects spans with HTTP 500 when it is
absent, and service.name alone is not enough.
openinference.project.name is the one vendor-namespaced value in this setup,
and it is supplied through the standard OTEL_RESOURCE_ATTRIBUTES variable
rather than any code change.
Short-lived scripts must flush before exiting, or the batch span processor is torn down with spans still queued:
from opentelemetry import trace
provider = trace.get_tracer_provider()
if hasattr(provider, "force_flush"):
provider.force_flush(30000)
For long-running services, use the SIGTERM flush described in Configuration instead.
Metrics go somewhere else¶
OTEL_EXPORTER_OTLP_ENDPOINT applies to every signal, and Arize's OTLP endpoint
ingests traces. Metric batches sent to it are rejected, which surfaces as a
retry loop in the log:
Traces are unaffected. Point metrics at their own destination with the standard signal-specific variable, which takes precedence over the generic one:
What Arize AX does with the spans¶
AX normalises the GenAI conventions onto its own OpenInference model at
ingestion. Sending nothing but gen_ai.*, a chat completion span arrives with:
| Arize field | Derived from |
|---|---|
openinference.span.kind = LLM |
inferred; TraceVerde sends a span kind only on agent spans (AGENT, see Agent graph) |
llm.provider, llm.system |
gen_ai.system |
llm.model_name |
gen_ai.request.model |
llm.finish_reason |
gen_ai.response.finish_reason |
llm.invocation_parameters |
gen_ai.request.* |
llm.token_count.prompt / .completion / .total |
gen_ai.usage.input_tokens / output_tokens |
llm.cost.* |
derived from the token counts |
All original gen_ai.* attributes are preserved alongside the normalised
fields, so the same span remains portable to any other OTLP backend. Nothing is
lost in the direction of Arize. The one Arize-shaped addition is the set of
agent graph attributes below, written alongside gen_ai.agent.name rather than
in its place.
Token counts must use the current conventions¶
Arize reads gen_ai.usage.input_tokens / gen_ai.usage.output_tokens - the
names introduced when semantic conventions v1.27.0 renamed prompt_tokens and
completion_tokens
(semantic-conventions#1200).
TraceVerde emits both spellings by default
(OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai/dup), so Arize resolves token counts and
cost correctly while dashboards still querying the superseded names keep
working. Setting gen_ai emits only the current names, which Arize also reads
correctly. Only the superseded-names-only configuration would report zero tokens
in AX.
What a real span carries¶
A single gpt-4o-mini chat completion, exported with no Arize-specific code:
gen_ai.operation.name = chat
gen_ai.provider.name = openai
gen_ai.system = openai
gen_ai.request.model = gpt-4o-mini
gen_ai.response.model = gpt-4o-mini-2024-07-18
gen_ai.request.max_tokens = 60
gen_ai.response.finish_reason= length
gen_ai.usage.input_tokens = 14
gen_ai.usage.output_tokens = 60
gen_ai.usage.total_tokens = 74
gen_ai.usage.prompt_tokens = 14 # dual emission
gen_ai.usage.completion_tokens = 60 # dual emission
gen_ai.usage.cost.prompt = 2.1e-06
gen_ai.usage.cost.completion = 3.6e-05
gen_ai.usage.cost.total = 3.81e-05
gen_ai.usage.cost.pricing_source = table
Streamed calls add the server-timing conventions, which AX charts alongside overall latency:
If the evaluation features are enabled, their verdicts ride on the same span as ordinary attributes and land in Arize with no extra wiring:
evaluation.pii.prompt.detected = False
evaluation.prompt_injection.detected = False
evaluation.toxicity.prompt.detected = False
evaluation.hallucination.response.score = 0.0
RAG traces¶
Embedding calls produce their own spans, so a retrieval-augmented trace arrives in Arize with both legs intact - the lookup and the generation - rather than the generation alone. Grouping them under a parent span sends the pipeline as one trace:
with tracer.start_as_current_span("rag.pipeline"):
client.embeddings.create(model="text-embedding-3-small", input=chunks)
client.embeddings.create(model="text-embedding-3-small", input=question)
client.chat.completions.create(model="gpt-4o-mini", messages=messages)
The embedding spans carry gen_ai.request.input_count,
gen_ai.response.vector_size and their own token counts and cost, priced from
the embeddings table. The embedded text is attached as embedding.text only
when GENAI_ENABLE_CONTENT_CAPTURE=true; retrieval inputs routinely contain
user data, so it is off by default.
Agent graph¶
Arize draws the agent graph and agent path from graph.node.id, which is
required, and graph.node.parent_id, which is optional: without it Arize infers
the parent from the span tree. graph.node.display_name labels the node. Every
span that names an agent through gen_ai.agent.name also gets:
| Attribute | Value |
|---|---|
agent.name |
the agent name |
graph.node.id |
the agent name, so repeat runs of one agent are one node |
graph.node.name, graph.node.display_name |
the agent name, as the node label |
graph.node.parent_id |
the nearest enclosing agent; left unset on a root agent |
openinference.span.kind |
AGENT |
The parent is found through the span tree, so an LLM or tool span between two
agents does not break the link. An agent nested inside a span of the same agent
(an AutoGen run and its on_messages) stays one node instead of becoming its own
parent. A span that declares a kind other than AGENT and sets no graph.node.id
is not a node, even when it names an agent. LLM, tool and transport spans are left
unannotated.
This covers CrewAI, AutoGen, AutoGen AgentChat, Google ADK, LangChain agents, OpenAI Agents, Pydantic AI and Strands.
A CrewAI kickoff is the root node, named after the crew (Crew(name=...), default
crew), with span kind CHAIN. Each agent execution is a node beneath it. The task
span names the agent that runs it but is a step (CHAIN), so each agent run appears
once, and two crews in one trace stay two graphs.
LangGraph graphs map node for node. Each node execution is a span carrying
metadata.langgraph_node / metadata.langgraph_step (what Arize reads for
LangGraph) and graph.node.id = the node name, with span kind CHAIN. The graph
run is the root node, named after the graph (compile(name="orchestrator"),
default LangGraph). A node that runs more than once, as in a loop, keeps one id,
and a subgraph's nodes hang under the node that called it.
A span that already carries any of these keys keeps its own value, so OpenInference instrumentors and hand-set attributes win.
To annotate your own agent code, pass the name when the span starts. Attributes set after the span starts are too late for the graph:
Set GENAI_ENABLE_AGENT_GRAPH_ATTRIBUTES=false to turn the extra attributes off.
Sessions¶
Arize groups traces into sessions by session.id. Wrap each turn of a conversation
in genai_otel.session(), with the same id for every turn:
import genai_otel
with genai_otel.session(conversation_id, user_id=user.id):
reply = graph.invoke(state)
Every span in the block carries session.id, gen_ai.conversation.id and
user.id: the graph, each node, and the LLM and tool calls inside them. Without a
block, a session the framework resolves is passed down the same way. For LangGraph
that is the config thread_id; for CrewAI, the crew id or inputs={"session_id": ...}.
Existing openinference.instrumentation.using_session() blocks work too, without
TraceVerde depending on OpenInference.
When a turn calls other services (an MCP server, an internal API), the session can go with it as W3C baggage, so their spans land in the same Arize session. Both sides opt in; see Across services.
Verifying a trace landed¶
pip install arize-ax-cli
ax profiles create --api-key $ARIZE_API_KEY --auth-method api-key
ax projects list -l 100 -o json
ax spans export <project-id> --trace-id <trace-id> --output-dir .arize-tmp-traces
Trace-ID lookups are immediately consistent once ingested. If a trace is missing, check that the exporter flushed, that the endpoint region matches your account, and that the project name resource attribute is present.
Sending to Arize and somewhere else at once¶
Because this is ordinary OTLP, an OpenTelemetry Collector can fan the same spans out to Arize and to any other backend without the application knowing. Point TraceVerde at the collector and configure the exporters there - no change to the four variables above beyond the endpoint.