Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use the shared RetrievalDocument model for retrieval results, with null IDs and scores for text-only passages, instead of capturing passage text.
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from opentelemetry.util.genai.types import (
InputMessage,
OutputMessage,
RetrievalDocument,
TextPart,
)
from opentelemetry.util.genai.utils import bind_arguments
Expand Down Expand Up @@ -485,7 +486,8 @@ def _set_retrieval_invocation_documents(
if passages is None:
return

invocation.documents = [{"content": str(psg)} for psg in passages]
# Retrieve returns passage text, without document IDs or scores.
invocation.documents = [RetrievalDocument() for _ in passages]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Unfortunate we end up with empty docs here, but that's what semconv does right now. Created open-telemetry/semantic-conventions-genai#537 to track, let's follow up once it's resolved in some way.



def _retrieve_forward(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@
import copy
import json
from typing import Any
from unittest.mock import Mock

import dspy
import pytest

from opentelemetry.instrumentation.genai.dspy import DSPyInstrumentor
from opentelemetry.instrumentation.genai.dspy.patch import (
_set_retrieval_invocation_documents,
)
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.trace import TracerProvider
Expand All @@ -25,6 +29,9 @@
from opentelemetry.semconv.attributes import error_attributes
from opentelemetry.test_util_genai.instrumentor import instrument
from opentelemetry.trace import StatusCode
from opentelemetry.util.genai.handler import TelemetryHandler
from opentelemetry.util.genai.invocation import RetrievalInvocation
from opentelemetry.util.genai.types import RetrievalDocument

_GEN_AI_RETRIEVAL_TOP_K = "gen_ai.retrieval.top_k"

Expand All @@ -49,11 +56,13 @@ def __call__(self, query: str, k: int = 3, **kwargs: Any) -> list[Any]:
return [_DummyPassage(f"Passage {i} for {query}") for i in range(k)]


@pytest.mark.parametrize("k", [0, 1, 3])
def test_sync_retrieve_execution(
tracer_provider: TracerProvider,
logger_provider: LoggerProvider,
meter_provider: MeterProvider,
span_exporter: InMemorySpanExporter,
k: int,
) -> None:
rm = _DummyRM()
dspy.settings.configure(rm=rm)
Expand All @@ -65,9 +74,9 @@ def test_sync_retrieve_execution(
meter_provider=meter_provider,
content_capture="SPAN_ONLY",
):
retrieve = dspy.Retrieve(k=3)
retrieve = dspy.Retrieve(k=k)
res = retrieve("What is OpenTelemetry?")
assert len(res.passages) == 3
assert len(res.passages) == k

spans = span_exporter.get_finished_spans()
assert len(spans) == 1
Expand All @@ -77,7 +86,7 @@ def test_sync_retrieve_execution(
assert span.status.status_code == StatusCode.UNSET
attrs = span.attributes or {}
assert attrs.get(GenAI.GEN_AI_OPERATION_NAME) == "retrieval"
assert attrs.get(_GEN_AI_RETRIEVAL_TOP_K) == 3
assert attrs.get(_GEN_AI_RETRIEVAL_TOP_K) == k
assert isinstance(attrs.get(_GEN_AI_RETRIEVAL_TOP_K), int)
assert (
attrs.get(GenAI.GEN_AI_RETRIEVAL_QUERY_TEXT)
Expand All @@ -87,10 +96,22 @@ def test_sync_retrieve_execution(
docs_attr = attrs.get(GenAI.GEN_AI_RETRIEVAL_DOCUMENTS)
assert isinstance(docs_attr, str)
docs = json.loads(docs_attr)
assert len(docs) == 3
assert docs[0] == {"content": "Passage 0 for What is OpenTelemetry?"}
assert docs[1] == {"content": "Passage 1 for What is OpenTelemetry?"}
assert docs[2] == {"content": "Passage 2 for What is OpenTelemetry?"}
assert docs == [{"id": None, "score": None}] * k
assert res.passages == [
f"Passage {i} for What is OpenTelemetry?" for i in range(k)
]


def test_retrieval_documents_do_not_stringify_passages() -> None:
class Passage:
def __str__(self) -> str:
raise AssertionError("passage text must not be read")

handler = Mock(spec=TelemetryHandler)
handler.should_capture_content.return_value = True
invocation = Mock(spec=RetrievalInvocation)
_set_retrieval_invocation_documents(handler, invocation, [Passage()])
assert invocation.documents == [RetrievalDocument()]


def test_retrieve_forward_direct_call(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use the shared RetrievalDocument model to capture only document IDs and scores, with null for unavailable fields, instead of document text.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ application:
agent name, id, description, and conversation/session id when available.
* **Tool spans** for tool calls made during a run.
* **Retrieval spans** for retriever invocations, capturing the query and retrieved
documents (including id, content, and relevance scores when available).
document IDs and relevance scores when available.

The spans nest to reflect the graph, so a single graph invocation produces a
workflow span with the agent, tool, and model calls it triggered as children.
Expand Down Expand Up @@ -102,11 +102,14 @@ Retrieval Spans and Document Scores
-----------------------------------

When invoking LangChain retrievers (e.g., vectorstores, knowledge bases, or contextual compression retrievers),
retrieval spans are recorded with the query and retrieved document metadata.
retrieval spans are recorded with the query and retrieved document IDs and scores.

When message content capture is enabled (``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY``
or ``SPAN_AND_EVENT``), the retrieved documents are serialized into the
``gen_ai.retrieval.documents`` span attribute as a JSON array of objects with ``id`` and ``content``.
``gen_ai.retrieval.documents`` span attribute using the shared ``RetrievalDocument``
model, as a JSON array of objects with only ``id`` and ``score``.
Document text (previously recorded as ``content``) and metadata are not captured.
Query text capture is unchanged.

When available, relevance and similarity scores are captured in each document object under ``score``:

Expand All @@ -117,7 +120,8 @@ When available, relevance and similarity scores are captured in each document ob
* **Duck-typed / custom documents**: extracted from a top-level ``score`` attribute or mapping key.

If a document has no score, or if the score is non-numeric or non-finite (``NaN``, ``Infinity``),
the ``score`` key is omitted to ensure RFC 8259 JSON compliance.
``score`` is recorded as JSON ``null`` to ensure RFC 8259 JSON compliance.
Missing document IDs are also recorded as ``null``.

Configuration
-------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
InputMessage,
MessagePart,
OutputMessage,
RetrievalDocument,
Role,
TextPart,
ToolCallRequestPart,
Expand Down Expand Up @@ -142,32 +143,18 @@ def _extract_document_score(doc: Any) -> float | int | None:
return None


def _document_to_dict(doc: Any) -> dict[str, Any]:
"""Convert a Document, duck-typed document object, or Mapping to a dict.

Extracts content (checking page_content first, then content), id,
and conditionally score if present and numeric.
"""
def _document_to_retrieval_document(doc: object) -> RetrievalDocument:
"""Extract only the standard document ID and relevance score."""
if isinstance(doc, Mapping):
doc_map = cast(Mapping[str, Any], doc)
content = doc_map.get("page_content")
if content is None:
content = doc_map.get("content")
doc_map = cast(Mapping[str, object], doc)
doc_id = doc_map.get("id")
else:
content = getattr(doc, "page_content", None)
if content is None:
content = getattr(doc, "content", None)
doc_id = getattr(doc, "id", None)

doc_dict: dict[str, Any] = {
"content": content,
"id": doc_id,
}
score = _extract_document_score(doc)
if score is not None:
doc_dict["score"] = score
return doc_dict
return RetrievalDocument(
id=doc_id if isinstance(doc_id, str) else None,
score=_extract_document_score(doc),
)


class OpenTelemetryLangChainCallbackHandler(BaseCallbackHandler):
Expand Down Expand Up @@ -851,7 +838,7 @@ def on_retriever_end(

if self._telemetry_handler.should_capture_content():
invocation.documents = [
_document_to_dict(doc) for doc in documents
_document_to_retrieval_document(doc) for doc in documents
]
invocation.stop()
self._invocation_manager.delete_invocation_state(run_id)
Expand Down
Loading
Loading