Spans
The instrumentation creates spans for HTTP requests, DB queries, Celery tasks, cache calls, and outbound HTTP automatically. To measure sub-operations like business logic, third-party API calls, or batched work, create spans using the OTel Tracer API.
Getting a Tracer
Call trace.get_tracer(__name__) once per module. Tracers are cheap, cached by name, and safe to keep at module scope:
from opentelemetry import trace
tracer = trace.get_tracer(__name__)The recommended pattern is start_as_current_span as a context manager. It activates the span, runs the block, ends the span on exit, and records any exception that propagates out:
class OrderService:
def process_order(self, order_id: int) -> None:
with tracer.start_as_current_span("order.validate"):
self._validate_order(order_id)
with tracer.start_as_current_span("order.charge"):
self._charge_payment(order_id)Exception handling inside
start_as_current_span: by default, if the block raises, the span callsrecord_exception(exc)andset_status(ERROR)before re-raising. Both happen automatically. This is different from some other OTel SDKs (e.g. PHP'skeepsuit/laravel-opentelemetry::measure) which only callrecord_exceptionand leave the status unset. If you want to suppress the auto-status (for example you're treating aHttp404as a normal outcome, not an error), passset_status_on_exception=False:with tracer.start_as_current_span("lookup-user", set_status_on_exception=False) as span: try: return User.objects.get(pk=user_id) except User.DoesNotExist: span.set_attribute("user.found", False) return None
set_status_on_exception=Falseonly suppresses the span status. Theexceptionevent is still recorded if the exception escapes the block, and TracePath still opens an Issue from it. Catch the exception inside the block, as above, if you don't want that.
With Attributes
Set attributes on the span via the context manager's binding, or via trace.get_current_span() from anywhere inside the block:
with tracer.start_as_current_span("stripe.charge") as span:
span.set_attribute("payment.amount", amount)
span.set_attribute("payment.currency", "usd")
return stripe.charge(amount)To set initial attributes when the span is created (useful when sampling decisions depend on them), pass them through attributes=:
with tracer.start_as_current_span(
"stripe.charge",
attributes={"payment.amount": amount, "payment.currency": "usd"},
):
return stripe.charge(amount)Nested Spans
Child spans opened with start_as_current_span are automatically activated, so any further spans nest under them:
with tracer.start_as_current_span("order.fulfill"):
with tracer.start_as_current_span("inventory.reserve"):
reserve()
with tracer.start_as_current_span("payment.charge"):
charge()
with tracer.start_as_current_span("email.send"):
notify()Manual Span Management
If you need finer control (e.g., spans that cross function boundaries or that need to outlive a with block), open the span manually and activate it with trace.use_span for the lifetime of the work:
from opentelemetry import trace
tracer = trace.get_tracer(__name__)
span = tracer.start_span("long-running-job")
with trace.use_span(span, end_on_exit=True):
do_work()Setting end_on_exit=True makes use_span close the span when the block exits. Always pair start_span with either use_span(..., end_on_exit=True) or an explicit span.end() in a finally block. Otherwise the span leaks and is never exported.
Root spans need a kind. Everything above assumes there is already a parent span, which is the case inside a request or a Celery task. A span you open at the top of a script, a cron entry, or a management command is a root span, and a root span with the default
INTERNALkind is dropped by TracePath with no error. Passkind=SpanKind.CONSUMERso it becomes a Task. See Tasks.
Other Tracer Utilities
A handful of helpers on the OTel API surface come in handy when working with custom spans:
from opentelemetry import trace
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
trace.get_current_span() # the currently active Span
trace.get_current_span().get_span_context() # SpanContext (trace_id, span_id, flags)
format(trace.get_current_span().get_span_context().trace_id, "032x") # trace id as hex string
# Inject the active trace context into outbound headers. Most users don't need this,
# because requests/httpx/urllib3 instrumentation already injects W3C headers automatically.
carrier: dict[str, str] = {}
TraceContextTextMapPropagator().inject(carrier)
# carrier now contains "traceparent" (and "tracestate" if set)Adding Attributes
Attach metadata to spans for filtering and debugging:
with tracer.start_as_current_span("db-query") as span:
span.set_attribute("db.system", "postgresql")
span.set_attribute("db.statement", "SELECT * FROM users WHERE id = $1")
rows = run_query()
span.set_attribute("db.row_count", len(rows))For DB queries you go through Django's ORM, you don't need to do this. opentelemetry-instrumentation-psycopg2 (or your DB driver's matching instrumentation) sets db.system / db.statement automatically.
Recording Errors on Spans
When a span's operation fails and you don't want to rely on the auto-record behavior (for example you catch the error, downgrade it to a warning, and don't re-raise), record the exception and set the status explicitly:
import requests
from opentelemetry import trace
from opentelemetry.trace import Status, StatusCode
tracer = trace.get_tracer(__name__)
with tracer.start_as_current_span("external-api-call") as span:
try:
response = requests.get("https://api.example.com/data", timeout=5)
response.raise_for_status()
except requests.RequestException as exc:
span.record_exception(exc)
span.set_status(Status(StatusCode.ERROR, str(exc)))
raiseSpan Naming Conventions
Use descriptive names that indicate the operation type:
| Good | Bad |
|---|---|
db.users.find | query |
cache.sessions.get | cache |
stripe.charge | api |
s3.upload-image | upload |
email.send-welcome | send |