Skip to content

Commit b0b94d6

Browse files
initial commit
1 parent d39a01b commit b0b94d6

File tree

8 files changed

+1072
-3
lines changed

8 files changed

+1072
-3
lines changed

util/opentelemetry-util-genai/examples/retrievals_example.py

Lines changed: 417 additions & 0 deletions
Large diffs are not rendered by default.

util/opentelemetry-util-genai/src/opentelemetry/util/genai/attributes.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@
5555
GEN_AI_EMBEDDINGS_INPUT_TEXTS = "gen_ai.embeddings.input.texts"
5656
GEN_AI_REQUEST_ENCODING_FORMATS = "gen_ai.request.encoding_formats"
5757

58+
# Retrieval attributes
59+
GEN_AI_RETRIEVAL_TYPE = "gen_ai.retrieval.type"
60+
GEN_AI_RETRIEVAL_QUERY_TEXT = "gen_ai.retrieval.query.text"
61+
GEN_AI_RETRIEVAL_TOP_K = "gen_ai.retrieval.top_k"
62+
GEN_AI_RETRIEVAL_DOCUMENTS_RETRIEVED = "gen_ai.retrieval.documents_retrieved"
63+
GEN_AI_RETRIEVAL_DOCUMENTS = "gen_ai.retrieval.documents"
64+
5865
# Server attributes (from semantic conventions)
5966
SERVER_ADDRESS = "server.address"
6067
SERVER_PORT = "server.port"

util/opentelemetry-util-genai/src/opentelemetry/util/genai/emitters/metrics.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ..types import (
1717
AgentInvocation,
1818
EmbeddingInvocation,
19+
RetrievalInvocation,
1920
Error,
2021
LLMInvocation,
2122
ToolCall,
@@ -50,6 +51,9 @@ def __init__(self, meter: Optional[Meter] = None):
5051
self._agent_duration_histogram: Histogram = (
5152
instruments.agent_duration_histogram
5253
)
54+
self._retrieval_duration_histogram: Histogram = (
55+
instruments.retrieval_duration_histogram
56+
)
5357

5458
def on_start(self, obj: Any) -> None: # no-op for metrics
5559
return None
@@ -146,6 +150,9 @@ def on_end(self, obj: Any) -> None:
146150
span=getattr(embedding_invocation, "span", None),
147151
)
148152

153+
if isinstance(obj, RetrievalInvocation):
154+
self._record_retrieval_metrics(obj)
155+
149156
def on_error(self, error: Error, obj: Any) -> None:
150157
# Handle new agentic types
151158
if isinstance(obj, Workflow):
@@ -242,6 +249,9 @@ def on_error(self, error: Error, obj: Any) -> None:
242249
span=getattr(embedding_invocation, "span", None),
243250
)
244251

252+
if isinstance(obj, RetrievalInvocation):
253+
self._record_retrieval_metrics(obj, error)
254+
245255
def handles(self, obj: Any) -> bool:
246256
return isinstance(
247257
obj,
@@ -251,6 +261,7 @@ def handles(self, obj: Any) -> bool:
251261
Workflow,
252262
AgentInvocation,
253263
EmbeddingInvocation,
264+
RetrievalInvocation,
254265
),
255266
)
256267

@@ -306,3 +317,40 @@ def _record_agent_metrics(self, agent: AgentInvocation) -> None:
306317
self._agent_duration_histogram.record(
307318
duration, attributes=metric_attrs, context=context
308319
)
320+
321+
def _record_retrieval_metrics(
322+
self, retrieval: RetrievalInvocation, error: Optional[Error] = None
323+
) -> None:
324+
"""Record metrics for a retrieval operation."""
325+
if retrieval.end_time is None:
326+
return
327+
duration = retrieval.end_time - retrieval.start_time
328+
metric_attrs = {
329+
GenAI.GEN_AI_OPERATION_NAME: retrieval.operation_name,
330+
}
331+
if retrieval.retriever_type:
332+
metric_attrs["gen_ai.retrieval.type"] = retrieval.retriever_type
333+
if retrieval.framework:
334+
metric_attrs["gen_ai.framework"] = retrieval.framework
335+
if retrieval.provider:
336+
metric_attrs[GenAI.GEN_AI_PROVIDER_NAME] = retrieval.provider
337+
# Add agent context if available
338+
if retrieval.agent_name:
339+
metric_attrs[GenAI.GEN_AI_AGENT_NAME] = retrieval.agent_name
340+
if retrieval.agent_id:
341+
metric_attrs[GenAI.GEN_AI_AGENT_ID] = retrieval.agent_id
342+
# Add error type if present
343+
if error is not None and getattr(error, "type", None) is not None:
344+
metric_attrs[ErrorAttributes.ERROR_TYPE] = error.type.__qualname__
345+
346+
context = None
347+
span = getattr(retrieval, "span", None)
348+
if span is not None:
349+
try:
350+
context = trace.set_span_in_context(span)
351+
except (ValueError, RuntimeError): # pragma: no cover - defensive
352+
context = None
353+
354+
self._retrieval_duration_histogram.record(
355+
duration, attributes=metric_attrs, context=context
356+
)

util/opentelemetry-util-genai/src/opentelemetry/util/genai/emitters/span.py

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
GEN_AI_OUTPUT_MESSAGES,
2727
GEN_AI_PROVIDER_NAME,
2828
GEN_AI_REQUEST_ENCODING_FORMATS,
29+
GEN_AI_RETRIEVAL_DOCUMENTS_RETRIEVED,
30+
GEN_AI_RETRIEVAL_QUERY_TEXT,
31+
GEN_AI_RETRIEVAL_TOP_K,
32+
GEN_AI_RETRIEVAL_TYPE,
2933
GEN_AI_STEP_ASSIGNED_AGENT,
3034
GEN_AI_STEP_NAME,
3135
GEN_AI_STEP_OBJECTIVE,
@@ -45,6 +49,7 @@
4549
AgentInvocation,
4650
ContentCapturingMode,
4751
EmbeddingInvocation,
52+
RetrievalInvocation,
4853
Error,
4954
LLMInvocation,
5055
Step,
@@ -201,9 +206,10 @@ def _apply_start_attrs(self, invocation: GenAIType):
201206
provider = getattr(invocation, "provider", None)
202207
if provider:
203208
span.set_attribute(GEN_AI_PROVIDER_NAME, provider)
204-
# framework (named field)
205-
if isinstance(invocation, LLMInvocation) and invocation.framework:
206-
span.set_attribute("gen_ai.framework", invocation.framework)
209+
# framework (named field) - applies to all invocation types
210+
framework = getattr(invocation, "framework", None)
211+
if framework:
212+
span.set_attribute("gen_ai.framework", framework)
207213
# function definitions (semantic conv derived from structured list)
208214
if isinstance(invocation, LLMInvocation):
209215
_apply_function_definitions(span, invocation.request_functions)
@@ -302,6 +308,8 @@ def on_start(
302308
self._apply_start_attrs(invocation)
303309
elif isinstance(invocation, EmbeddingInvocation):
304310
self._start_embedding(invocation)
311+
elif isinstance(invocation, RetrievalInvocation):
312+
self._start_retrieval(invocation)
305313
else:
306314
# Use operation field for span name (defaults to "chat")
307315
operation = getattr(invocation, "operation", "chat")
@@ -335,6 +343,8 @@ def on_end(self, invocation: LLMInvocation | EmbeddingInvocation) -> None:
335343
self._finish_step(invocation)
336344
elif isinstance(invocation, EmbeddingInvocation):
337345
self._finish_embedding(invocation)
346+
elif isinstance(invocation, RetrievalInvocation):
347+
self._finish_retrieval(invocation)
338348
else:
339349
span = getattr(invocation, "span", None)
340350
if span is None:
@@ -359,6 +369,8 @@ def on_error(
359369
self._error_step(error, invocation)
360370
elif isinstance(invocation, EmbeddingInvocation):
361371
self._error_embedding(error, invocation)
372+
elif isinstance(invocation, RetrievalInvocation):
373+
self._error_retrieval(error, invocation)
362374
else:
363375
span = getattr(invocation, "span", None)
364376
if span is None:
@@ -771,3 +783,70 @@ def _error_embedding(
771783
token.__exit__(None, None, None) # type: ignore[misc]
772784
except Exception:
773785
pass
786+
787+
# ---- Retrieval lifecycle ---------------------------------------------
788+
def _start_retrieval(self, retrieval: RetrievalInvocation) -> None:
789+
"""Start a retrieval span."""
790+
span_name = f"{retrieval.operation_name}"
791+
if retrieval.provider:
792+
span_name = f"{retrieval.operation_name} {retrieval.provider}"
793+
parent_span = getattr(retrieval, "parent_span", None)
794+
parent_ctx = (
795+
trace.set_span_in_context(parent_span)
796+
if parent_span is not None
797+
else None
798+
)
799+
cm = self._tracer.start_as_current_span(
800+
span_name,
801+
kind=SpanKind.CLIENT,
802+
end_on_exit=False,
803+
context=parent_ctx,
804+
)
805+
span = cm.__enter__()
806+
self._attach_span(retrieval, span, cm)
807+
self._apply_start_attrs(retrieval)
808+
809+
# Set retrieval-specific start attributes
810+
if retrieval.top_k is not None:
811+
span.set_attribute(GEN_AI_RETRIEVAL_TOP_K, retrieval.top_k)
812+
if self._capture_content and retrieval.query:
813+
span.set_attribute(GEN_AI_RETRIEVAL_QUERY_TEXT, retrieval.query)
814+
815+
def _finish_retrieval(self, retrieval: RetrievalInvocation) -> None:
816+
"""Finish a retrieval span."""
817+
span = retrieval.span
818+
if span is None:
819+
return
820+
# Apply finish-time semantic conventions
821+
if retrieval.documents_retrieved is not None:
822+
span.set_attribute(
823+
GEN_AI_RETRIEVAL_DOCUMENTS_RETRIEVED,
824+
retrieval.documents_retrieved,
825+
)
826+
token = retrieval.context_token
827+
if token is not None and hasattr(token, "__exit__"):
828+
try:
829+
token.__exit__(None, None, None) # type: ignore[misc]
830+
except Exception:
831+
pass
832+
span.end()
833+
834+
def _error_retrieval(
835+
self, error: Error, retrieval: RetrievalInvocation
836+
) -> None:
837+
"""Fail a retrieval span with error status."""
838+
span = retrieval.span
839+
if span is None:
840+
return
841+
span.set_status(Status(StatusCode.ERROR, error.message))
842+
if span.is_recording():
843+
span.set_attribute(
844+
ErrorAttributes.ERROR_TYPE, error.type.__qualname__
845+
)
846+
token = retrieval.context_token
847+
if token is not None and hasattr(token, "__exit__"):
848+
try:
849+
token.__exit__(None, None, None) # type: ignore[misc]
850+
except Exception:
851+
pass
852+
span.end()

util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def genai_debug_log(*_args: Any, **_kwargs: Any) -> None: # type: ignore
8787
AgentInvocation,
8888
ContentCapturingMode,
8989
EmbeddingInvocation,
90+
RetrievalInvocation,
9091
Error,
9192
EvaluationResult,
9293
GenAI,
@@ -475,6 +476,70 @@ def fail_embedding(
475476
pass
476477
return invocation
477478

479+
def start_retrieval(
480+
self, invocation: RetrievalInvocation
481+
) -> RetrievalInvocation:
482+
"""Start a retrieval invocation and create a pending span entry."""
483+
self._refresh_capture_content()
484+
if (
485+
not invocation.agent_name or not invocation.agent_id
486+
) and self._agent_context_stack:
487+
top_name, top_id = self._agent_context_stack[-1]
488+
if not invocation.agent_name:
489+
invocation.agent_name = top_name
490+
if not invocation.agent_id:
491+
invocation.agent_id = top_id
492+
invocation.start_time = time.time()
493+
self._emitter.on_start(invocation)
494+
span = getattr(invocation, "span", None)
495+
if span is not None:
496+
self._span_registry[str(invocation.run_id)] = span
497+
self._entity_registry[str(invocation.run_id)] = invocation
498+
return invocation
499+
500+
def stop_retrieval(
501+
self, invocation: RetrievalInvocation
502+
) -> RetrievalInvocation:
503+
"""Finalize a retrieval invocation successfully and end its span."""
504+
invocation.end_time = time.time()
505+
506+
# Determine if this invocation should be sampled for evaluation
507+
invocation.sample_for_evaluation = self._should_sample_for_evaluation(
508+
invocation.trace_id
509+
)
510+
511+
self._emitter.on_end(invocation)
512+
self._notify_completion(invocation)
513+
self._entity_registry.pop(str(invocation.run_id), None)
514+
# Force flush metrics if a custom provider with force_flush is present
515+
if (
516+
hasattr(self, "_meter_provider")
517+
and self._meter_provider is not None
518+
):
519+
try: # pragma: no cover
520+
self._meter_provider.force_flush() # type: ignore[attr-defined]
521+
except Exception:
522+
pass
523+
return invocation
524+
525+
def fail_retrieval(
526+
self, invocation: RetrievalInvocation, error: Error
527+
) -> RetrievalInvocation:
528+
"""Fail a retrieval invocation and end its span with error status."""
529+
invocation.end_time = time.time()
530+
self._emitter.on_error(error, invocation)
531+
self._notify_completion(invocation)
532+
self._entity_registry.pop(str(invocation.run_id), None)
533+
if (
534+
hasattr(self, "_meter_provider")
535+
and self._meter_provider is not None
536+
):
537+
try: # pragma: no cover
538+
self._meter_provider.force_flush() # type: ignore[attr-defined]
539+
except Exception:
540+
pass
541+
return invocation
542+
478543
# ToolCall lifecycle --------------------------------------------------
479544
def start_tool_call(self, invocation: ToolCall) -> ToolCall:
480545
"""Start a tool call invocation and create a pending span entry."""
@@ -880,6 +945,8 @@ def start(self, obj: Any) -> Any:
880945
return self.start_llm(obj)
881946
if isinstance(obj, EmbeddingInvocation):
882947
return self.start_embedding(obj)
948+
if isinstance(obj, RetrievalInvocation):
949+
return self.start_retrieval(obj)
883950
if isinstance(obj, ToolCall):
884951
return self.start_tool_call(obj)
885952
return obj
@@ -960,6 +1027,8 @@ def finish(self, obj: Any) -> Any:
9601027
return self.stop_llm(obj)
9611028
if isinstance(obj, EmbeddingInvocation):
9621029
return self.stop_embedding(obj)
1030+
if isinstance(obj, RetrievalInvocation):
1031+
return self.stop_retrieval(obj)
9631032
if isinstance(obj, ToolCall):
9641033
return self.stop_tool_call(obj)
9651034
return obj
@@ -976,6 +1045,8 @@ def fail(self, obj: Any, error: Error) -> Any:
9761045
return self.fail_llm(obj, error)
9771046
if isinstance(obj, EmbeddingInvocation):
9781047
return self.fail_embedding(obj, error)
1048+
if isinstance(obj, RetrievalInvocation):
1049+
return self.fail_retrieval(obj, error)
9791050
if isinstance(obj, ToolCall):
9801051
return self.fail_tool_call(obj, error)
9811052
return obj

util/opentelemetry-util-genai/src/opentelemetry/util/genai/instruments.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,8 @@ def __init__(self, meter: Meter):
4242
unit="s",
4343
description="Duration of agent operations",
4444
)
45+
self.retrieval_duration_histogram: Histogram = meter.create_histogram(
46+
name="gen_ai.retrieval.duration",
47+
unit="s",
48+
description="Duration of retrieval operations",
49+
)

util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,42 @@ class EmbeddingInvocation(GenAI):
317317
)
318318
error_type: Optional[str] = None
319319

320+
@dataclass
321+
class RetrievalInvocation(GenAI):
322+
"""Represents a single retrieval/search invocation."""
323+
324+
#Required attribute
325+
operation_name: str = field(
326+
default="retrieval",
327+
metadata={"semconv": GenAIAttributes.GEN_AI_OPERATION_NAME},
328+
)
329+
330+
# Recommended attributes
331+
retriever_type: Optional[str] = field(
332+
default=None,
333+
metadata={"semconv": "gen_ai.retrieval.type"},
334+
)
335+
query: Optional[str] = field(
336+
default=None,
337+
metadata={"semconv": "gen_ai.retrieval.query.text"},
338+
)
339+
top_k: Optional[int] = field(
340+
default=None,
341+
metadata={"semconv": "gen_ai.retrieval.top_k"},
342+
)
343+
documents_retrieved: Optional[int] = field(
344+
default=None,
345+
metadata={"semconv": "gen_ai.retrieval.documents_retrieved"},
346+
)
347+
348+
# Opt-in attribute
349+
results: list[dict[str, Any]] = field(
350+
default_factory=list,
351+
metadata={"semconv": "gen_ai.retrieval.documents"},
352+
)
353+
354+
# Additional utility fields (not in semantic conventions)
355+
query_vector: Optional[list[float]] = None
320356

321357
@dataclass
322358
class Workflow(GenAI):

0 commit comments

Comments
 (0)