Tracing Redis Calls in Python
Redis is fast enough that a single request may issue dozens or hundreds of commands, and a span per command is trivially cheap to produce and easy to overdo. This page covers instrumenting redis-py, keeping keys — which usually embed identifiers — out of span attributes, tracing pipelines as the single round trip they are, and deciding when cache behaviour is better described by metrics than by spans. It is a task article under tracing databases and message queues, part of the distributed tracing and OpenTelemetry in Python section.
Prerequisites
pip install "redis>=5.0.0,<6.0.0" \
"opentelemetry-instrumentation-redis>=0.48b0,<1.0.0" \
"opentelemetry-sdk>=1.27.0,<2.0.0" \
"prometheus-client>=0.20.0,<1.0.0"
Implementation
Step 1 — Enable the instrumentation at startup. The instrumentor wraps redis-py's command execution for both the synchronous and asyncio clients. Every command produces a client span with the database system and the command name.
from opentelemetry.instrumentation.redis import RedisInstrumentor
RedisInstrumentor().instrument(request_hook=redis_request_hook)
Step 2 — Record the key prefix, not the key. Keys in most applications follow a type:identifier pattern — cart:u_8f21c9, product:8812, session:…. The full key as an attribute is unbounded in cardinality and may contain personal identifiers. A request hook that extracts the part before the first separator gives a bounded attribute that still says what kind of data was touched.
def redis_request_hook(span, instance, args, kwargs):
if not span.is_recording() or len(args) < 2:
return
key = args[1]
if isinstance(key, bytes):
key = key.decode(errors="replace")
if isinstance(key, str):
span.set_attribute("app.cache.key_prefix", key.split(":", 1)[0])
span.set_attribute("db.statement", args[0]) # the command name only
Step 3 — Treat pipelines as one operation. A pipeline sends many commands in one round trip, and the instrumentation records it as one span with the commands listed. That is the right model: on the wire it is one operation, and its duration is one round trip plus server processing. Rewriting loops of individual commands as pipelines improves both the trace and the latency.
def load_products(r, ids):
pipe = r.pipeline(transaction=False)
for pid in ids:
pipe.get(f"product:{pid}")
return pipe.execute() # one span, one round trip
Expected Output: a single span for the whole batch.
PIPELINE CLIENT 2.1 ms db.redis.pipeline_length=100 app.cache.key_prefix=product
Step 4 — Decide which commands deserve spans. Reads that happen hundreds of times per request, each in well under a millisecond, contribute little to a trace and a great deal to its size — and to the export volume, which is billed per span in many backends. Options range from sampling them within the trace to disabling cache instrumentation entirely and relying on metrics. Writes, slow commands and anything with side effects — EVAL, DEL, list operations used as queues — usually deserve spans.
Step 5 — Measure hit rate with metrics. Hit rate is the single most important number about a cache, and spans are the wrong place to compute it. Spans are sampled, and tail sampling in particular keeps slow and failing traces preferentially, so a hit rate computed from stored spans is biased. A counter of hits and misses labelled by key prefix — bounded — gives an exact rate from every request.
from prometheus_client import Counter, Histogram
CACHE = Counter("cache_requests_total", "Cache lookups", ["prefix", "result"])
CACHE_LATENCY = Histogram("cache_request_seconds", "Cache latency", ["prefix"],
buckets=(0.0001, 0.00025, 0.0005, 0.001, 0.0025, 0.005, 0.01))
def cached_get(r, key):
prefix = key.split(":", 1)[0]
with CACHE_LATENCY.labels(prefix).time():
value = r.get(key)
CACHE.labels(prefix, "hit" if value is not None else "miss").inc()
return value
Redis as a queue or lock
Redis is used for more than caching, and the other uses need different treatment.
As a queue. List operations — LPUSH and BRPOP — or streams used to pass work between services are messaging, not caching. The producer and consumer should propagate trace context just as with any broker: inject into the message payload, since Redis list entries have no header field, and extract on the consumer side. The consumer's span then relates to the producer's, and the queue wait — the time between push and pop — becomes visible as an attribute. The general patterns are in tracing databases and message queues.
As a lock. A distributed lock acquired with SET NX and a timeout, or a library built on it, can be the reason a request is slow — it waited for a lock held elsewhere. The acquisition should be its own span, with the wait time and whether it succeeded, because the individual commands are fast and the waiting happens in the retry loop around them. Without that span, lock contention appears as unexplained gaps between fast Redis commands.
As a rate limiter. A rate-limiting check typically runs on every request and is very fast. It is a candidate for no span at all, with a metric counting allowed and rejected requests, and an attribute on the server span recording the rejection when one happens.
The common thread is that the command-level spans the instrumentation produces are correct and often not the useful unit. The useful unit is the operation the commands implement — enqueue, acquire lock, check limit — and that deserves a manual span with attributes describing its outcome.
Reading Redis latency correctly
Redis spans are short, and interpreting them has a few pitfalls that longer database spans do not.
Client time dominates. A Redis command typically executes on the server in microseconds. A client span of two milliseconds is almost entirely network round trip, connection acquisition and serialisation. When Redis spans grow, the cause is rarely Redis itself; it is more often the network path, a saturated connection pool, or the event loop being too busy to process the response promptly. Comparing Redis spans with the server's own slow log — which records commands exceeding a threshold on the server side — separates the two quickly.
Large values are expensive on the client. A GET returning a two-megabyte serialised object is fast on the server and slow on the client, which must receive and deserialise it. A span that records the response size as an attribute makes this visible, and the fix — storing smaller values, or fetching only the fields needed with a hash — is a data-modelling change rather than a Redis one.
Blocking commands are supposed to be slow. BRPOP, BLPOP and XREAD with a block timeout wait for data by design. Their spans measure the wait, which may be seconds, and including them in a latency aggregate makes the aggregate meaningless. Excluding blocking commands from latency views, or instrumenting the consumer loop so the wait is recorded as queue idle time rather than as operation latency, keeps the numbers honest.
Cluster redirects add round trips. In a clustered deployment, a command sent to the wrong node is redirected, doubling its round trips. Spans that are consistently twice as long as their peers for certain key prefixes often indicate a client whose slot map is stale. The instrumentation records the host each command was sent to, which is enough to spot it.
Configuration options
| Concern | Recommendation | Why |
|---|---|---|
| Instrumentation | RedisInstrumentor at startup |
sync and asyncio clients |
| Keys | prefix only, via request hook | bounded, no identifiers |
| Pipelines | one span, command count | matches the round trip |
| High-volume reads | sample, filter, or drop | readable traces |
| Hit rate | metric by prefix | exact, unbiased |
| Queue usage | propagate context in the payload | consumer relates to producer |
| Lock usage | manual span around acquisition | contention made visible |
Verification
Check that no span carries a full key.
keys = [s.attributes.get("app.cache.key_prefix") for s in exporter.get_finished_spans()
if s.attributes.get("db.system") == "redis"]
assert all(k and ":" not in k for k in keys), keys
print(sorted(set(keys)))
Expected Output: a short, bounded list of prefixes.
['cart', 'price', 'product', 'session']
Common mistakes
Full keys as span attributes. Error signature: an attribute with millions of distinct values, some containing user identifiers. Root cause: default recording of arguments. Remediation: prefix only, via a request hook.
A loop of individual GETs. Error signature: traces dominated by hundreds of tiny Redis spans, and slow pages. Root cause: one round trip per key. Remediation: a pipeline or MGET.
Hit rate from spans. Error signature: a hit rate far lower than the application's own experience suggests. Root cause: sampling bias. Remediation: a metric counted on every lookup.
No span around lock acquisition. Error signature: gaps between fast commands during contention. Root cause: the waiting happens in the retry loop. Remediation: a manual span with wait time and outcome.
Blocking commands in latency views. Error signature: a Redis p99 of several seconds for a healthy cache. Root cause: BRPOP or XREAD waits counted as operation latency. Remediation: exclude blocking commands from latency aggregates.
Redis queues without propagation. Error signature: consumer traces unrelated to the requests that enqueued work. Root cause: no header field and no context in the payload. Remediation: inject into the payload, extract on pop.
Frequently Asked Questions
Does redis-py instrumentation record keys?
It records the command and, depending on version and configuration, its arguments, which include the key. Keys often embed identifiers, so recording them verbatim creates an unbounded attribute and may expose data. A request hook that records only the prefix is the usual remedy.
How are pipelines traced?
As a single span covering the whole pipeline execution, with the commands it contained recorded as an attribute. That matches how the pipeline behaves on the wire — one round trip — and is usually what an investigation needs.
Should every cache GET be a span?
Not necessarily. A request that performs two hundred cache reads produces two hundred sub-millisecond spans that bury the ones that matter. Metrics describe cache behaviour — hit rate, latency by prefix — more cheaply and more accurately.
Is the async Redis client traced too?
Yes. The instrumentation covers the asyncio client in redis-py, and spans are parented to the task that awaited the command.