Three ways to emit spans — auto-instrumentation, manual instrumentation, and hybrid — with trade-offs, mechanics, and the custom span processor pattern.
Auto-instrumentation automatically collects telemetry from supported libraries — no per-call code changes needed. Recommended whenever an OpenInference auto-instrumentor exists for your stack.
After this runs, every OpenAI call your application makes is traced — model name, input messages, output messages, token counts, finish reason, all set as attributes on an LLM span.For the full list of supported frameworks and providers, see Integrations.
Auto-instrumentors use a technique called monkey patching to wrap library functions with tracing logic. The mechanics:When you call OpenAIInstrumentor().instrument():
Identify — the instrumentor targets specific functions in the library (e.g., openai.chat.completions.create).
Wrap — the original function is replaced with a wrapper that:
Creates a span before the original function runs.
Calls the original function.
Sets span attributes from the function’s arguments and response (e.g., llm.input_messages, llm.output_messages, llm.token_count.*).
Ends the span.
The wrapping happens at import time (or at instrument() call time, depending on the library). After that, every call to the wrapped function flows through the tracing logic transparently.For the source of every OpenInference instrumentor, see the OpenInference repository.
Manual instrumentation means creating each span explicitly using the OTel tracer and setting attributes following the OpenInference semantic conventions.The most common pattern uses start_as_current_span so the new span automatically becomes a child of the most recent active span in the OTel context:
with tracer.start_as_current_span("span-name") as span: span.set_attributes({ "openinference.span.kind": "TOOL", "tool.name": "search", "input.value": query, "output.value": result, })
start_as_current_span does two important things:
It automatically sets the span as the active span in the OTel context, so any spans created inside the block become children.
It automatically calls span.end() when the context-manager block exits, so you don’t have to manage span lifetime by hand.
When spans are created without explicitly setting their context, they inherit the most recent active span as their parent_id — which is exactly what you want for nested operations.
Full control. You decide what spans to create, what attributes to set, and how the tree is shaped. Works for any code, not just libraries the instrumentor supports.
Cons
Tracing an entire application by hand is tedious. Custom decorators help. Instrumentation code lives alongside your application code, which adds maintenance burden and can distract from the core logic when reading the file.
Forgetting to end a span — spans are only handed off to the span processor when end() is called. A never-ended span never exports. Use start_as_current_span (which auto-ends) to avoid this entirely.
Not setting span status — set Ok or Error explicitly so the Arize AX UI can flag failures.
Missing important semantic conventions:
openinference.span.kind — without this, the Arize AX UI can’t pick the right icon.
input.value and output.value — without these, the trace list view shows blanks.
llm.input_messages and llm.output_messages on LLM spans — the chat history doesn’t render in the Arize AX UI without them.
tool.name and tool.parameters on tool spans — tool detail panels stay empty.
Auto-instrumentors are an easy way to begin tracing, but they only know about what the underlying library exposes. The most interesting parts of an AI application — tool execution in user code, custom business logic, agent loops, evaluation steps — often need extra spans or extra attributes the instrumentor can’t see.Hybrid instrumentation combines auto-instrumentation with targeted manual work. Three primary ways to extend an auto-instrumented trace:
Method
Pros
Cons
Manually add additional spans with tracer.start_as_current_span() while the traced application is running, setting attributes on the newly created span.
Full control over what’s added. Fairly easy to implement.
Depending on the instrumented library, it can be hard to insert a custom span at the right place in the trace tree.
OpenInference Context Managers like using_session, using_user, using_metadata — apply attributes to every span created inside their scope.
Very easy to implement. No per-span code.
Adds attributes to all spans in scope. Useful for cross-cutting context like session ID; less useful for per-span data.
Custom span processor — a class that mutates spans as they pass through the pipeline.
Centralized — one place to enrich or filter every span.
Custom span processors are the most powerful hybrid pattern. You write a class that implements on_start and on_end (and optionally the upcoming on_ending) and attach it to the Tracer Provider.What you can do at each hook:
Hook
Span state
What it’s good for
on_start(span, parent_context)
Mutable. Attributes may not be set yet, but name and span context can be edited.
Passed in as a ReadableSpan — intended to be immutable.
Filtering, enriching, or modifying attributes before export.
on_ending(span)
Mutable (still in development).
Last-moment attribute changes without the immutability workarounds.
The classic use case is PII redaction: walk every outgoing span and scrub sensitive values from the attributes. For practical examples, see Mask and redact data.
The ReadableSpan passed to on_end is intended to be immutable. In Python you can edit it anyway, but doing so affects every downstream processor and exporter attached to the same Tracer Provider. If you need to modify a span, prefer copying the ReadableSpan with edited attributes rather than mutating it in place.
For the full lifecycle details and how on_end immutability varies between Python and TypeScript, see the Span Processor page.