Adding OpenTelemetry tracing to async SQLAlchemy

Start a CLIENT span in before_cursor_execute, stash it on conn.info, and end it in after_cursor_execute and handle_error — the greenlet bridge copies the context, so the span is correctly parented to whatever request span was active when the query was issued. This is the tracing instrument from instrumenting and observing async queries.

Quick Answer

Why the span lands under the right parent Five stages. The framework opens a server span for the request and it is the active span in the handler task. The handler awaits a query, and AsyncSession hands the work to greenlet_spawn, which copies the current context into the greenlet. The before_cursor_execute listener runs inside that greenlet, so the active span it sees is still the request span, and the client span it starts is parented to it automatically. The span is stashed on the connection and ended in after_cursor_execute — or in handle_error, if the statement fails. The result is one child span per statement, under the request that issued it, with no manual context plumbing. the framework opens a server span active in the handler task the parent for everything greenlet_spawn copies the context into the greenlet running the ORM this is what makes it work before_cursor_execute starts a CLIENT span parented automatically stashed on conn.info after_cursor_execute ends it one span per statement or handle_error, on failure the trace shows the request and its queries no manual context plumbing Because the context is copied rather than shared, a span started in the listener cannot leak back into the handler task — which is exactly the isolation you want.

Before — a trace that stops at the handler:

# The request span exists; the queries inside it are invisible, so a slow
# endpoint shows as one long span with no explanation.

After — one span per statement:

from __future__ import annotations

from opentelemetry import trace
from opentelemetry.trace import SpanKind, Status, StatusCode
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncEngine

tracer = trace.get_tracer("sqlalchemy")


def install_tracing(async_engine: AsyncEngine, db_name: str) -> None:
    engine = async_engine.sync_engine

    @event.listens_for(engine, "before_cursor_execute")
    def _start(conn, cursor, statement, parameters, context, executemany):
        span = tracer.start_span(
            _operation_name(statement),
            kind=SpanKind.CLIENT,
            attributes={
                "db.system": "postgresql",
                "db.name": db_name,
                "db.statement": " ".join(statement.split())[:1000],
                "db.sqlalchemy.executemany": bool(executemany),
            },
        )
        conn.info["otel_span"] = span

    @event.listens_for(engine, "after_cursor_execute")
    def _end(conn, cursor, statement, parameters, context, executemany):
        span = conn.info.pop("otel_span", None)
        if span is None:
            return
        if cursor.rowcount is not None and cursor.rowcount >= 0:
            span.set_attribute("db.row_count", cursor.rowcount)
        span.end()

    @event.listens_for(engine, "handle_error")
    def _failed(context):
        conn = context.connection
        span = conn.info.pop("otel_span", None) if conn is not None else None
        if span is None:
            return
        span.record_exception(context.original_exception)
        span.set_status(Status(StatusCode.ERROR))
        span.end()

The handle_error listener is what stops a failing statement from leaving its span open forever — without it, the trace shows a query that never completed rather than one that failed.

Execution Context & Async Workflow Integration

The question people expect to be hard — does the span end up under the right parent when the ORM runs inside a greenlet? — turns out to be handled for you. greenlet_spawn copies the current contextvars context into the greenlet, and OpenTelemetry's context is a ContextVar, so the active span inside the listener is the same one that was active in the awaiting coroutine.

What that means practically is that no manual context plumbing is needed, and attempts to add some usually make things worse:

# Wrong — carrying the context by hand, which double-parents or detaches the span
from opentelemetry import context as otel_context

def _start(conn, cursor, statement, parameters, context_, executemany):
    ctx = otel_context.get_current()        # already correct; capturing it is redundant
    token = otel_context.attach(ctx)        # and attaching it here leaks the token
    ...
# Right — let the copied context do its job
def _start(conn, cursor, statement, parameters, context_, executemany):
    span = tracer.start_span(_operation_name(statement), kind=SpanKind.CLIENT)
    conn.info["otel_span"] = span

The one place manual propagation is needed is a background task, because a task created inside a request captures the context at creation time and then outlives the request span. A query issued by that task is parented to a span that has already ended, which most backends render as an orphan. Starting a fresh root span at the top of the background task, and linking it to the originating trace, gives a trace that reflects what actually happened:

async def send_receipt(order_id: int) -> None:
    link = trace.Link(trace.get_current_span().get_span_context())
    with tracer.start_as_current_span("send_receipt", links=[link]):
        async with Session() as session:
            ...

The same lifetime rule appears from a different angle in using expire_on_commit=False in FastAPI dependencies: a background task started inside a request outlives everything the request owned, spans included.

What to put on a database span Four attributes and one exclusion. db.system identifies the backend and is what lets a tracing backend render the span as a database call. db.name distinguishes one logical database from another in a service that talks to several. db.statement carries the parameterised SQL, which is safe because the values travel separately. A row count, added in the after hook, turns an ambiguous slow span into an obvious one — a query returning four hundred thousand rows explains itself. The bound parameter values must never be attached: span attributes are stored, indexed and shipped to a third party, which is a worse destination for user data than a log file. db.system postgresql renders as a database call set once db.name which logical database matters with several set once db.statement the parameterised SQL values travel separately safe to attach the parameter VALUES never attach these stored, indexed and shipped worse than a log file Add the row count in the after hook when the cursor exposes it: a slow span returning four hundred thousand rows has explained itself before anyone opens the SQL.

Resolving Warnings, Errors & Common Mistakes

SymptomRoot CauseProduction Fix
No database spans at allListeners attached to the AsyncEngine rather than engine.sync_engine.Attach to sync_engine; cursor events are emitted there.
Spans that never endA statement failed, so after_cursor_execute never ran.Add the handle_error listener, ending and marking the span.
Database spans appear as roots, not childrenThe query ran in a background task whose parent span has ended.Start a fresh span in the task and link it to the originating context.
The backend shows thousands of operationsThe full statement is being used as the span name.Derive a low-cardinality name from the verb and table; keep the SQL in db.statement.
Trace volume and cost explodeEvery statement is sampled, including health checks and pool pings.Sample at the request level and let database spans inherit the decision; skip SELECT 1.
Parameters visible in the tracing UISomething attached parameters as an attribute.Never attach values. The parameterised statement is safe; the values are not.

Advanced: Sampling, Noise and Span Naming

Tracing every statement in a busy service produces a volume nobody wants to pay for, and most of it is uninteresting. Three refinements make the trace both cheaper and more readable.

Sample at the request level, not the statement level. If the request span is sampled, its database children should be too — otherwise a sampled trace has gaps where queries should be, which is worse than not sampling. OpenTelemetry's ParentBased sampler does this by default; the mistake is configuring a separate sampler for the database instrumentation.

Skip the statements that carry no information. Pool pre-ping issues SELECT 1 on every checkout, and at a thousand requests per second that is a thousand spans per second saying nothing:

_UNINTERESTING = {"SELECT 1", "COMMIT", "ROLLBACK", "BEGIN"}


def _start(conn, cursor, statement, parameters, context, executemany):
    normalised = " ".join(statement.split())
    if normalised.upper() in _UNINTERESTING:
        return                              # no span, no stash, nothing to end
    ...

Note that skipping means storing nothing on conn.info, so the after hook's pop(..., None) finds nothing and returns — the two stay consistent without a flag.

Derive the name from the shape, not the text.

import re

_LEADING = re.compile(r"^\s*(?:/\*.*?\*/\s*)?(\w+)", re.S)


def _operation_name(statement: str) -> str:
    """A stable, low-cardinality operation name: the verb and the table."""
    match = _LEADING.match(statement)
    verb = match.group(1).upper() if match else "SQL"
    upper = statement.upper()
    table = ""
    if verb in {"SELECT", "DELETE"} and " FROM " in upper:
        table = statement[upper.index(" FROM ") + 6:].split()[0]
    elif verb == "INSERT" and " INTO " in upper:
        table = statement[upper.index(" INTO ") + 6:].split()[0]
    elif verb == "UPDATE":
        parts = statement.split()
        table = parts[1] if len(parts) > 1 else ""
    return f"{verb} {table.strip(chr(34)).split('.')[-1]}".strip()

That produces SELECT orders, INSERT order_items, UPDATE customers — a set whose size is bounded by the schema rather than by traffic. The regular expression skips a leading SQL comment, which matters because query-tagging middleware often prepends one and it would otherwise become the verb.

Span names must be low-cardinality Two naming schemes. A name built from the verb and the table — SELECT orders, INSERT order_items — produces a small, stable set of operation names, so the backend can show a p99 per operation and a call count per endpoint. Using the full statement text as the name gives every distinct query its own operation; with parameters already removed that is still hundreds of names, and with an IN list of varying length it is effectively unbounded. Backends respond to that by either dropping the excess or billing for it, and in both cases the aggregate view that made tracing worth adopting stops working. name = verb + table "SELECT orders", "INSERT order_items" a small, stable set of operations p99 per operation actually means something the full SQL still lives in db.statement name = the full statement hundreds of distinct operations unbounded with a varying IN list aggregates become meaningless and the backend drops or bills for it The statement itself is not lost — it belongs in the db.statement attribute, where it is searchable without becoming a grouping key.

Deciding Whether Tracing Is Worth It Yet

Tracing is the most expensive of the four instruments in operational terms — a collector to run, a backend to pay for, sampling to tune — and in a single-service application it answers questions that timing and counting already answer more cheaply.

The point at which it earns its place is when a request crosses a process boundary. Once a slow endpoint might be slow because of its own queries, or because of a downstream service's queries, no amount of per-service instrumentation resolves the ambiguity and a trace does it immediately.

The second point is when the same database is used by several services. A slow query attributed to "the orders database" is not actionable; a trace attributes it to a request, an endpoint and a deploy.

Before that, the honest ordering is: query counting first, because it catches the failure mode that actually degrades applications; slow-query logging second, because it needs no infrastructure; pool metrics third, because they distinguish a leak from a spike; tracing last. Adopting them in that order means each one is earning its keep before the next is added — and it means the tracing configuration, when it arrives, is being tuned by someone who already knows what the database is doing.

When tracing does arrive, the official opentelemetry-instrumentation-sqlalchemy package is a reasonable starting point and handles the sync_engine attachment for you. The hand-written listeners here are worth it when you need attributes it does not emit, want precise control over naming and sampling, or want one hook feeding traces, metrics and logs together rather than three separate instrumentations walking the same events.

Frequently Asked Questions

Do I need to propagate context manually across the greenlet bridge?

No. greenlet_spawn copies the contextvars context, and OpenTelemetry stores the active span in a ContextVar, so a span started inside a listener is parented correctly with no plumbing. Attempts to attach context by hand usually leak a token or double-parent the span.

Should I use the official SQLAlchemy instrumentation instead?

For a straightforward application, yes — it handles the sync-engine attachment and naming and is one line to enable. Hand-written listeners are worth it when you want attributes it does not set, tighter control over span naming and sampling, or a single hook feeding traces, metrics and logs at once.

Is db.statement safe to include?

Yes. The statement SQLAlchemy hands the driver contains placeholders, not values — the values travel separately in parameters and must never be attached. Truncate the statement so a very large IN list does not inflate every span.

Why do my database spans sometimes have no parent?

Almost always a background task: it captured the context at creation, and by the time it runs, the request span has ended. Start a new root span in the task and link it to the originating span context, which keeps the causal relationship without parenting to something already finished.