Repository navigation
feat: emit OpenTelemetry spans for SemanticCache and embeddings (FDE-88) - #777
Conversation
check() and acheck() searched with a VectorRangeQuery, so Redis discarded every candidate beyond distance_threshold and a miss carried no information about how close it came. They now run a KNN VectorQuery for num_results candidates and keep those at or within the threshold in Python, inclusive as the range radius was. On the FLAT index the cache always uses, both queries are exhaustive, so hit and miss decisions match. Measured on Redis 8.4.7 they agreed on 3,000 of 3,000 samples per configuration and on 200 of 200 queries within 2e-6 of the threshold. The one theoretical difference is that the client compares the distance as Redis returns it, rounded to 12 significant digits, so a distance within about 1e-12 of the threshold can now fall on the other side of it. Unfiltered lookups take 2-3% longer. With a tag filter matching 10% of entries they take 13% less time at 1,000 entries and 36% less at 10,000, because KNN compares vectors only within the filtered subset where the range query scanned the whole index. The nearest candidate's distance is now in hand on every lookup, which the planned OpenTelemetry instrumentation reports. A per-call distance_threshold is still validated as the range query validated it: TypeError for a non-number, ValueError for a negative value. An id-only row (the Redis 8.8+ expiry race) still reaches _process_cache_results and is skipped there rather than raising. Query construction and the threshold filter are shared by the sync and async paths.
Adds the plumbing RedisVL's OpenTelemetry instrumentation builds on. It emits no telemetry of its own. - An `otel` extra that depends on opentelemetry-api only; the application owns the SDK and exporters. The floor, 1.30.0, is the first API release that accepts histogram bucket advice. - redisvl.utils.otel returns a tracer and meter from the global providers under the `redisvl` scope, RedisVL's version and the semantic-conventions 1.44.0 schema URL. With no SDK configured they are the API's own no-ops: about 2 us for one span and two histogram records on an Apple M4 Pro. When the API is not installed, or REDISVL_OTEL_DISABLED is "true", stand-ins with the same interface are returned, so call sites never branch. The metric and attribute names the instrumentation will use are defined there in one place. - The SDK the tests need sits in a non-default `otel-test` group, so a plain `uv sync` stays free of OpenTelemetry. A shared `otel_sdk` fixture configures in-memory exporters and resets the set-once global providers afterwards. - A CI leg runs the default suite without OpenTelemetry, installing every extra except mistralai and mcp, which pull it in.
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.
Each SemanticCache operation now produces an OpenTelemetry span tree
that separates embedding, vector search and the rest of the call:
semantic_cache.check INTERNAL redisvl.cache.name, redisvl.cache.result
├── embeddings {model} CLIENT gen_ai.operation.name, gen_ai.provider.name,
│ gen_ai.request.model,
│ gen_ai.embeddings.dimension.count,
│ redisvl.embeddings_cache.result (*)
└── retrieval {index} CLIENT gen_ai.operation.name, gen_ai.data_source.id,
gen_ai.retrieval.top_k, db.system.name,
db.collection.name,
redisvl.cache.distance_threshold,
redisvl.cache.distance
(*) only when the vectorizer has an embeddings cache: hit, miss, or
partial for a batch.
store, update and drop get the same parent span; store has an
embeddings child unless it is given a vector, and so does a lookup.
Async methods produce the same trees.
The embeddings span lives in BaseVectorizer, so every vectorizer, and
SemanticRouter and SemanticMessageHistory, gain it with no further
change. gen_ai.provider.name uses the registered GenAI value where one
exists and the vectorizer type otherwise. gen_ai.client.operation.duration
is recorded only for calls that reach the provider, not for
embeddings-cache hits.
A failure sets an error status and error.type on every span it passes
through. The SDK's exception recording is off on every RedisVL span,
because the message it records can carry prompt text. When
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT is SPAN_ONLY or
SPAN_AND_EVENT, the retrieval span also records the query text and the
retrieved entries' ids and distances; response text is never recorded.
A how-to guide covering installation of redisvl[otel] with an SDK, the spans and metrics RedisVL emits and their attributes, reading the distance histogram to tune distance_threshold, starter PromQL queries, enabling redis-py's own metrics alongside, content capture and its privacy implications, and the off switch. The installation page lists the new extra. The PromQL metric names are as the OpenTelemetry Prometheus exporter produces them, checked against an export with SDK 1.42.1. The SDK minimum of 1.32.0 follows from the bucket-advice export fixes in 1.31.0 (open-telemetry/opentelemetry-python#4434) and 1.32.0 (#4521); the otel-test dependency group now requires it too, so the tests run on an SDK the guide recommends.
…otel/02-FDE-86-foundation
…-otel/03-FDE-87-metrics
…el/04-FDE-88-spans
…otel/02-FDE-86-foundation
…-otel/03-FDE-87-metrics
…el/04-FDE-88-spans
…el/04-FDE-88-spans
…gle backend gen_ai.client.operation.duration was recorded in a finally block, so a cancelled aembed or an interrupted embed recorded a duration with no error.type and counted as a success. It is now recorded only when the call completes or raises an Exception, as the SemanticCache wrapper already does. GoogleGenAIVectorizer reported gcp.gen_ai for both of its backends. It now reports gcp.vertex_ai or gcp.gemini for the backend it resolved.
Only SPAN_ONLY and SPAN_AND_EVENT, in any case, may turn on prompt capture; "true" must not. The default-off test now clears the variable, so it no longer fails in a shell that exports it.
With no SDK, a lookup that embeds now runs three spans and three records, measured at about 11 us on an Apple M4 Pro, not the 2 us of one span and two records. The guide also says that skip_cache=True omits redisvl.embeddings_cache.result, that one embed_many call records one duration point across its batches, and lists gcp.gemini. Rewraps a docstring and a comment.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a55eec924
ℹ️ 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".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
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.
…el/04-FDE-88-spans # Conflicts: # redisvl/extensions/cache/llm/semantic.py
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.
…om/redis/redis-vl-python into feat/FDE-83-otel/04-FDE-88-spans # Conflicts: # pyproject.toml # redisvl/extensions/cache/llm/_telemetry.py # redisvl/extensions/cache/llm/semantic.py # redisvl/utils/otel.py # tests/unit/test_cache_telemetry.py # tests/unit/test_otel.py # uv.lock
…feat/FDE-83-otel/02-FDE-86-foundation # Conflicts: # uv.lock
…feat/FDE-83-otel/02-FDE-86-foundation
…-otel/03-FDE-87-metrics
…el/04-FDE-88-spans
limjoobin
left a comment
There was a problem hiding this comment.
Thanks for the thorough implementation and documentation.
I left two comments that I think are worth addressing:
- Failures during the OpenTelemetry span lifecycle can prevent the wrapped operation, turn a successful operation into an exception, or replace the original RedisVL exception. I reproduced this using real SDK sampler and processor extension points.
gen_ai.retrieval.documents[].scorecurrently receives cosine distance, whose ordering is the reverse of the relevance score OpenTelemetry defines. SinceSemanticCachealways uses cosine, this can be converted with1 - distancewhile retaining the raw distance in the RedisVL-specific attribute.
Otherwise, the sync/async structure, Redis query behaviour, privacy gating, tests, and documentation look solid to me.
A sampler or span processor that raised while a RedisVL span started or ended could stop the operation from running, turn a success into an exception, or replace the operation's own exception. start_span now starts, activates and ends the span itself, and logs any SDK failure at debug level instead. Co-Authored-By: Claude <noreply@anthropic.com>
…core gen_ai.retrieval.documents[].score is a relevance score, higher for closer matches, but carried the raw cosine distance, which orders the other way. The cache's index always uses cosine, so the score is now 1 - distance; the nearest raw distance stays on redisvl.cache.distance. Co-Authored-By: Claude <noreply@anthropic.com>
They imported the SDK before checking for it, so they failed instead of skipping on the CI leg that runs without OpenTelemetry. Co-Authored-By: Claude <noreply@anthropic.com>
A dedicated CI job installed every extra except the two that pull in OpenTelemetry. Two unit tests now cover the same ground on every leg: one imports every RedisVL module with OpenTelemetry hidden and exercises the stand-ins, and one checks the package metadata only requires it behind an extra. Co-Authored-By: Claude <noreply@anthropic.com>
…ation' into feat/FDE-83-otel/03-FDE-87-metrics
…cs' into feat/FDE-83-otel/04-FDE-88-spans # Conflicts: # tests/unit/test_otel.py
There was a problem hiding this comment.
LGTM, thanks! I verified that span lifecycle failures are now isolated without changing the operation’s outcome, and retrieval document scores now use cosine similarity while preserving the raw distance separately. The regression tests and documentation cover both fixes. This is good to go now!
The stand-ins no longer run anywhere in the suite. Store and check, sync and async, with REDISVL_OTEL_DISABLED=true, and assert the results are unchanged and nothing reaches the SDK.
…-otel/03-FDE-87-metrics # Conflicts: # redisvl/extensions/cache/llm/semantic.py
…el/04-FDE-88-spans
…DE-87-metrics # Conflicts: # redisvl/utils/otel.py # tests/conftest.py # tests/unit/test_otel.py
…el/04-FDE-88-spans
The tests need the SDK, which no extra provides, and a separate otel-test group kept it out of a plain `uv sync`. Nothing depends on that any more: the unit suite checks the no-OpenTelemetry path with OpenTelemetry hidden, and mistralai installs the SDK under --all-extras anyway. Listing it in dev, like every other test dependency, removes the extra flag from make install, CONTRIBUTING and the test matrix.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit c58c42d. Configure here.
…DE-88-spans # Conflicts: # redisvl/extensions/cache/llm/_telemetry.py # redisvl/extensions/cache/llm/semantic.py # redisvl/utils/otel.py # tests/unit/test_cache_telemetry.py # tests/unit/test_otel.py

Motivation
The metrics in the PR below show how
SemanticCacheperforms overall. To explain a single slow or missed lookup, you need to see where it spent its time: embedding the prompt, searching Redis, or the rest of the call. This change adds OpenTelemetry spans for cache operations and embeddings, and a guide to the whole feature. Together with the two PRs below it, it addresses most of #684; what remains is listed in a comment there.Changes
A span tree for every cache operation
store,updateanddropget the same parent span.checkandstorehave an embeddings child unless they are given a precomputedvector. Async methods produce the same trees.Embeddings spans for every vectorizer
The embeddings span lives in
BaseVectorizer, so every vectorizer gains it with no further change, and so doSemanticRouterandSemanticMessageHistory. The fourembedmethods were re-indented under the span; their behaviour is unchanged.gen_ai.provider.nameuses the registered GenAI value where one exists, and the vectorizer type otherwise.GoogleGenAIVectorizerreportsgcp.vertex_aiorgcp.gemini, depending on the backend it resolved.gen_ai.client.operation.durationis recorded for calls that reach the provider, witherror.typeon failure. It is not recorded for embeddings-cache hits or cancelled calls. Oneembed_manycall records one point, covering all of its batches.Errors and content capture
A failure sets an error status and
error.typeon every span it passes through. The SDK's exception recording is off on every RedisVL span, because the exception message can carry prompt text.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENTcontrols content capture:SPAN_ONLYorSPAN_AND_EVENT, the retrieval span also records the query text and each candidate's entry id and distance.truedoes not turn capture on, unlike in some older GenAI instrumentations.The guide
docs/user_guide/how_to_guides/observability.mdcovers:redisvl[otel]with an SDK;distance_threshold;The PromQL names are the ones the OpenTelemetry Prometheus exporter produces. The guide recommends
opentelemetry-sdk>=1.32, the first release that exports bucket advice correctly over OTLP, and the tests install that version too. The SDK now comes with thedevdependency group, like every other test dependency, so the separateotel-testgroup and its--group otel-testflag inmake install, CONTRIBUTING and the test matrix are gone.Notes
hf,custom) and calls served entirely from the embeddings cache. A full cache hit carriesredisvl.embeddings_cache.result=hitand records nogen_ai.client.operation.duration. Span kind can change later without affecting any metric.db.operation.name=FT.SEARCHalongsidedb.system.name=redis, because the Redis database conventions require an operation name on Redis spans, and backends group spans by it.Release Notes
SemanticCachecan now report OpenTelemetry metrics and spans.pip install "redisvl[otel]", then configure an OpenTelemetry SDK in your application. RedisVL emits nothing until you do.embeddingsspans andgen_ai.client.operation.duration.OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENTisSPAN_ONLYorSPAN_AND_EVENT.REDISVL_OTEL_DISABLED=true.Reviewer Brief
Start here:
redisvl/utils/vectorize/_telemetry.py,embeddings(). Every vectorizer's embed call inbase.pynow runs inside it, so it decides what each provider call records.Where it could fail
redisvl/utils/vectorize/base.py:191and:338: the embeddings-cache result for a batch is computed aslen(contents) - len(cache_misses). It stays right only while misses align with inputs, and only the partial case is tested.redisvl/extensions/cache/llm/_telemetry.py:140: with content capture on,record_lookuprecords the prompt and each candidate's entry id and distance. Any field added todocumentswould go beyond what the guide promises.redisvl/utils/otel.py:202(start_span): this is the one place exception recording is turned off for RedisVL spans. A span started any other way would put exception messages, which can contain prompt text, on the trace.Not verified: live provider APIs (the provider names and the Google backend mapping are tested with stand-ins); cluster mode; the SDK 1.31.0 and 1.32.0 bucket-advice fixes the guide cites.
Beyond CI: the cancellation and Google-backend regression tests fail against the code before their fixes; an instrumented lookup with no SDK costs about 11 µs on an Apple M4 Pro.
Since the last brief:
ee7149dfmakes the guide's hit-rate query report 0 for a cache that has never hit, instead of dropping it; the query wasn't run against Prometheus.e5efdf61merges the base after it was restacked ontomain; this PR's own diff is unchanged.cf278b32merges the updated base, which brings inmainup to506b961b(Python 3.15 support); this PR's own diff is unchanged.37654c3cadds a sentence to the guide's threshold section: the near-miss share counts only misses that found a candidate.Reviewed
5eb8ffdf, fixes inb981f7b2· Claude Code (claude-opus-5-5) · lenses: correctness, security, design, tests, docs, Redis behaviourNote
Medium Risk
Extensive instrumentation around caches/embeddings plus new privacy-sensitive span attributes—misconfiguration could leak prompts or break tracing pipelines if OTEL SDK expectations change.
Overview
Semantic instrumentation overhaul — adds full OpenTelemetry span trees for
SemanticCacheplus retrieval child spans and richer distance capture, wraps every vectorizerembed*path with GenAI-style client spans/histograms, and gates optional prompt logging behindOTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT.Docs & developer wiring — introduces
docs/user_guide/how_to_guides/observability.md, links it from the how-to index/install guide, bumps dev dependencies to bundleopentelemetry-sdk>=1.32(including lock/workflow/make instructions dropping the oldotel-testgroup), and backs the feature with broad unit/integration coverage around spans, metrics, privacy toggles, and failure isolation.Risk summary — touches hot embedding/cache code paths and can leak prompt metadata when capture is enabled; verify downstream SRE/backends tolerate new spans and the histogram/schema upgrades.
Reviewed by Cursor Bugbot for commit e57b1e1. Bugbot is set up for automated code reviews on this repo. Configure here.