Skip to content

feat: record SemanticCache operation-duration and distance metrics (FDE-87) - #776

Open
vishal-bala wants to merge 8 commits into
feat/FDE-83-otel/02-FDE-86-foundationfrom
feat/FDE-83-otel/03-FDE-87-metrics
Open

vishal-bala wants to merge 8 commits into
feat/FDE-83-otel/02-FDE-86-foundationfrom
feat/FDE-83-otel/03-FDE-87-metrics

Conversation

@vishal-bala

@vishal-bala vishal-bala commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Applications using SemanticCache can't see its hit rate, how long its operations take, or how close a miss came to matching. The last of these is what you need to tune distance_threshold. This change records two OpenTelemetry histograms through the redisvl[otel] plumbing from the PR below.

Changes

Two histograms

  • redisvl.semantic_cache.operation.duration (s), recorded for check, store, update and drop and their async forms. Attributes:

    • the cache name;
    • the operation;
    • the result (hit or miss) on check;
    • error.type on failure.

    The count split by result is the hit rate, so there is no separate counter. The duration covers the whole call, including any prompt embedding. The spans in the next PR separate the two.

  • redisvl.semantic_cache.distance (1), the nearest candidate's distance on every lookup that found one, hit or miss, with the cache name and the result. It is read from the KNN candidates before the threshold filter, which is why a miss still reports how close it came.

Both histograms set bucket advice:

  • Duration buckets start at 100 µs, because a lookup on local Redis takes about 250 µs before embedding, while the GenAI buckets start at 10 ms.
  • Distance buckets are 0.01 wide up to 0.1, then coarser up to 2, the maximum cosine distance.

Applications can override either with an SDK View.

Recording never changes what the caller sees

  • Errors. error.type names the exception's class the way the SDK does, with a module prefix except for builtins, so a Redis ConnectionError and the builtin one stay distinct. The exception message is never recorded, and the exception re-raises unchanged.
  • Cancellation. A cancelled call records nothing.
  • Attributes. Prompts, filters, entry ids and distances never become metric attributes.
  • Telemetry failures. A failure inside recording is logged at debug level and dropped. One example is an opentelemetry-api older than 1.30, installed by another package, rejecting the bucket advice.

Tests

  • Integration tests read both histograms back through an in-memory reader, sync and async. They cover:
    • each operation's attributes;
    • nearest-first distance on hits and misses;
    • no distance point for an empty cache or a filter that excludes everything.
  • A hermetic unit test checks that a failing meter never reaches the caller.

Notes

This PR sits in a stack on top of the redisvl[otel] foundation, and below the spans and the user guide. It should merge together with them, not on its own.

This PR records two of the three published metrics. The third, gen_ai.client.operation.duration, is named in the foundation and recorded from the spans PR above, together with the embeddings spans that time the same provider calls.

Reviewer Brief

Start here: redisvl/extensions/cache/llm/_telemetry.py, timed(). It wraps check, store, update and drop and their async forms, and _record_duration builds every duration point's attributes.

Where it could fail

  • semantic.py:515 and :602: the distance is recorded after the TTL refresh, so a lookup interrupted during the refresh records nothing. Moving it earlier brings back distance points for lookups the caller never received.
  • _telemetry.py:93 (record_nearest_distance): it takes the first candidate as the nearest, which holds only because VectorQuery sorts by distance by default.
  • _telemetry.py:27 (_record_duration): metric attributes must stay low-cardinality and free of content. A filter or tag value added here would be exported unconditionally.

Not verified: cluster mode; the opentelemetry-api 1.30.0 floor, not re-run in this review.

Beyond CI: the TTL-refresh interruption test fails against the code before the fix (2 failed).

Reviewed c70b7ed3 · Claude Code (claude-opus-5-5) · lenses: correctness, security, design, tests, docs, Redis behaviour


Note

Medium Risk
Introduces stable public metric names and attributes that observability tooling will depend on, though cache behavior and error semantics are unchanged and metric recording is isolated from application outcomes.

Overview
Adds OpenTelemetry histogram instrumentation for SemanticCache so apps can observe lookup latency, hit/miss mix, and how close misses came to matching.

A new internal _telemetry module wraps sync/async check, store, update, and drop with a timed decorator that records redisvl.semantic_cache.operation.duration using low-cardinality attributes (cache name, operation, hit/miss on lookups, error.type on failure). check/acheck also emit redisvl.semantic_cache.distance for the nearest KNN candidate before threshold filtering, including on misses when any candidate exists. Recording is best-effort: telemetry errors are debug-logged and dropped, exceptions re-raise unchanged, and interrupted lookups (e.g. cancellation during TTL refresh) record nothing.

redisvl.utils.otel gains cached histogram() instruments with explicit bucket advice tuned for sub-millisecond Redis lookups and cosine-distance tuning, plus error_type() and _clear_caches() for tests. Integration and unit tests assert exported attributes, distances, and that a broken meter never affects callers.

Reviewed by Cursor Bugbot for commit b0e5c91. Bugbot is set up for automated code reviews on this repo. Configure here.

SemanticCache now records two OpenTelemetry histograms through the
redisvl[otel] plumbing:

- redisvl.semantic_cache.operation.duration (s), for check, store,
  update and drop and their async forms, with the cache name and the
  operation, the result (hit or miss) on check, and error.type on
  failure. Its count split by result is the hit rate, so there is no
  counter.
- redisvl.semantic_cache.distance (1), the nearest candidate's distance
  on every lookup that found one, hit or miss, with the cache name and
  result. Read against distance_threshold it shows how close misses come.

Both set bucket advice. Duration buckets start at 100 us, since a lookup
on local Redis takes about 250 us excluding embedding and the GenAI
buckets start at 10 ms; distance buckets are dense up to 0.3.

error.type names the exception's class as the SDK does on exception
events, prefixed by its module unless it is a builtin, so a Redis
ConnectionError and the builtin one stay distinct. The message is never
recorded, and the exception re-raises unchanged. A cancelled call records
nothing. Prompts, filters, entry ids and distances never become metric
attributes.

Recording can never fail a cache call: an error inside it, such as an
opentelemetry-api older than 1.30 installed by another package rejecting
the bucket advice, is logged at debug level and dropped.
CustomTextVectorizer is deprecated in favour of CustomVectorizer.
@vishal-bala vishal-bala added the auto:minor Increment the minor version when merged label Oct 20, 2026
@vishal-bala
vishal-bala added this pull request to stack #756 October 20, 2026 13:25
@vishal-bala
vishal-bala marked this pull request as ready for review October 30, 2026 08:43
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-30T08:45:22.893941Z f9a61d5 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@limjoobin limjoobin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM overall.

Just one minor issue about syncing the distance metric recording and TTL refresh operation on acheck()

Comment on lines 599 to 602
_telemetry.record_nearest_distance(self.name, candidates, cache_hits)

# Refresh TTL on all found keys
await self._aexpire_many(redis_keys)

@limjoobin limjoobin Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the nearest distance is recorded before the TTL refresh finishes, a distance sample is still logged if acheck() is cancelled during the refresh. We can consider moving the distance recording operation after the TTL refresh so cancelled lookups record nothing.

Suggested change
_telemetry.record_nearest_distance(self.name, candidates, cache_hits)
# Refresh TTL on all found keys
await self._aexpire_many(redis_keys)
# Refresh TTL on all found keys
await self._aexpire_many(redis_keys)
_telemetry.record_nearest_distance(self.name, candidates, cache_hits)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 7d58ffb: both check and acheck now record the distance after the TTL refresh, so a lookup that is cancelled or fails during the refresh records nothing, matching the duration histogram. A new integration test interrupts the refresh in both and asserts no distance point; it fails without the fix. #777 carries the same ordering.

🤖 Addressed by Claude Code

check and acheck recorded the nearest distance before refreshing the
hits' TTLs. A lookup cancelled or failed during the refresh recorded no
duration, but still added a distance point, labelled with a result the
caller never received. The distance is now recorded after the refresh,
so an interrupted lookup records nothing.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auto:minor Increment the minor version when merged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants