Skip to content

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

pip install genai-otel-instrument[openai]

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:

Transient error Bad Gateway encountered while exporting metrics batch, retrying in 1.15s.

Traces are unaffected. Point metrics at their own destination with the standard signal-specific variable, which takes precedence over the generic one:

OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://localhost:4318/v1/metrics

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:

gen_ai.server.time_to_first_token  = 2.117
gen_ai.server.time_per_output_token = 0.0115

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:

with tracer.start_as_current_span("triage", attributes={"gen_ai.agent.name": "triage"}):
    ...

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.