Skip to content

Commit f8656f0

Browse files
committed
Add embedding invocation type
1 parent 2b8ca97 commit f8656f0

4 files changed

Lines changed: 377 additions & 3 deletions

File tree

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

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,15 @@
8282
from opentelemetry.util.genai.span_utils import (
8383
_apply_error_attributes,
8484
_apply_llm_finish_attributes,
85+
_apply_embedding_finish_attributes,
8586
_maybe_emit_llm_event,
87+
_maybe_emit_embedding_event,
88+
)
89+
from opentelemetry.util.genai.types import (
90+
Error,
91+
LLMInvocation,
92+
EmbeddingInvocation,
8693
)
87-
from opentelemetry.util.genai.types import Error, LLMInvocation
8894
from opentelemetry.util.genai.version import __version__
8995

9096

@@ -131,6 +137,18 @@ def _record_llm_metrics(
131137
error_type=error_type,
132138
)
133139

140+
def _record_embedding_metrics(
141+
self,
142+
invocation: EmbeddingInvocation,
143+
span: Span | None = None,
144+
*,
145+
error_type: str | None = None,
146+
) -> None:
147+
# Metrics recorder currently supports LLMInvocation fields only.
148+
# Keep embedding metrics as a no-op until dedicated embedding
149+
# metric support is added.
150+
return
151+
134152
def start_llm(
135153
self,
136154
invocation: LLMInvocation,
@@ -208,6 +226,62 @@ def llm(
208226
raise
209227
self.stop_llm(invocation)
210228

229+
def start_embedding(
230+
self, invocation: EmbeddingInvocation
231+
) -> EmbeddingInvocation:
232+
"""Start an embedding invocation and create a pending span entry."""
233+
234+
span = self._tracer.start_span(
235+
name=f"{invocation.operation_name} {invocation.request_model}",
236+
kind=SpanKind.CLIENT,
237+
)
238+
# Record a monotonic start timestamp (seconds) for duration
239+
# calculation using timeit.default_timer.
240+
invocation.monotonic_start_s = timeit.default_timer()
241+
invocation.span = span
242+
invocation.context_token = otel_context.attach(
243+
set_span_in_context(span)
244+
)
245+
return invocation
246+
247+
def stop_embedding(
248+
self, invocation: EmbeddingInvocation
249+
) -> EmbeddingInvocation:
250+
"""Finalize an embedding invocation successfully and end its span."""
251+
if invocation.context_token is None or invocation.span is None:
252+
# TODO: Provide feedback that this invocation was not started
253+
return invocation
254+
255+
span = invocation.span
256+
_apply_embedding_finish_attributes(span, invocation)
257+
self._record_embedding_metrics(invocation, span)
258+
_maybe_emit_embedding_event(self._logger, span, invocation)
259+
# Detach context and end span
260+
otel_context.detach(invocation.context_token)
261+
span.end()
262+
return invocation
263+
264+
def fail_embedding(
265+
self, invocation: EmbeddingInvocation, error: Error
266+
) -> EmbeddingInvocation:
267+
"""Fail an embedding invocation and end its span with error status."""
268+
if invocation.context_token is None or invocation.span is None:
269+
# TODO: Provide feedback that this invocation was not started
270+
return invocation
271+
272+
span = invocation.span
273+
_apply_embedding_finish_attributes(invocation.span, invocation)
274+
_apply_error_attributes(invocation.span, error)
275+
error_type = getattr(error.type, "__qualname__", None)
276+
self._record_embedding_metrics(
277+
invocation, span, error_type=error_type
278+
)
279+
_maybe_emit_embedding_event(self._logger, span, invocation, error)
280+
# Detach context and end span
281+
otel_context.detach(invocation.context_token)
282+
span.end()
283+
return invocation
284+
211285

212286
def get_telemetry_handler(
213287
tracer_provider: TracerProvider | None = None,

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

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
Error,
3636
InputMessage,
3737
LLMInvocation,
38+
EmbeddingInvocation,
3839
MessagePart,
3940
OutputMessage,
4041
)
@@ -68,11 +69,35 @@ def _get_llm_common_attributes(
6869
}
6970

7071

72+
def _get_embedding_common_attributes(
73+
invocation: EmbeddingInvocation,
74+
) -> dict[str, Any]:
75+
"""Get common Embedding attributes shared by finish() and error() paths.
76+
77+
Returns a dictionary of attributes.
78+
"""
79+
optional_attrs = (
80+
(server_attributes.SERVER_ADDRESS, invocation.server_address),
81+
(server_attributes.SERVER_PORT, invocation.server_port),
82+
)
83+
84+
return {
85+
GenAI.GEN_AI_OPERATION_NAME: invocation.operation_name,
86+
GenAI.GEN_AI_PROVIDER_NAME: invocation.provider,
87+
**{key: value for key, value in optional_attrs if value is not None},
88+
}
89+
90+
7191
def _get_llm_span_name(invocation: LLMInvocation) -> str:
7292
"""Get the span name for an LLM invocation."""
7393
return f"{invocation.operation_name} {invocation.request_model}".strip()
7494

7595

96+
def _get_embedding_span_name(invocation: EmbeddingInvocation) -> str:
97+
"""Get the span name for an Embedding invocation."""
98+
return f"{invocation.operation_name} {invocation.request_model}".strip()
99+
100+
76101
def _get_llm_messages_attributes_for_span(
77102
input_messages: list[InputMessage],
78103
output_messages: list[OutputMessage],
@@ -192,6 +217,44 @@ def _maybe_emit_llm_event(
192217
logger.emit(event)
193218

194219

220+
def _maybe_emit_embedding_event(
221+
logger: Logger | None,
222+
span: Span,
223+
invocation: EmbeddingInvocation,
224+
error: Error | None = None,
225+
) -> None:
226+
"""Emit a gen_ai.client.inference.operation.details event to the logger.
227+
228+
This function creates a LogRecord event following the semantic convention
229+
for gen_ai.client.inference.operation.details as specified in the GenAI
230+
event semantic conventions.
231+
232+
For more details, see the semantic convention documentation:
233+
https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-events.md#event-eventgen_aiclientinferenceoperationdetails
234+
"""
235+
if not is_experimental_mode() or not should_emit_event() or logger is None:
236+
return
237+
238+
# Build event attributes by reusing the attribute getter functions
239+
attributes: dict[str, Any] = {}
240+
attributes.update(_get_embedding_common_attributes(invocation))
241+
attributes.update(_get_embedding_request_attributes(invocation))
242+
attributes.update(_get_embedding_response_attributes(invocation))
243+
244+
# Add error.type if operation ended in error
245+
if error is not None:
246+
attributes[error_attributes.ERROR_TYPE] = error.type.__qualname__
247+
248+
# Create and emit the event
249+
context = set_span_in_context(span, get_current())
250+
event = LogRecord(
251+
event_name="gen_ai.client.embedding.operation.details",
252+
attributes=attributes,
253+
context=context,
254+
)
255+
logger.emit(event)
256+
257+
195258
def _apply_llm_finish_attributes(
196259
span: Span, invocation: LLMInvocation
197260
) -> None:
@@ -218,6 +281,26 @@ def _apply_llm_finish_attributes(
218281
span.set_attributes(attributes)
219282

220283

284+
def _apply_embedding_finish_attributes(
285+
span: Span, invocation: EmbeddingInvocation
286+
) -> None:
287+
"""Apply attributes common to embedding finish() paths."""
288+
# Update span name
289+
span.update_name(_get_embedding_span_name(invocation))
290+
291+
# Build all attributes by reusing the attribute getter functions
292+
attributes: dict[str, Any] = {}
293+
attributes.update(_get_embedding_common_attributes(invocation))
294+
attributes.update(_get_embedding_request_attributes(invocation))
295+
attributes.update(_get_embedding_response_attributes(invocation))
296+
297+
attributes.update(invocation.attributes)
298+
299+
# Set all attributes on the span
300+
if attributes:
301+
span.set_attributes(attributes)
302+
303+
221304
def _apply_error_attributes(span: Span, error: Error) -> None:
222305
"""Apply status and error attributes common to error() paths."""
223306
span.set_status(Status(StatusCode.ERROR, error.message))
@@ -244,6 +327,19 @@ def _get_llm_request_attributes(
244327
return {key: value for key, value in optional_attrs if value is not None}
245328

246329

330+
def _get_embedding_request_attributes(
331+
invocation: EmbeddingInvocation,
332+
) -> dict[str, Any]:
333+
"""Get GenAI request semantic convention attributes."""
334+
optional_attrs = (
335+
(GenAI.GEN_AI_REQUEST_MODEL, invocation.request_model),
336+
(GenAI.GEN_AI_EMBEDDING_DIMENSION_COUNT, invocation.dimension_count),
337+
(GenAI.GEN_AI_REQUEST_ENCODING_FORMATS, invocation.encoding_formats),
338+
)
339+
340+
return {key: value for key, value in optional_attrs if value is not None}
341+
342+
247343
def _get_llm_response_attributes(
248344
invocation: LLMInvocation,
249345
) -> dict[str, Any]:
@@ -279,6 +375,17 @@ def _get_llm_response_attributes(
279375
return {key: value for key, value in optional_attrs if value is not None}
280376

281377

378+
def _get_embedding_response_attributes(
379+
invocation: EmbeddingInvocation,
380+
) -> dict[str, Any]:
381+
"""Get GenAI response semantic convention attributes."""
382+
optional_attrs = (
383+
(GenAI.GEN_AI_USAGE_INPUT_TOKENS, invocation.input_tokens),
384+
)
385+
386+
return {key: value for key, value in optional_attrs if value is not None}
387+
388+
282389
__all__ = [
283390
"_apply_llm_finish_attributes",
284391
"_apply_error_attributes",
@@ -287,4 +394,10 @@ def _get_llm_response_attributes(
287394
"_get_llm_response_attributes",
288395
"_get_llm_span_name",
289396
"_maybe_emit_llm_event",
397+
"_apply_embedding_finish_attributes",
398+
"_get_embedding_common_attributes",
399+
"_get_embedding_request_attributes",
400+
"_get_embedding_response_attributes",
401+
"_get_embedding_span_name",
402+
"_maybe_emit_embedding_event",
290403
]

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,61 @@ class LLMInvocation(GenAIInvocation):
256256
monotonic_start_s: float | None = None
257257

258258

259+
@dataclass
260+
class EmbeddingInvocation(GenAIInvocation):
261+
"""
262+
Represents a single embedding model invocation. When creating an
263+
EmbeddingInvocation object, only update the data attributes. The span
264+
and context_token attributes are set by the TelemetryHandler.
265+
"""
266+
267+
operation_name: str = field(
268+
default=GenAI.GenAiOperationNameValues.EMBEDDINGS.value,
269+
metadata={"semconv": GenAI.GEN_AI_OPERATION_NAME},
270+
)
271+
provider: str | None = None # e.g., azure.ai.openai, openai, aws.bedrock
272+
273+
request_model: str | None = field(
274+
default=None,
275+
metadata={"semconv": GenAI.GEN_AI_REQUEST_MODEL},
276+
)
277+
278+
server_address: str | None = None
279+
server_port: int | None = None
280+
error_type: str | None = None
281+
282+
# encoding_formats can be multi-value -> combinational cardinality risk.
283+
# Keep on spans/events only.
284+
encoding_formats: list[str] = field(
285+
default_factory=list,
286+
metadata={"semconv": GenAI.GEN_AI_REQUEST_ENCODING_FORMATS},
287+
)
288+
289+
input_tokens: int | None = field(
290+
default=None,
291+
metadata={"semconv": GenAI.GEN_AI_USAGE_INPUT_TOKENS},
292+
)
293+
dimension_count: int | None = None
294+
295+
attributes: dict[str, Any] = field(default_factory=_new_str_any_dict)
296+
"""
297+
Additional attributes to set on spans and/or events. These attributes
298+
will not be set on metrics.
299+
"""
300+
301+
metric_attributes: dict[str, Any] = field(
302+
default_factory=_new_str_any_dict
303+
)
304+
"""
305+
Additional attributes to set on metrics. Must be of a low cardinality.
306+
These attributes will not be set on spans or events.
307+
"""
308+
# Monotonic start time in seconds (from timeit.default_timer) used
309+
# for duration calculations to avoid mixing clock sources. This is
310+
# populated by the TelemetryHandler when starting an invocation.
311+
monotonic_start_s: float | None = None
312+
313+
259314
@dataclass
260315
class Error:
261316
message: str

0 commit comments

Comments
 (0)