Skip to content

Commit b83f540

Browse files
util-genai | Add RetrievalInvocation and lifecycle API (#36)
* retrieval invocation * copilot feedback * addressing copilot feedback * lint update * towncrier fix * clean up old changelog and docstring * use float for top_k per semconvs https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/registry/attributes/gen-ai.md#gen-ai-request-top-k * migrate away from using Any on attributes --------- Co-authored-by: Liudmila Molkova <neskazu@gmail.com>
1 parent 4f4f625 commit b83f540

7 files changed

Lines changed: 685 additions & 2 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `RetrievalInvocation` type with `start_retrieval` / `retrieval` span lifecycle, supporting `gen_ai.operation.name=retrieval` spans per the GenAI semantic conventions.

util/opentelemetry-util-genai/AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,18 +41,19 @@ Factory methods on `TelemetryHandler` (`handler.py`):
4141

4242
- `inference(provider, request_model, *, server_address, server_port)``InferenceInvocation`
4343
- `embedding(provider, request_model, *, server_address, server_port)``EmbeddingInvocation`
44+
- `retrieval(*, data_source_id, provider, request_model, server_address, server_port)``RetrievalInvocation`
4445
- `tool(name, *, arguments, tool_call_id, tool_type, tool_description)``ToolInvocation`
4546
- `workflow(name)``WorkflowInvocation`
4647

4748
The returned object can also be used as a context manager (`with ... as invocation:`) when the span lifetime maps cleanly to a `with` block.
4849

4950
The above factories must map 1:1 to distinct semconv operation types (inference, embeddings,
50-
tool execution, agent invocation, workflow invocation). Names must match the operation
51+
retrieval, tool execution, agent invocation, workflow invocation). Names must match the operation
5152
unambiguously — for example, `create_agent` and `invoke_agent` are different operations, so a
5253
single `agent()` would be ambiguous and is not acceptable. Add a new factory per operation
5354
instead.
5455

55-
Factory names are Python-style singular verbs (`inference`, `embedding`, `tool`, `workflow`); the op names
56+
Factory names are Python-style singular verbs (`inference`, `embedding`, `retrieval`, `tool`, `workflow`); the op names
5657
they map to follow semconv operations.
5758

5859
Factory methods must accept all attributes that semconv marks as important for sampling
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
from collections.abc import Mapping, Sequence
7+
from typing import Any, cast
8+
9+
from opentelemetry._logs import Logger
10+
from opentelemetry.semconv._incubating.attributes import (
11+
gen_ai_attributes as GenAI,
12+
)
13+
from opentelemetry.semconv.attributes import server_attributes
14+
from opentelemetry.trace import SpanKind, Tracer
15+
from opentelemetry.util.genai._invocation import Error, GenAIInvocation
16+
from opentelemetry.util.genai.completion_hook import CompletionHook
17+
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
18+
from opentelemetry.util.genai.utils import (
19+
ContentCapturingMode,
20+
gen_ai_json_dumps,
21+
get_content_capturing_mode,
22+
is_experimental_mode,
23+
)
24+
from opentelemetry.util.types import AttributeValue
25+
26+
27+
class RetrievalInvocation(GenAIInvocation):
28+
"""Represents a single retrieval invocation (retrieval span).
29+
30+
Use handler.retrieval() rather than constructing this directly.
31+
32+
Reference: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-spans.md#retrievals
33+
34+
Semantic convention attributes for retrieval spans:
35+
- gen_ai.operation.name: "retrieval" (Required)
36+
- error.type: Error type if operation failed (Conditionally Required)
37+
- gen_ai.data_source.id: Data source identifier (Conditionally Required, when applicable)
38+
- gen_ai.provider.name: Provider name (Conditionally Required, when applicable)
39+
- gen_ai.request.model: Model name if applicable (Conditionally Required, if available)
40+
- server.port: Server port (Conditionally Required, if server.address is set)
41+
- gen_ai.request.top_k: Top-k sampling setting (Recommended)
42+
- server.address: Server address (Recommended)
43+
- gen_ai.retrieval.documents: Retrieved documents (Opt-In, may contain sensitive data)
44+
- gen_ai.retrieval.query.text: Query text (Opt-In, may contain sensitive data)
45+
"""
46+
47+
def __init__(
48+
self,
49+
tracer: Tracer,
50+
metrics_recorder: InvocationMetricsRecorder,
51+
logger: Logger,
52+
completion_hook: CompletionHook,
53+
*,
54+
data_source_id: str | None = None,
55+
provider: str | None = None,
56+
request_model: str | None = None,
57+
server_address: str | None = None,
58+
server_port: int | None = None,
59+
) -> None:
60+
"""Use handler.retrieval() instead of calling this directly."""
61+
_operation_name = GenAI.GenAiOperationNameValues.RETRIEVAL.value
62+
super().__init__(
63+
tracer,
64+
metrics_recorder,
65+
logger,
66+
completion_hook,
67+
operation_name=_operation_name,
68+
span_name=f"{_operation_name} {data_source_id}"
69+
if data_source_id
70+
else _operation_name,
71+
span_kind=SpanKind.CLIENT,
72+
)
73+
self.data_source_id = data_source_id
74+
self.provider = provider
75+
self.request_model = request_model
76+
self.server_address = server_address
77+
self.server_port = server_port
78+
self.top_k: float | None = None
79+
self.query_text: str | None = None
80+
self.documents: Sequence[Mapping[str, Any]] | None = None
81+
self._start(self._get_base_attributes())
82+
83+
def _get_base_attributes(self) -> dict[str, AttributeValue]:
84+
"""Return sampling-relevant attributes available at span creation time."""
85+
optional_attrs: tuple[tuple[str, AttributeValue | None], ...] = (
86+
(GenAI.GEN_AI_DATA_SOURCE_ID, self.data_source_id),
87+
(GenAI.GEN_AI_PROVIDER_NAME, self.provider),
88+
(GenAI.GEN_AI_REQUEST_MODEL, self.request_model),
89+
(server_attributes.SERVER_ADDRESS, self.server_address),
90+
(server_attributes.SERVER_PORT, self.server_port),
91+
)
92+
return {
93+
GenAI.GEN_AI_OPERATION_NAME: self._operation_name,
94+
**{k: v for k, v in optional_attrs if v is not None},
95+
}
96+
97+
def _get_metric_attributes(self) -> dict[str, AttributeValue]:
98+
# data_source_id intentionally excluded — high cardinality
99+
optional_attrs: tuple[tuple[str, AttributeValue | None], ...] = (
100+
(GenAI.GEN_AI_PROVIDER_NAME, self.provider),
101+
(GenAI.GEN_AI_REQUEST_MODEL, self.request_model),
102+
(server_attributes.SERVER_ADDRESS, self.server_address),
103+
(server_attributes.SERVER_PORT, self.server_port),
104+
)
105+
attrs: dict[str, AttributeValue] = {
106+
GenAI.GEN_AI_OPERATION_NAME: self._operation_name,
107+
**{k: v for k, v in optional_attrs if v is not None},
108+
}
109+
# TODO: remove cast once base class metric_attributes is typed as dict[str, AttributeValue]
110+
attrs.update(cast(dict[str, AttributeValue], self.metric_attributes))
111+
return attrs
112+
113+
def _get_content_attributes_for_span(self) -> dict[str, AttributeValue]:
114+
if not self.span.is_recording():
115+
return {}
116+
if not is_experimental_mode() or get_content_capturing_mode() not in (
117+
ContentCapturingMode.SPAN_ONLY,
118+
ContentCapturingMode.SPAN_AND_EVENT,
119+
):
120+
return {}
121+
optional_attrs: tuple[tuple[str, AttributeValue | None], ...] = (
122+
(GenAI.GEN_AI_RETRIEVAL_QUERY_TEXT, self.query_text),
123+
(
124+
GenAI.GEN_AI_RETRIEVAL_DOCUMENTS,
125+
gen_ai_json_dumps(self.documents)
126+
if self.documents is not None
127+
else None,
128+
),
129+
)
130+
return {k: v for k, v in optional_attrs if v is not None}
131+
132+
def _apply_finish(self, error: Error | None = None) -> None:
133+
if error is not None:
134+
self._apply_error_attributes(error)
135+
attributes: dict[str, AttributeValue] = {}
136+
if self.top_k is not None:
137+
attributes[GenAI.GEN_AI_REQUEST_TOP_K] = self.top_k
138+
attributes.update(self._get_content_attributes_for_span())
139+
# TODO: remove cast once base class self.attributes is typed as dict[str, AttributeValue]
140+
attributes.update(cast(dict[str, AttributeValue], self.attributes))
141+
self.span.set_attributes(attributes)
142+
self._metrics_recorder.record(self)

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
from opentelemetry.util.genai.invocation import (
6464
EmbeddingInvocation,
6565
InferenceInvocation,
66+
RetrievalInvocation,
6667
ToolInvocation,
6768
WorkflowInvocation,
6869
)
@@ -202,6 +203,35 @@ def start_embedding(
202203
server_port=server_port,
203204
)
204205

206+
def retrieval(
207+
self,
208+
*,
209+
data_source_id: str | None = None,
210+
provider: str | None = None,
211+
request_model: str | None = None,
212+
server_address: str | None = None,
213+
server_port: int | None = None,
214+
) -> RetrievalInvocation:
215+
"""Returns a Retrieval invocation. Starts span when called.
216+
217+
Returned object can be used as a ContextManager which automatically calls `stop` or `fail`
218+
to finalize the span upon exiting. If not used as a ContextManager, the caller is
219+
responsible for calling `stop` or `fail` to finalize the span.
220+
221+
Only set data attributes on the invocation object, do not modify the span or context.
222+
"""
223+
return RetrievalInvocation(
224+
self._tracer,
225+
self._metrics_recorder,
226+
self._logger,
227+
self._completion_hook,
228+
data_source_id=data_source_id,
229+
provider=provider,
230+
request_model=request_model,
231+
server_address=server_address,
232+
server_port=server_port,
233+
)
234+
205235
def start_tool(
206236
self,
207237
name: str,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
GenAIInvocation,
1111
InferenceInvocation,
1212
EmbeddingInvocation,
13+
RetrievalInvocation,
1314
ToolInvocation,
1415
WorkflowInvocation,
1516
)
@@ -23,6 +24,7 @@
2324
Error,
2425
GenAIInvocation,
2526
)
27+
from opentelemetry.util.genai._retrieval_invocation import RetrievalInvocation
2628
from opentelemetry.util.genai._tool_invocation import ToolInvocation
2729
from opentelemetry.util.genai._workflow_invocation import WorkflowInvocation
2830

@@ -33,6 +35,7 @@
3335
"GenAIInvocation",
3436
"InferenceInvocation",
3537
"EmbeddingInvocation",
38+
"RetrievalInvocation",
3639
"ToolInvocation",
3740
"WorkflowInvocation",
3841
]

util/opentelemetry-util-genai/tests/test_handler_metrics.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,3 +392,130 @@ def test_fail_tool_records_duration_with_error(self) -> None:
392392
)
393393
self.assertAlmostEqual(duration_point.sum, 1.5, places=3)
394394
self.assertNotIn("gen_ai.client.token.usage", metrics)
395+
396+
397+
class TelemetryHandlerRetrievalMetricsTest(TestBase):
398+
def _harvest_metrics(self) -> Dict[str, List[Any]]:
399+
metrics = self.get_sorted_metrics()
400+
metrics_by_name: Dict[str, List[Any]] = {}
401+
for metric in metrics or []:
402+
points = metric.data.data_points or []
403+
metrics_by_name.setdefault(metric.name, []).extend(points)
404+
return metrics_by_name
405+
406+
def test_stop_retrieval_records_duration(self) -> None:
407+
handler = TelemetryHandler(
408+
tracer_provider=self.tracer_provider,
409+
meter_provider=self.meter_provider,
410+
)
411+
with patch("timeit.default_timer", return_value=1000.0):
412+
invocation = handler.retrieval(
413+
provider="pinecone", request_model="text-embedding-ada-002"
414+
)
415+
416+
with patch("timeit.default_timer", return_value=1001.5):
417+
invocation.stop()
418+
419+
metrics = self._harvest_metrics()
420+
self.assertIn("gen_ai.client.operation.duration", metrics)
421+
duration_points = metrics["gen_ai.client.operation.duration"]
422+
self.assertEqual(len(duration_points), 1)
423+
duration_point = duration_points[0]
424+
425+
self.assertEqual(
426+
duration_point.attributes[GenAI.GEN_AI_OPERATION_NAME],
427+
GenAI.GenAiOperationNameValues.RETRIEVAL.value,
428+
)
429+
self.assertEqual(
430+
duration_point.attributes[GenAI.GEN_AI_PROVIDER_NAME], "pinecone"
431+
)
432+
self.assertEqual(
433+
duration_point.attributes[GenAI.GEN_AI_REQUEST_MODEL],
434+
"text-embedding-ada-002",
435+
)
436+
self.assertAlmostEqual(duration_point.sum, 1.5, places=3)
437+
self.assertNotIn("gen_ai.client.token.usage", metrics)
438+
439+
def test_stop_retrieval_excludes_data_source_id_from_metrics(self) -> None:
440+
handler = TelemetryHandler(
441+
tracer_provider=self.tracer_provider,
442+
meter_provider=self.meter_provider,
443+
)
444+
invocation = handler.retrieval(
445+
data_source_id="DS_HIGH_CARDINALITY", provider="weaviate"
446+
)
447+
invocation.stop()
448+
449+
metrics = self._harvest_metrics()
450+
self.assertIn("gen_ai.client.operation.duration", metrics)
451+
duration_points = metrics["gen_ai.client.operation.duration"]
452+
self.assertEqual(len(duration_points), 1)
453+
duration_point = duration_points[0]
454+
455+
self.assertNotIn(
456+
GenAI.GEN_AI_DATA_SOURCE_ID, duration_point.attributes
457+
)
458+
self.assertEqual(
459+
duration_point.attributes[GenAI.GEN_AI_PROVIDER_NAME], "weaviate"
460+
)
461+
462+
def test_stop_retrieval_records_duration_with_additional_attributes(
463+
self,
464+
) -> None:
465+
handler = TelemetryHandler(
466+
tracer_provider=self.tracer_provider,
467+
meter_provider=self.meter_provider,
468+
)
469+
invocation = handler.retrieval(
470+
provider="pinecone",
471+
server_address="db.example.com",
472+
server_port=443,
473+
)
474+
invocation.metric_attributes = {"custom.retrieval.attr": "val"}
475+
invocation.attributes = {"should not be on metrics": "value"}
476+
invocation.stop()
477+
478+
metrics = self._harvest_metrics()
479+
self.assertIn("gen_ai.client.operation.duration", metrics)
480+
duration_points = metrics["gen_ai.client.operation.duration"]
481+
self.assertEqual(len(duration_points), 1)
482+
duration_point = duration_points[0]
483+
484+
self.assertEqual(
485+
duration_point.attributes["server.address"], "db.example.com"
486+
)
487+
self.assertEqual(duration_point.attributes["server.port"], 443)
488+
self.assertEqual(
489+
duration_point.attributes["custom.retrieval.attr"], "val"
490+
)
491+
self.assertIsNone(
492+
duration_point.attributes.get("should not be on metrics")
493+
)
494+
495+
def test_fail_retrieval_records_duration_with_error(self) -> None:
496+
handler = TelemetryHandler(
497+
tracer_provider=self.tracer_provider,
498+
meter_provider=self.meter_provider,
499+
)
500+
with patch("timeit.default_timer", return_value=2000.0):
501+
invocation = handler.retrieval(provider="pinecone")
502+
503+
error = Error(message="retrieval failed", type=ConnectionError)
504+
with patch("timeit.default_timer", return_value=2003.0):
505+
invocation.fail(error)
506+
507+
metrics = self._harvest_metrics()
508+
self.assertIn("gen_ai.client.operation.duration", metrics)
509+
duration_points = metrics["gen_ai.client.operation.duration"]
510+
self.assertEqual(len(duration_points), 1)
511+
duration_point = duration_points[0]
512+
513+
self.assertEqual(
514+
duration_point.attributes["error.type"], "ConnectionError"
515+
)
516+
self.assertEqual(
517+
duration_point.attributes[GenAI.GEN_AI_OPERATION_NAME],
518+
GenAI.GenAiOperationNameValues.RETRIEVAL.value,
519+
)
520+
self.assertAlmostEqual(duration_point.sum, 3.0, places=3)
521+
self.assertNotIn("gen_ai.client.token.usage", metrics)

0 commit comments

Comments
 (0)