Tracing Databases and Message Queues in Python

Most of a typical Python request's time is spent waiting on something outside the process — a database, a cache, a broker — and the spans for those calls are the ones that most often explain a slow trace. Automatic instrumentation covers the common client libraries well; the work that remains is sanitising what gets recorded, adding the attributes that make spans queryable, carrying trace context through a broker, and deciding how consumer spans relate to producers. This guide covers the principles, and its child pages cover tracing PostgreSQL queries with psycopg, tracing Redis calls in Python and tracing Kafka producers and consumers in Python. It is part of the distributed tracing and OpenTelemetry in Python section.

Where the client spans come from A request handled by an orders service produces a server span. Inside it, the database driver's instrumentation creates a client span for each SQL statement, carrying the database system, the operation, the table and a sanitised statement template. The cache client's instrumentation creates a client span for each cache command. The producer instrumentation creates a producer span when an order event is published to a topic, and injects the trace context into the message headers. Later, a separate consumer service receives the message; the consumer instrumentation extracts the context from the headers and creates a consumer span related to the producer span, so the consumer's own database spans appear in relation to the original request. The note records that every arrow into a store or broker is a client or producer span, and that the only arrow that needs explicit care is the header carrying context across the broker. one request, three kinds of dependency orders service POST /orders — server INSERT orders — client SET cart:… — client publish order-events PostgreSQL Redis Kafka topic traceparent in message headers fulfilment consumer extracts context from headers related to the producer span every arrow into a store is a client span; only the broker hop needs context carried explicitly
Database and cache calls are traced by their client instrumentation. The broker is the one hop where context has to travel inside the data, in message headers.

Prerequisites

pip install "opentelemetry-instrumentation-psycopg>=0.48b0,<1.0.0" \
            "opentelemetry-instrumentation-redis>=0.48b0,<1.0.0" \
            "opentelemetry-instrumentation-confluent-kafka>=0.48b0,<1.0.0" \
            "opentelemetry-sdk>=1.27.0,<2.0.0"

Concept and architecture

Every call a service makes to a database, cache or broker is a client span — or, for publishing, a producer span — with the same basic shape: the system, the operation, the target, and how long it took. What varies between libraries is how much detail the instrumentation records and how safe that detail is.

Databases get a span per statement, carrying the database system, the operation, the target table and the statement text. The statement is the most useful attribute and the most dangerous: with placeholders it is a template that groups well and contains no data; with values interpolated it is a unique string per execution containing whatever the query was filtering on. Tracing slow SQL queries in Python covers making statement spans groupable and ranking them.

Caches get a span per command. The command is useful; the full key often is not, because keys typically embed identifiers — cart:u_8f21c9 — which makes the attribute unbounded and may expose data. Recording the key's prefix or pattern rather than the full key keeps the span useful and safe.

Brokers get a producer span per publish and a consumer span per message processed, and the relationship between them is the interesting part. The producer instrumentation injects trace context into message headers; the consumer instrumentation extracts it. With that in place, the consumer's work — including its own database calls — can be related back to the request that published the message, which is often the only way to explain why a downstream effect happened.

The relationship itself is a design decision. A consumer span as a child of the producer span makes one trace cover the whole path, and that trace's duration includes however long the message sat in the queue — seconds or hours. A consumer span as a new root with a link to the producer keeps each trace's duration meaningful, at the cost of navigating between two traces. Adding span links for batch work discusses the trade in detail.

Step-by-step implementation

Step 1 — Enable instrumentation for every client library. Each driver or client has its own package. Enabling them at startup, before connections are created, means every call is traced.

from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor
from opentelemetry.instrumentation.redis import RedisInstrumentor
from opentelemetry.instrumentation.confluent_kafka import ConfluentKafkaInstrumentor

PsycopgInstrumentor().instrument(enable_commenter=True)
RedisInstrumentor().instrument()
kafka = ConfluentKafkaInstrumentor()
producer = kafka.instrument_producer(Producer(conf))
consumer = kafka.instrument_consumer(Consumer(conf))

Step 2 — Verify statements are sanitised. Before trusting the spans — and certainly before shipping them to a store that more people can read than can read the database — inspect one: the statement should contain placeholders, not values. If it contains values, the driver or ORM is interpolating before execution, and the fix is at the query layer — parameterised queries — rather than in the instrumentation.

Step 3 — Bound cache key attributes. A request hook on the cache instrumentation can replace the full key with its prefix before the span is recorded, keeping the attribute's cardinality small and its content free of identifiers.

def redis_request_hook(span, instance, args, kwargs):
    if span.is_recording() and len(args) > 1 and isinstance(args[1], (str, bytes)):
        key = args[1].decode() if isinstance(args[1], bytes) else args[1]
        span.set_attribute("app.cache.key_prefix", key.split(":", 1)[0])

RedisInstrumentor().instrument(request_hook=redis_request_hook)

Step 4 — Add the attributes that queries depend on. Rows affected by a write, items returned by a read, the message size for a publish, the partition a message went to. These turn a span from "a call happened" into "a call happened and did this much work", which is what distinguishes a slow query that returned a million rows from one that returned one.

Step 5 — Propagate context through the broker and verify it. The producer and consumer instrumentations handle injection and extraction, provided both are enabled and nothing between them strips headers. A test that publishes and consumes through a local broker, and asserts that the consumer span relates to the producer span, confirms the path.

Step 6 — Apply one consumer convention across the fleet. Child or link, applied consistently and written down where every team will find it. Mixed conventions make cross-service traces unpredictable: some consumers appear inside the producer's trace, others in their own, and nobody can predict which without checking.

Child or linked root for consumers A request publishes a message and returns in eighty milliseconds. The message waits in the queue for forty seconds before a consumer processes it in two hundred milliseconds. With the consumer span as a child of the producer span, the request's trace now spans forty seconds, its root appearing to take far longer than the request actually did, and latency views computed from trace duration are distorted. With the consumer span as the root of its own trace, carrying a link to the producer span, the request's trace shows eighty milliseconds and the consumer's trace shows two hundred, and the link lets an engineer navigate from one to the other. A queue wait attribute on the consumer span records the forty seconds explicitly. The note records that both are valid, and that the linked form keeps durations honest at the cost of one extra navigation step. publish, wait 40 s in the queue, consume consumer as child trace root appears to last 40 s request 80 ms … queue … consume 200 ms consumer as linked root request trace: 80 ms consumer trace: 200 ms link both are valid; the linked form keeps every trace's duration honest record the queue wait as an attribute on the consumer span either way — it is often the real latency
A child consumer makes one trace cover the whole path and inflates its duration. A linked root keeps durations honest and needs one extra click.

Configuration reference

Client Instrumentation Key attributes Watch for
psycopg PsycopgInstrumentor db.system, db.operation.name, db.collection.name values in statements
SQLAlchemy SQLAlchemyInstrumentor as above, plus pool timing via events ORM query volume
asyncpg AsyncPGInstrumentor as above per-statement span volume
redis RedisInstrumentor db.system, command full keys as attributes
confluent-kafka ConfluentKafkaInstrumentor messaging.system, messaging.destination.name header stripping
aio-pika / pika messaging instrumentation as above consumer convention
SQL commenter enable_commenter=True trace context in the SQL joins to database logs

Async and concurrency considerations

Async drivers — asyncpg, redis's asyncio client, aiokafka — produce spans the same way, with one extra consideration: many concurrent operations share one event loop, and the span context for each comes from the task that issued it. As long as each operation is awaited within the request's task, or in a task spawned from it, spans are parented correctly. Operations issued from callbacks registered on long-lived connection objects — reconnection handlers, pool maintenance — run in whatever context was current when the callback was registered and should not be expected to belong to any request.

Connection pools affect span timing in a way worth knowing. A client span measures from the moment the instrumented call begins, which may include time waiting for a pooled connection before the operation reaches the server. Under pool exhaustion, database spans grow long while the database itself reports fast queries. Separating acquisition time from execution time, as in observing connection pool exhaustion, makes the difference visible.

For consumers, concurrency determines span structure. A consumer processing messages one at a time produces one consumer span per message. A consumer that pulls a batch and processes items concurrently should create a span per message inside the batch, each related to its own producer, or a batch span with links — never a single span parented to one arbitrary message.

Production code examples

A consumer loop that extracts context, relates the consumer span to the producer by link, and records the queue wait:

import time
from opentelemetry import trace, context
from opentelemetry.propagate import extract
from opentelemetry.trace import Link, SpanKind

tracer = trace.get_tracer("fulfilment")

def handle(msg) -> None:
    headers = {k: v.decode() for k, v in (msg.headers() or [])}
    producer_ctx = trace.get_current_span(extract(headers)).get_span_context()
    links = [Link(producer_ctx)] if producer_ctx.is_valid else []

    with tracer.start_as_current_span(
        f"process {msg.topic()}",
        kind=SpanKind.CONSUMER,
        context=context.Context(),                   # new root for this trace
        links=links,
    ) as span:
        span.set_attribute("messaging.system", "kafka")
        span.set_attribute("messaging.destination.name", msg.topic())
        span.set_attribute("messaging.kafka.message.offset", msg.offset())
        _, ts_ms = msg.timestamp()
        span.set_attribute("app.queue_wait_ms", max(0, int(time.time() * 1000) - ts_ms))
        fulfil(msg.value())

Expected Output: a consumer trace that records how long the message waited and links back to the request that sent it.

process order-events   CONSUMER  204 ms  app.queue_wait_ms=40112  links=1
  SELECT inventory     CLIENT     31 ms
  UPDATE shipments     CLIENT     18 ms

Reading dependency spans during an incident

Dependency spans are where an incident investigation usually spends most of its time, and a few reading habits make them quicker to interpret.

Compare the span with the server's own view. A database span of eight hundred milliseconds says the client waited that long. It does not say the database took that long. The difference — network, connection acquisition, client-side result processing — is often the whole story. SQL commenting, which puts the trace context into the statement, lets the span be matched to the database's own record of the same execution, which states how long the server spent. If the server says four milliseconds, the problem is on the client side of the connection.

Look at the distribution across the same statement, not one span. One slow execution of a normally fast statement points to lock contention or a cold cache at that moment; every execution being slow points to the statement or its plan. Grouping spans by statement template and comparing percentiles across the incident window separates the two.

Look at what surrounds the slow span. A slow database span preceded by a gap may be waiting on a pool; one followed by a gap may have returned a large result that the application then processed slowly. The spans before and after, and the gaps between them, often explain more than the slow span itself.

For consumers, check the queue wait first. A consumer trace that looks fast may belong to a message that waited an hour. The queue wait attribute from the example above — or the difference between the message timestamp and the consumer span's start — is frequently the latency a user actually experienced, and it is invisible in the consumer's own span duration.

Check the other side of the broker. When a consumer span is missing its relationship to a producer, the producer's span often still exists, and finding it by time and topic reconstructs the path manually. The pattern of which relationships are missing — all messages from one producer, or all messages through one bridge — usually identifies where headers are being lost.

Semantic conventions for data systems

The semantic conventions define attribute names for databases and messaging, and following them has the same benefit as for HTTP: every service's spans can be queried the same way, and backends render them with purpose-built views.

For databases, the core attributes are the system, the operation, the collection or table, and the query text — with the text sanitised. For messaging, they are the system, the operation (publish, receive, process), the destination name, and message identifiers where useful. Server address and port apply to both and identify which instance a client talked to, which matters for sharded or replicated stores.

Automatic instrumentation sets these, and manual spans for custom data access — a repository layer wrapping several calls, a bespoke client for an internal store — should use the same names. A manual span named load customer aggregate with db.system and db.collection.name set sits naturally alongside the driver's spans beneath it, and a query for all database activity against a collection finds both. The naming principles are the same as in naming spans and using semantic conventions.

Retries and transactions

Two behaviours of data clients complicate their spans and are worth handling deliberately.

Retries inside the client. Some drivers and most broker clients retry transient failures internally. Whether each attempt gets its own span depends on where the instrumentation hooks in: at the public API, one span covers every attempt and its duration includes the retries; at the protocol level, each attempt is visible. The first hides flapping, where a dependency fails and succeeds alternately; the second shows it. Where the instrumentation only offers the first, recording the attempt count as an attribute — from the client's own retry callback, if it has one — restores most of the information.

Transactions spanning several statements. A transaction is a unit the database sees as one, and the trace shows it as several statement spans. Wrapping the transaction in its own span — transaction or a business name such as place order transaction — makes its total duration visible and groups its statements, which matters because a transaction holds locks for its whole duration, not only while statements run. A transaction span that is much longer than the sum of its statements is holding locks while the application does something else, which is both a latency problem and a contention problem for every other request touching the same rows.

Deciding what to trace

Client instrumentation traces everything by default, and at high call volume that produces traces dominated by dependency spans. A few decisions keep them useful.

Keep database spans. They are the most common explanation for a slow request, and a span per statement is the right granularity for all but the most query-heavy endpoints — and for those, the span count is itself the finding, as detecting N+1 queries with traces explains.

Consider cache spans carefully. A request making two hundred cache reads produces two hundred spans that are each a fraction of a millisecond. Metrics — hit rate and latency by key prefix — describe cache behaviour better than spans do, and disabling cache instrumentation, or keeping only slow calls, often improves traces.

Always keep producer and consumer spans. They are few — usually very few — relative to database calls and they carry the context that joins services. Without them the asynchronous half of the system is invisible.

Record volume on spans rather than creating more spans. Rows returned, items processed, message size — an attribute on the existing span says how much work was done without adding to the span count.

Where a typical request's time goes A bar chart of time spent in one traced request to an orders endpoint that takes 180 milliseconds. Python code in the handler accounts for 12 milliseconds. Four PostgreSQL queries account for 96 milliseconds together, the largest share. Six Redis calls account for 9 milliseconds. Publishing one Kafka message takes 4 milliseconds before the producer acknowledges. Waiting for a connection from the pool takes 41 milliseconds, visible only if pool acquisition has its own span. The note says the dependency spans explain most of the latency, and the pool wait is the part most often missing from traces. one 180 ms request, by where the time went Python handler code 12 ms 4 PostgreSQL queries 96 ms pool connection wait 41 ms · often untraced 6 Redis calls 9 ms 1 Kafka publish 4 ms dependency spans explain most of the latency the pool wait is the part most often missing — give acquisition its own span
Most of a typical request's time is in dependencies. The wait for a pooled connection is the slice traces most often miss.

Common mistakes

Values in SQL statement attributes. Error signature: statement attributes with a unique value per execution and data in them. Root cause: interpolated SQL. Remediation: parameterised queries; check a real span.

Full cache keys as attributes. Error signature: an attribute with millions of distinct values, some containing identifiers. Root cause: default recording of the key. Remediation: record the prefix with a request hook.

One side of the broker uninstrumented. Error signature: consumer traces unrelated to any request. Root cause: the producer did not inject, or the consumer did not extract. Remediation: instrument both, and test the path.

Headers stripped in transit. Error signature: instrumented producer and consumer, and still no relationship. Root cause: a proxy, bridge or serialisation step dropping message headers. Remediation: carry context in the message body as a fallback, or fix the intermediary.

Mixed consumer conventions. Error signature: some consumers inside producer traces, others separate, unpredictably. Root cause: no fleet-wide decision. Remediation: choose child or link and apply it everywhere.

A long transaction span nobody looks at. Error signature: lock waits in the database and no obvious slow query. Root cause: a transaction held open across non-database work, visible only as a gap between statement spans. Remediation: a span around each transaction, and an alert when its duration far exceeds its statements'.

Retries hidden inside one span. Error signature: a dependency that looks uniformly slow and is actually failing and recovering. Root cause: client-internal retries under one span. Remediation: record the attempt count, or instrument at the attempt level.

Cache spans drowning the trace. Error signature: traces with hundreds of sub-millisecond spans and the useful ones buried. Root cause: default instrumentation at high call volume. Remediation: disable or filter cache spans and use metrics for cache behaviour.

Frequently Asked Questions

Does OpenTelemetry trace database calls automatically?

For supported drivers, yes: psycopg, asyncpg, mysqlclient, SQLAlchemy, redis, pymongo and others each have an instrumentation package that creates a client span per operation. Enabling them is a matter of installing the package and calling its instrument method, or using the launcher.

Are SQL parameter values recorded?

Instrumentations for drivers that use placeholders record the statement template, not the values. Drivers or ORMs that interpolate values before execution can produce statements containing data, which should be checked for on a real span before relying on it.

How does trace context cross a message queue?

The producer instrumentation injects the current context into message headers when publishing; the consumer instrumentation extracts it when processing. If either side is uninstrumented or strips headers, the consumer starts a new trace.

Should a consumer span be a child of the producer span?

Either is defensible. As a child, the trace covers the whole path but its duration includes time the message spent waiting in the queue. As a new root with a link, each trace's duration is honest. Choose one convention for the fleet.

Do cache calls need spans?

They get them by default, and at high call volume they can dominate a trace. Keeping them for the calls that matter, or relying on metrics for cache hit rates and latency, is often the better balance.