Skip to content

feat: emit OpenTelemetry spans for SemanticCache and embeddings (FDE-88) - #777

Merged
vishal-bala merged 57 commits into
mainfrom
feat/FDE-83-otel/04-FDE-88-spans
Nov 7, 2026
Merged

vishal-bala merged 57 commits into
mainfrom
feat/FDE-83-otel/04-FDE-88-spans

Conversation

@vishal-bala

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

Copy link
Copy Markdown
Collaborator

Motivation

The metrics in the PR below show how SemanticCache performs 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

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 (with an EmbeddingsCache)
└── retrieval {index}    CLIENT    gen_ai.operation.name, gen_ai.data_source.id,
                                   gen_ai.retrieval.top_k, db.system.name, db.collection.name,
                                   db.operation.name,
                                   redisvl.cache.distance_threshold, redisvl.cache.distance

store, update and drop get the same parent span. check and store have an embeddings child unless they are given a precomputed vector. 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 do SemanticRouter and SemanticMessageHistory. The four embed methods were re-indented under the span; their behaviour is unchanged.

gen_ai.provider.name uses the registered GenAI value where one exists, and the vectorizer type otherwise. GoogleGenAIVectorizer reports gcp.vertex_ai or gcp.gemini, depending on the backend it resolved.

gen_ai.client.operation.duration is recorded for calls that reach the provider, with error.type on failure. It is not recorded for embeddings-cache hits or cancelled calls. One embed_many call records one point, covering all of its batches.

Errors and content capture

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 exception message can carry prompt text.

OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT controls content capture:

  • When it is SPAN_ONLY or SPAN_AND_EVENT, the retrieval span also records the query text and each candidate's entry id and distance.
  • true does not turn capture on, unlike in some older GenAI instrumentations.
  • Response text is never recorded.

The guide

docs/user_guide/how_to_guides/observability.md covers:

  • installing redisvl[otel] with an SDK;
  • every span, metric and attribute;
  • reading the distance histogram to tune distance_threshold;
  • starter PromQL queries;
  • turning on redis-py's own metrics alongside;
  • content capture and its privacy implications;
  • the off switch.

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 the dev dependency group, like every other test dependency, so the separate otel-test group and its --group otel-test flag in make install, CONTRIBUTING and the test matrix are gone.

Notes

  • Span kind. Embeddings spans are CLIENT for every vectorizer, including local models (hf, custom) and calls served entirely from the embeddings cache. A full cache hit carries redisvl.embeddings_cache.result=hit and records no gen_ai.client.operation.duration. Span kind can change later without affecting any metric.
  • Retrieval span attributes. The retrieval span sets db.operation.name=FT.SEARCH alongside db.system.name=redis, because the Redis database conventions require an operation name on Redis spans, and backends group spans by it.
  • Span creation isn't guarded. A tracer, sampler or span processor that raises reaches the caller, as with the OpenTelemetry contrib instrumentations, so a broken SDK setup fails loudly instead of silently dropping traces. Metric recording is guarded, because an older API rejecting bucket advice is a real case.

Release Notes

  • SemanticCache can now report OpenTelemetry metrics and spans.
    • Install: pip install "redisvl[otel]", then configure an OpenTelemetry SDK in your application. RedisVL emits nothing until you do.
    • Metrics: operation duration, hit rate and nearest-match distance.
    • Spans: a span tree that separates embedding from vector search.
    • Embeddings spans: every vectorizer now produces embeddings spans and gen_ai.client.operation.duration.
    • Content capture: prompts are recorded only when OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT is SPAN_ONLY or SPAN_AND_EVENT.
    • Off switch: set REDISVL_OTEL_DISABLED=true.
    • See the new guide, "Observe the Semantic Cache with OpenTelemetry".

Reviewer Brief

Start here: redisvl/utils/vectorize/_telemetry.py, embeddings(). Every vectorizer's embed call in base.py now runs inside it, so it decides what each provider call records.

Where it could fail

  • redisvl/utils/vectorize/base.py:191 and :338: the embeddings-cache result for a batch is computed as len(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_lookup records the prompt and each candidate's entry id and distance. Any field added to documents would 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: ee7149df makes 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. e5efdf61 merges the base after it was restacked onto main; this PR's own diff is unchanged. cf278b32 merges the updated base, which brings in main up to 506b961b (Python 3.15 support); this PR's own diff is unchanged. 37654c3c adds a sentence to the guide's threshold section: the near-miss share counts only misses that found a candidate.

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


Note

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 SemanticCache plus retrieval child spans and richer distance capture, wraps every vectorizer embed* path with GenAI-style client spans/histograms, and gates optional prompt logging behind OTEL_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 bundle opentelemetry-sdk>=1.32 (including lock/workflow/make instructions dropping the old otel-test group), 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.

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.
…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.
@vishal-bala vishal-bala added auto:release Create a release when this PR is merged auto:minor Increment the minor version when merged labels Oct 20, 2026
@vishal-bala
vishal-bala added this pull request to stack #756 October 20, 2026 13:25
@vishal-bala vishal-bala removed the auto:release Create a release when this PR is merged label Oct 20, 2026
@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:47:04.163239Z 5a55eec 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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

@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.

Thanks for the thorough implementation and documentation.

I left two comments that I think are worth addressing:

  1. 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.
  2. gen_ai.retrieval.documents[].score currently receives cosine distance, whose ordering is the reverse of the relevance score OpenTelemetry defines. Since SemanticCache always uses cosine, this can be converted with 1 - distance while 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.

Comment thread redisvl/utils/otel.py Resolved
vishal-bala and others added 6 commits November 5, 2026 13:30
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

@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, 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
…DE-87-metrics

# Conflicts:
#	redisvl/utils/otel.py
#	tests/conftest.py
#	tests/unit/test_otel.py
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.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread redisvl/utils/vectorize/_telemetry.py Resolved
Base automatically changed from feat/FDE-83-otel/03-FDE-87-metrics to main November 6, 2026 17:40
…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
@vishal-bala
vishal-bala merged commit 87d5c17 into main Nov 7, 2026
124 of 125 checks passed
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