Instrumenting Redis Clients and Cache Operations
Record cache.hit as a boolean on each cache span and template the key rather than recording it, then decide deliberately whether every command gets a span — because a service issuing forty cache calls per request produces more spans from Redis than from everything else combined.
Context and when it matters
Cache instrumentation answers a different question from database instrumentation. A Redis GET takes a few hundred microseconds; its duration is almost never the problem. What matters is whether it hit, because a cache that is fast and always missing is strictly worse than no cache — you pay the round trip and then do the expensive work anyway.
The second reason to care is volume. Cache clients are the most common cause of span-volume blowouts, and the resulting pressure lands on the batch processor and the storage bill rather than on the application. Deciding how much cache detail you actually need is part of instrumenting it.
Hit ratio from spans
from opentelemetry import trace
tracer = trace.get_tracer("app.cache")
def get_product(product_id: str):
with tracer.start_as_current_span("GET product") as span:
span.set_attribute("db.system.name", "redis")
span.set_attribute("db.operation.name", "GET")
# Key TEMPLATE — bounded. The literal key is one value per product and
# has no business being an aggregation dimension.
span.set_attribute("cache.key.template", "product:{id}")
raw = redis.get(f"product:{product_id}")
hit = raw is not None
# Exactly two values: safe to promote into span metrics for a dashboard.
span.set_attribute("cache.hit", hit)
if hit:
return deserialize(raw)
# The miss path stays inside the same trace, so the waterfall shows
# miss → database read → cache write as one causal chain.
product = load_from_db(product_id)
redis.setex(f"product:{product_id}", 300, serialize(product))
return product
Controlling span volume
Auto-instrumentation creates one span per command, which is right for a service issuing three cache calls per request and badly wrong for one issuing forty. Three strategies, in increasing order of aggression:
Aggregate per request. One span covering all cache work, with counts as attributes. You lose per-key timing and keep the ratio, which is usually the thing you actually query.
class CacheSession:
"""One span per request instead of one per command."""
def __init__(self):
self.hits = self.misses = 0
self.span = tracer.start_span("cache session")
def get(self, key_template, key):
raw = redis.get(key)
if raw is None:
self.misses += 1
else:
self.hits += 1
return raw
def close(self):
self.span.set_attribute("cache.operations", self.hits + self.misses)
self.span.set_attribute("cache.hits", self.hits)
self.span.set_attribute("cache.misses", self.misses)
# Ratio recorded as a bucket keeps it usable as a dimension.
total = self.hits + self.misses
ratio = self.hits / total if total else 1.0
self.span.set_attribute("cache.hit_ratio.bucket",
"high" if ratio > 0.9 else "medium" if ratio > 0.6 else "low")
self.span.end()
Span only on miss. Hits are cheap and uninteresting; misses cause work. Recording a span only when the cache misses cuts volume by the hit ratio — typically 90% — while keeping every span that explains latency. The cost is that hit ratio can no longer be computed from spans, so it must come from a counter metric instead.
Sample cache spans independently. Keep per-command spans but drop most of them in the Collector, retaining all misses and errors.
# collector-config.yaml — thin out cache hits, keep everything interesting
processors:
filter/cache_hits:
error_mode: ignore
traces:
span:
# Drop 90% of successful cache hits by hashing the span id.
- 'attributes["db.system.name"] == "redis"
and attributes["cache.hit"] == true
and Hour(TruncateTime(end_time, Duration("1s"))) >= 0
and IsMatch(span_id.string, "^[0-9a-e]")'
Pipelines, Lua, and cluster hops
Pipelines batch many commands into one round trip. Instrumenting each command inside a pipeline produces spans whose durations are meaningless — they all end when the batch returns. One span for the pipeline with a command count is both cheaper and more accurate.
Lua scripts execute server-side and are atomic. They deserve a span named for the script’s purpose (cache.reserve_stock, not EVALSHA), because the script name is what you will want to group by when one of them starts blocking the single-threaded server.
Cluster redirects are invisible without instrumentation: a MOVED response causes the client to retry against another node, doubling latency. Recording db.redis.redirects on the span turns an unexplained bimodal latency distribution into an obvious slot-migration story.
# Pipeline: one span, not N
with tracer.start_as_current_span("cache pipeline") as span:
pipe = redis.pipeline()
for pid in product_ids:
pipe.get(f"product:{pid}")
results = pipe.execute()
span.set_attribute("db.system.name", "redis")
span.set_attribute("db.operation.name", "PIPELINE")
span.set_attribute("db.redis.command_count", len(product_ids))
span.set_attribute("cache.hits", sum(1 for r in results if r is not None))
Choosing a strategy per service, not per fleet
The four strategies above are not a ranking; the right choice depends on what a given service’s cache traffic looks like, and applying one policy fleet-wide is how teams end up with both excessive volume and insufficient detail.
A service issuing three cache calls per request should keep per-command spans. The volume is trivial, the detail is free, and aggregation would hide a genuinely interesting call. A service issuing forty should aggregate by default and switch to per-command spans temporarily when investigating something specific — an override behind a feature flag or an environment variable makes that a restart rather than a deployment.
The deciding number is cache spans per second, not per request. Forty per request at ten requests per second is four hundred spans a second, which is unremarkable; the same forty at a thousand requests a second is forty thousand, which will dominate everything else in your pipeline. Measure before choosing.
There is also a correctness argument for aggregation that has nothing to do with cost. A waterfall containing forty near-instantaneous cache spans is genuinely harder to read than one containing a single span with a count attribute — the eye has to scan past the noise to find the database call that actually took the time. Instrumentation that makes traces harder to read is a net negative even when the volume is affordable.
Whichever strategy you pick, keep the hit ratio available somewhere. If aggregation drops per-command spans, the ratio must come from counts on the aggregate span or from a metric; losing it entirely removes the one number that says whether the cache is doing its job.
Verification
- Group by
cache.hitin the backend and confirm the ratio matches Redis’s ownkeyspace_hitsandkeyspace_missescounters. A large discrepancy usually means some code path bypasses the instrumented client. - Count spans per request and confirm it matches your chosen strategy — a service that was supposed to aggregate still emitting forty spans means the aggregation wrapper is being bypassed somewhere.
- Check that misses lead somewhere. A miss span with no subsequent database work means the cache is being read and the result discarded.
- Confirm no literal keys appear in attributes:
grep 'product:8fa21c'over exporter output should return nothing.
Common pitfalls
- Recording the literal key. One distinct value per cached object; it is the cache equivalent of putting a user ID in a metric dimension.
- Timing pipelines per command. Every command appears to take as long as the whole batch, which makes the slowest-command analysis meaningless.
- Ignoring connection setup. A client that reconnects per request pays TCP and AUTH costs that dwarf the command itself, and neither shows up unless connection spans exist.
- Forgetting that misses are the interesting half. Instrumentation tuned to minimise volume by dropping misses removes exactly the spans that explain latency.
- Treating
cache.hitas optional. Without it there is no way to distinguish a healthy cache from a useless one, and the span becomes pure cost.
Related
- Instrumenting databases and cache clients — the wider layer-choice discussion
- Controlling span attribute cardinality — why the key template matters
- Configuring the batch and memory limiter processors — where cache span volume lands