Span Lifecycle and Attributes in Python OpenTelemetry
A span is the atomic unit of a distributed trace, and every span moves through a fixed lifecycle: creation, context attachment, attribute enrichment, status assignment, termination, and export. Getting this lifecycle right is what separates a queryable trace graph from a pile of orphaned, oversized, or silently dropped spans. This guide sits within the broader Distributed Tracing and OpenTelemetry in Python guide, and it builds directly on a correctly initialized provider from OpenTelemetry SDK Setup. It pairs with context propagation and baggage, which governs how a span's context survives a network hop, and it feeds directly into sampling strategies for distributed tracing, the child page that decides which of these spans you actually keep.
Key implementation areas covered below:
- Span creation, naming, and context attachment mechanics across sync and async code.
- Attribute cardinality, type validation, and length-limit enforcement under semantic conventions.
- Status code assignment, span events, links, and exception recording.
- Termination guarantees and the export pipeline under sustained load.
Prerequisites
Install the API and SDK together with matched versions. A mismatch between opentelemetry-api and opentelemetry-sdk is the most common cause of ImportError during provider construction, because the SDK imports private API symbols that move between minor releases.
pip install \
"opentelemetry-api>=1.30.0,<2.0.0" \
"opentelemetry-sdk>=1.30.0,<2.0.0" \
"opentelemetry-semantic-conventions>=0.51b0,<1.0.0"
The examples assume a TracerProvider registered as the global provider. If you have not set one up, follow the deterministic bootstrap in OpenTelemetry SDK Setup first. The environment variables below are read once during SDK initialization, so export them before the process imports your bootstrap module:
export OTEL_SERVICE_NAME="order-service"
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317"
export OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT=1024
export OTEL_BSP_MAX_QUEUE_SIZE=4096
Concept and Architecture
A span records one operation: an HTTP handler, a database call, a queue consume. It carries a span context (trace ID, span ID, trace flags), a parent reference, a start and end timestamp, a set of attributes, an ordered list of events, zero or more links, and a status. The trace ID is shared by every span in the request; the parent reference is what reconstructs the tree.
The Python SDK tracks the "current" span using PEP 567 contextvars. When you call tracer.start_as_current_span(), the SDK does two things: it creates the span, and it sets that span as the active one in the current context. Any span you start while it is active becomes its child automatically. This implicit parenting is the entire reason synchronous tracing works without you threading a span object through every function, and it is the same mechanism that contextvars-based request tracing uses on the logging side.
It helps to separate three things the word "span" can mean. There is the live span object you hold and mutate inside the with block; there is its immutable SpanContext — the trace ID, span ID, and flags that identify it and propagate to other services; and there is the finished, read-only span the SDK hands to the processor after end(). You set attributes and status on the live object; you read the SpanContext to correlate with logs or metrics; and the processor only ever sees the immutable finished form. A common confusion is trying to mutate a span after it has ended — the SDK ignores the call rather than raising, so the change vanishes silently. Everything you want recorded must happen before the with block exits.
Asynchronous event loops complicate this because work is suspended and resumed across await points. contextvars propagate correctly across await, but they do not propagate across a raw thread boundary or a ThreadPoolExecutor submission unless you carry the context explicitly. Detaching a span from its logical execution path produces an orphaned span whose parent reference points at nothing the backend can resolve. The rules for carrying context across those boundaries are detailed in context propagation and baggage and expanded for event-loop work in async tracing patterns.
There are two ways to start a span. start_as_current_span() returns a context manager that both activates the span and guarantees end(). start_span() returns a detached span you must activate and end yourself; reserve it for cases where the span outlives a single function scope, such as a long-running stream where start and end happen in different callbacks, or a background job whose parent request has already returned.
A span also carries a SpanKind that the backend uses to assemble service topology. SERVER marks the inbound side of a remote call, CLIENT the outbound side, and PRODUCER and CONSUMER the two ends of an asynchronous message hop. A CLIENT span in one service and the SERVER span it triggers in the next share a trace ID and form a parent-child edge; that edge is how a distributed map gets drawn. Leaving everything INTERNAL produces correct traces but a flat, hop-less topology, so set kind deliberately on anything that crosses a process boundary — which is exactly what the framework instrumentors described in instrumenting Python web frameworks do on your behalf for inbound requests.
Span names deserve the same discipline as attributes. The name is the primary grouping key in every backend, so it must be low-cardinality: GET /orders/{order_id}, not GET /orders/8831. A name that embeds an identifier fragments your latency aggregates into thousands of one-sample buckets, and no amount of attribute hygiene recovers from it. Put the variable part in an attribute (order.id) and keep the route template in the name.
Sampling intersects the lifecycle at exactly one point: span creation. The configured sampler runs inside start_as_current_span() and decides whether the span records and exports. A non-recording span is cheap — set_attribute and add_event become near no-ops — which is why you should never guard instrumentation behind your own if span.is_recording() checks for correctness; the SDK already short-circuits the work. The exception is when computing an attribute value is itself expensive (a JSON serialization, a hash), in which case the is_recording() guard saves real CPU. How that sampling decision is made and propagated is the subject of sampling strategies for distributed tracing.
Step-by-Step Implementation
Step 1 — Acquire a tracer. Get a named tracer from the global provider. The name should identify the instrumenting library or module, not the service; the service identity already lives on the Resource. Backends surface this as otel.scope.name, which lets you distinguish your own instrumentation from a library's.
from opentelemetry import trace
tracer = trace.get_tracer("order-service.checkout")
Step 2 — Start the span and bind context. Use the context manager form so the span is both activated and guaranteed to end. Pass kind to describe the span's role; SERVER, CLIENT, PRODUCER, and CONSUMER drive backend topology views, while INTERNAL is the default for in-process work. Pass any attribute the sampler needs at creation time, since attributes added later are invisible to the sampling decision.
from opentelemetry.trace import SpanKind
with tracer.start_as_current_span(
"process_order",
kind=SpanKind.INTERNAL,
attributes={"order.tier": "gold"}, # visible to the sampler
) as span:
... # span is now the active span; children attach automatically
Step 3 — Set attributes with semantic conventions. Attributes must be primitives (str, bool, int, float) or homogeneous sequences of one primitive type. Mixing types in one sequence raises a validation error, and None values are dropped silently. Prefer the documented semantic convention keys (http.request.method, db.system, messaging.destination.name) so dashboards and alerts written against one service work across all of them.
span.set_attributes({
"order.id": "ORD-991",
"order.total": 149.99,
"http.request.method": "POST",
})
Step 4 — Annotate moments with events. An event is a timestamped annotation inside the span: a retry, a cache miss, a validation failure. Events cost far less than a child span and are the right tool for marking a moment that has no meaningful duration.
span.add_event("inventory_reserved", {"warehouse": "us-east-1", "retry.count": 1})
Step 5 — Record outcome. Set StatusCode.ERROR only when the operation fails its contract, and record the exception first so the stack trace is preserved as an exception event. StatusCode.UNSET is the correct default for success; explicitly setting OK is rarely needed and can hide a downstream override. The three-state status model is deliberate: UNSET means "no opinion, treat as success," OK means "definitively succeeded, do not override," and ERROR means "failed." Reserve OK for the rare case where a downstream auto-instrumentation might otherwise mark a span as failed and you need to assert success — for example, a 404 that is an expected, handled outcome rather than a fault.
from opentelemetry.trace import Status, StatusCode
try:
charge_payment()
except PaymentError as exc:
span.record_exception(exc) # captures type, message, stacktrace
span.set_status(Status(StatusCode.ERROR, str(exc)))
raise
One nuance that surprises people: start_as_current_span() defaults to record_exception=True and set_status_on_exception=True, so an exception that escapes the with block is already recorded and already marks the span as ERROR. The explicit handler above is for the case where you want a message of your own or you catch and continue. If you both record manually and re-raise, pass record_exception=False to the span factory, or accept that the finished span will carry two exception events describing the same failure.
Step 6 — Let it end and export. Exiting the with block calls end(), stamping the end time and handing the finished span to the registered span processor. With a BatchSpanProcessor, export happens on a background thread, off the request path.
Links are the enrichment mechanism that events and attributes cannot cover. A link, created at span start, points at another span context that is causally related but not the parent: the canonical case is a batch consumer whose single span links to the many producer spans that contributed messages. Reach for links when one span has several upstream causes rather than one — a fan-in that a parent-child edge simply cannot express.
from opentelemetry.trace import Link
links = [Link(msg.span_context) for msg in batch] # one per contributing producer
with tracer.start_as_current_span("consume_batch", kind=SpanKind.CONSUMER, links=links) as span:
span.set_attribute("messaging.batch.message_count", len(batch))
The export path is asynchronous by design. The BatchSpanProcessor enqueues each finished span and a worker thread drains the queue every schedule_delay_millis or whenever max_export_batch_size accumulates. This decoupling is what keeps tracing off the critical path, but it has a failure mode: if the queue fills faster than the exporter drains it, spans are dropped silently once max_queue_size is reached. The drop is intentional back-pressure, not an error, so under sustained load you tune max_queue_size and schedule_delay_millis rather than expecting an exception.
Configuration Reference
These limits and processor settings are the levers that govern span size and export behavior. Environment variables are read once at SDK initialization; the constructor parameters override them.
| Env var / parameter | Type | Default | Production value | What it controls |
|---|---|---|---|---|
OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT |
int | 128 | 128 | Attributes kept per span; extras are dropped |
OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT |
int | unlimited | 1024 | Longer string values are truncated |
OTEL_SPAN_EVENT_COUNT_LIMIT |
int | 128 | 64 | Events kept per span; extras are dropped |
OTEL_SPAN_LINK_COUNT_LIMIT |
int | 128 | 128 | Links kept per span; extras are dropped |
OTEL_BSP_MAX_QUEUE_SIZE / max_queue_size |
int | 2048 | 4096 | Spans buffered before drops begin |
OTEL_BSP_MAX_EXPORT_BATCH_SIZE / max_export_batch_size |
int | 512 | 512 | Spans per export call |
OTEL_BSP_SCHEDULE_DELAY / schedule_delay_millis |
int (ms) | 5000 | 2000 | Interval between forced exports |
OTEL_BSP_EXPORT_TIMEOUT / export_timeout_millis |
int (ms) | 30000 | 10000 | Per-export deadline |
For limits that must differ per service without touching the environment, construct SpanLimits explicitly and hand it to the provider:
from opentelemetry.sdk.trace import TracerProvider, SpanLimits
limits = SpanLimits(
max_attributes=64, # tighter than the 128 default
max_events=32,
max_attribute_length=1024, # truncate long strings deterministically
)
provider = TracerProvider(span_limits=limits)
Distinguish resource attributes from span attributes. Resource attributes (service.name, deployment.environment) describe the producer and are attached once on the TracerProvider. Span attributes describe one operation. Copying static resource data onto every span inflates payload size with no analytical gain.
The limits matter more than they first appear because the SDK enforces them silently. A value that exceeds OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT is truncated without warning, so a serialized payload you stuffed into an attribute arrives at the backend half-present and you debug a "corrupt" value that was simply cut. An attribute past the count limit is dropped entirely. Set the value-length limit deliberately — many backends index only the first few hundred characters of a string field anyway, so a limit of 1024 both protects the wire and matches what the backend can actually search. There is no per-attribute override; the limits are global to the SDK, which is the right level since they exist to protect the export pipeline, not individual spans.
Cardinality is the other budget, and it is the one that costs money. A span attribute with millions of distinct values inflates the backend's index the same way an unbounded Prometheus label does; the reasoning in controlling label cardinality transfers directly. The practical rule is that anything you want to group or filter by belongs in an attribute, and anything you only want to read once you have found the trace belongs in an event, a log line carrying the trace ID, or the request body — not in an indexed field.
A useful discipline is to set attributes as early in the span as you have the data, not all at the end. The SDK applies count and length limits at set time, and a sampler that inspects attributes only sees attributes passed at span creation through the attributes= argument — attributes added later with set_attribute are invisible to the sampling decision. If a route or tenant drives sampling, pass it at start_as_current_span() time, not afterward.
Async and Concurrency Considerations
In a pure asyncio handler, start_as_current_span() behaves exactly as it does synchronously because contextvars follow the coroutine across await. The danger appears at concurrency boundaries the SDK cannot see. When you offload CPU-bound work with loop.run_in_executor() or hand a job to a background thread, the new thread starts with an empty context, so a span created there has no parent.
The fix is to capture the active context and re-attach it inside the worker. The example below uses contextvars.copy_context(), which snapshots the active OpenTelemetry context so the executor thread runs the child span under the correct parent.
import asyncio
import contextvars
from opentelemetry import trace
tracer = trace.get_tracer("async-handler")
def cpu_bound(n: int) -> int:
# Runs in a worker thread; the parent context was copied in by run_in_executor
with tracer.start_as_current_span("hash_payload") as span:
span.set_attribute("payload.size", n)
return sum(i * i for i in range(n))
async def handle_request(payload_size: int) -> int:
with tracer.start_as_current_span("async_handler") as span:
span.set_attribute("processing.duration_ms", 50)
await asyncio.sleep(0.05)
loop = asyncio.get_running_loop()
ctx = contextvars.copy_context()
# ctx.run rebinds the captured OTel context inside the worker thread
return await loop.run_in_executor(None, lambda: ctx.run(cpu_bound, payload_size))
asyncio.run(handle_request(10000))
Without ctx.run, the hash_payload span would become a second root span. With it, the backend renders a clean async_handler → hash_payload parent-child edge.
The same principle applies to asyncio.create_task(), but with a subtler default. A task created from inside an active span inherits a copy of the current context at creation time, so a span started in the task is correctly parented — provided you create the task while the parent span is still active. Create the task after the parent's with block exits and the parent context is already gone, leaving the task's span orphaned. Two patterns avoid this: create all child tasks inside the parent span's scope, or capture the context explicitly and wrap the coroutine. For long-lived background tasks that outlive the request, prefer an explicit start_span() with a captured parent context over relying on the ambient one, because the request's context will be torn down long before the task finishes.
Concurrent spans also change what "current" means. Under asyncio.gather(), several child spans are open at once, and each coroutine sees its own current span because each runs in its own context copy — but code that reaches for trace.get_current_span() from a shared helper called outside those coroutines gets whichever span the enclosing context holds, which is usually the parent. Pass the span (or the value you want recorded) explicitly to shared helpers rather than relying on ambient lookup when concurrency is in play. Client libraries that fan out requests, such as the sessions covered in instrumenting aiohttp client requests, hit this constantly.
The BatchSpanProcessor is thread-safe and its worker thread is separate from the event loop, so ending a span never blocks a coroutine. What can block is force_flush(), which waits for the queue to drain up to its timeout — never call it inside a request handler. Reserve it for shutdown and for tests.
Shutdown is the other place async tracing leaks data. A BatchSpanProcessor holds spans in memory between flushes, so a process that exits without flushing loses everything still queued. In a container that means registering a SIGTERM handler that calls provider.shutdown(), which flushes and then stops the worker thread. Web frameworks expose a cleaner hook: an ASGI lifespan shutdown or a Flask teardown that calls force_flush() before the process returns. The FastAPI setup guide wires this into the lifespan context manager, and tracing SQLAlchemy async queries shows the same discipline applied to a connection pool that outlives individual requests.
Production Code Examples
Full span lifecycle with events, status, and graceful flush
This end-to-end example wires a provider, records a span event, handles an error path, and flushes pending spans on shutdown so a scaling pod loses nothing.
import signal
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.resources import Resource
from opentelemetry.trace import Status, StatusCode, SpanKind
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
# 1. Resource attributes describe the producer once, not per span
resource = Resource.create({"service.name": "order-service"})
provider = TracerProvider(resource=resource)
provider.add_span_processor(BatchSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("order-service.checkout")
def process_order(order_id: str, total: float) -> str:
with tracer.start_as_current_span("process_order", kind=SpanKind.INTERNAL) as span:
span.set_attributes({"order.id": order_id, "order.total": total})
span.add_event("inventory_reserved", {"warehouse": "us-east-1"})
try:
if total <= 0:
raise ValueError("non-positive order total")
span.set_attribute("payment.status", "captured")
return "success"
except ValueError as exc:
span.record_exception(exc) # record before status
span.set_status(Status(StatusCode.ERROR, str(exc)))
raise
# 2. Flush on SIGTERM so a scaling pod exports buffered spans
def _shutdown(*_):
provider.shutdown()
signal.signal(signal.SIGTERM, _shutdown)
if __name__ == "__main__":
process_order("ORD-991", 149.99)
provider.force_flush()
Expected Output:
{
"name": "process_order",
"context": {"trace_id": "0x8a3c...", "span_id": "0x7b2f...", "trace_state": "[]"},
"kind": "SpanKind.INTERNAL",
"parent_id": null,
"status": {"status_code": "UNSET"},
"attributes": {
"order.id": "ORD-991",
"order.total": 149.99,
"payment.status": "captured"
},
"events": [
{"name": "inventory_reserved", "attributes": {"warehouse": "us-east-1"}}
],
"resource": {"attributes": {"service.name": "order-service"}}
}
Asserting the lifecycle in tests with an in-memory exporter
Instrumentation is code, and the cheapest way to keep it honest is a test that inspects finished spans directly. InMemorySpanExporter behind a SimpleSpanProcessor gives you the exported spans synchronously, with no collector and no timing flakiness.
# pip install "opentelemetry-sdk>=1.30.0,<2.0.0" "pytest>=8.0.0,<9.0.0"
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from opentelemetry.trace import StatusCode
@pytest.fixture
def exporter():
# SimpleSpanProcessor exports on end(), so assertions never race a batch timer
exporter = InMemorySpanExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)
yield exporter
exporter.clear()
def test_failed_order_is_marked_error(exporter):
with pytest.raises(ValueError):
process_order("ORD-000", -1.0)
(span,) = exporter.get_finished_spans()
assert span.name == "process_order"
assert span.status.status_code is StatusCode.ERROR
assert span.attributes["order.id"] == "ORD-000"
assert any(e.name == "exception" for e in span.events)
Expected Output:
test_span_lifecycle.py::test_failed_order_is_marked_error PASSED [100%]
1 passed in 0.04s
Two assertions in that test are worth keeping in every instrumentation suite: that the status is ERROR on the failure path, and that an exception event exists. Those two facts are what tail-based sampling and error dashboards key on, so a regression in either silently degrades production visibility long before anyone notices a missing trace.
Correlating a span with metrics via exemplars
A span ID embedded in a histogram bucket is an exemplar: it lets a latency spike on a dashboard link straight to the trace that caused it. The trace-side requirement is simply that the span be sampled and recording when the metric is observed. The metric side is covered in Python Metrics and Instrumentation, but the join key is the span context read here. The same SpanContext is what adding trace IDs to log records writes into every log line, which is what makes a three-signal jump from metric to trace to log possible.
from opentelemetry import trace
with tracer.start_as_current_span("checkout") as span:
ctx = span.get_span_context()
# Pass trace_id/span_id to your metric recording so backends attach an exemplar
labels = {"trace_id": format(ctx.trace_id, "032x"), "sampled": ctx.trace_flags.sampled}
# record_latency_histogram(value, exemplar=labels)
Expected Output:
checkout span_id=0x7b2f... sampled=True -> histogram bucket carries this exemplar
Common Mistakes
Unbounded attribute cardinality. Error signature: backend indexing failures, slow trace search, ballooning storage cost. Root cause: high-cardinality values such as raw user IDs, UUIDs, or full request bodies attached as span attributes. Remediation: keep attributes low-cardinality; route per-user debugging data into structured logs carrying the trace ID, or into context propagation and baggage when it genuinely must cross services.
High-cardinality span names. Error signature: a trace list with thousands of near-identical operation names and useless latency percentiles. Root cause: interpolating an identifier into the span name, as in GET /orders/8831. Remediation: name the span after the route template and move the identifier into an attribute; framework instrumentors already do this, so the mistake usually appears in hand-written spans.
Manual span.end() without scope cleanup. Error signature: orphaned spans, leaked context, incorrect parent-child edges in async or threaded code. Root cause: using start_span() plus a manual end() while forgetting to detach the attached context token. Remediation: prefer start_as_current_span() as a context manager; if you must use a detached span, pair context.attach() with context.detach() in a finally block, or wrap it in trace.use_span(span, end_on_exit=True).
Mutating a span after it has ended. Error signature: an attribute or event that appears in the code but never in the backend, with no error anywhere. Root cause: enriching a span from a callback or finally block that runs after the with block exited. Remediation: set everything before the block exits; if enrichment genuinely happens later, keep the span open with a detached start_span() and end it when the work truly completes.
Overriding resource attributes at span level. Error signature: inflated OTLP payloads, duplicated service.name on every span. Root cause: setting static topology metadata as span attributes instead of on the Resource. Remediation: define service.name, deployment.environment, and similar once during provider initialization.
Setting ERROR status before recording the exception. Error signature: error traces with no stack trace event. Root cause: an early raise after set_status skips record_exception. Remediation: always call span.record_exception(exc) first, then set_status(StatusCode.ERROR). Error status also drives retention in tail-based sampling strategies for distributed tracing, so a missing status can cost you the trace entirely.
Related Reading
- Distributed Tracing and OpenTelemetry in Python — the parent guide covering the full tracing pipeline.
- Sampling Strategies for Distributed Tracing — which of these spans you keep, decided at head or tail.
- OpenTelemetry SDK Setup — the provider, processor, and exporter configuration these examples assume.
- Context Propagation and Baggage — how a span's context survives the hop to the next service.
- Async Tracing Patterns — span lifecycles across event loops, executors, and background tasks.
- Adding Trace IDs to Log Records — using the span context to correlate logs with traces.
- Recording exceptions and span events — why recording an exception does not fail a span, and the limits that keep one span readable.
Frequently Asked Questions
How do OpenTelemetry attribute limits affect Python span performance?
Exceeding the default limits triggers attribute truncation or span drops. Configure OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT and OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT to align with your backend indexing capacity rather than letting values be silently cut.
Should I use span events or log attributes for high-frequency debugging?
Use span events for discrete, trace-correlated occurrences such as a retry or a cache miss. For high-frequency, high-cardinality data, use structured logging with trace ID injection to avoid bloating the trace and throttling the backend.
How does the span lifecycle interact with async Python frameworks like FastAPI or aiohttp?
Async frameworks need explicit context attachment and detachment, or the official instrumentation packages, to keep trace continuity across event loops and prevent context leakage between concurrent requests.
Do I need to call span.end() myself?
Not when you use start_as_current_span as a context manager, which calls end() even on unhandled exceptions. You only call end() manually when you start a detached span with start_span and manage its scope yourself.
When should I use a span link instead of a child span?
Use a child span when one operation causes another within the same trace. Use a link when a span has several causally related but separate origins, such as a batch consumer processing messages produced by many different traces.
Why did my attribute disappear after I set it?
Three common causes: the value was None or a mixed-type sequence and was dropped by validation, the span had already ended so the mutation was ignored, or the attribute exceeded OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT and was discarded.