Skip to content

Commit f1b6ea9

Browse files
committed
GenAI Utils minor API cleanup
1 parent d22bf39 commit f1b6ea9

8 files changed

Lines changed: 105 additions & 95 deletions

File tree

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,7 @@ deps =
790790
util-genai: {[testenv]test_deps}
791791
util-genai: -r {toxinidir}/util/opentelemetry-util-genai/test-requirements.txt
792792
util-genai: {toxinidir}/util/opentelemetry-util-genai
793+
lint-util-genai: {toxinidir}/util/opentelemetry-util-genai
793794

794795
; FIXME: add coverage testing
795796
allowlist_externals =

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

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,13 @@
2828
Usage:
2929
handler = get_telemetry_handler()
3030
31-
# Create an invocation object with your request data
32-
# The span and context_token attributes are set by the TelemetryHandler, and
33-
# managed by the TelemetryHandler during the lifecycle of the span.
31+
# Create an invocation object
32+
invocation = LLMInvocation(
33+
request_model="my-model",
34+
input_messages=[...],
35+
provider="my-provider"
36+
)
37+
3438
3539
# Use the context manager to manage the lifecycle of an LLM invocation.
3640
with handler.llm(invocation) as invocation:
@@ -39,31 +43,24 @@
3943
invocation.attributes.update({"more": "attrs"})
4044
4145
# Or, if you prefer to manage the lifecycle manually
42-
invocation = LLMInvocation(
43-
request_model="my-model",
44-
input_messages=[...],
45-
provider="my-provider",
46-
attributes={"custom": "attr"},
47-
)
48-
49-
# Start the invocation (opens a span)
5046
handler.start_llm(invocation)
5147
5248
# Populate outputs and any additional attributes, then stop (closes the span)
5349
invocation.output_messages = [...]
5450
invocation.attributes.update({"more": "attrs"})
5551
handler.stop_llm(invocation)
5652
57-
# Or, in case of error
58-
handler.fail_llm(invocation, Error(type="...", message="..."))
53+
# Or, in case of error — pass an exception directly or an Error object
54+
handler.fail_llm(invocation, exc)
55+
handler.fail_llm(invocation, Error(type=MyError, message="..."))
5956
"""
6057

6158
from __future__ import annotations
6259

6360
import logging
6461
import timeit
6562
from contextlib import contextmanager
66-
from typing import Iterator, TypeVar
63+
from typing import Iterator, TypeVar, overload
6764

6865
from opentelemetry import context as otel_context
6966
from opentelemetry._logs import (
@@ -104,9 +101,9 @@
104101

105102
def _safe_detach(invocation: GenAIInvocation) -> None:
106103
"""Detach the context token if still present, as a safety net."""
107-
if invocation.context_token is not None:
104+
if invocation._context_token is not None:
108105
try:
109-
otel_context.detach(invocation.context_token)
106+
otel_context.detach(invocation._context_token)
110107
except Exception: # pylint: disable=broad-except
111108
pass
112109
if invocation.span is not None:
@@ -197,16 +194,16 @@ def _start(self, invocation: _T) -> _T:
197194
)
198195
# Record a monotonic start timestamp (seconds) for duration
199196
# calculation using timeit.default_timer.
200-
invocation.monotonic_start_s = timeit.default_timer()
197+
invocation._monotonic_start_s = timeit.default_timer()
201198
invocation.span = span
202-
invocation.context_token = otel_context.attach(
199+
invocation._context_token = otel_context.attach(
203200
set_span_in_context(span)
204201
)
205202
return invocation
206203

207204
def _stop(self, invocation: _T) -> _T:
208205
"""Finalize a GenAI invocation successfully and end its span."""
209-
if invocation.context_token is None or invocation.span is None:
206+
if invocation._context_token is None or invocation.span is None:
210207
# TODO: Provide feedback that this invocation was not started
211208
return invocation
212209

@@ -224,13 +221,13 @@ def _stop(self, invocation: _T) -> _T:
224221
# TODO: Add workflow metrics when supported
225222
finally:
226223
# Detach context and end span even if finishing fails
227-
otel_context.detach(invocation.context_token)
224+
otel_context.detach(invocation._context_token)
228225
span.end()
229226
return invocation
230227

231228
def _fail(self, invocation: _T, error: Error) -> _T:
232229
"""Fail a GenAI invocation and end its span with error status."""
233-
if invocation.context_token is None or invocation.span is None:
230+
if invocation._context_token is None or invocation.span is None:
234231
# TODO: Provide feedback that this invocation was not started
235232
return invocation
236233

@@ -258,7 +255,7 @@ def _fail(self, invocation: _T, error: Error) -> _T:
258255
# TODO: Add workflow metrics when supported
259256
finally:
260257
# Detach context and end span even if finishing fails
261-
otel_context.detach(invocation.context_token)
258+
otel_context.detach(invocation._context_token)
262259
span.end()
263260
return invocation
264261

@@ -273,8 +270,16 @@ def stop(self, invocation: _T) -> _T:
273270
"""Finalize a GenAI invocation successfully and end its span."""
274271
return self._stop(invocation)
275272

276-
def fail(self, invocation: _T, error: Error) -> _T:
273+
@overload
274+
def fail(self, invocation: _T, error: Error) -> _T: ...
275+
276+
@overload
277+
def fail(self, invocation: _T, error: BaseException) -> _T: ...
278+
279+
def fail(self, invocation: _T, error: Error | BaseException) -> _T:
277280
"""Fail a GenAI invocation and end its span with error status."""
281+
if isinstance(error, BaseException):
282+
error = Error(type=type(error), message=str(error))
278283
return self._fail(invocation, error)
279284

280285
# LLM-specific convenience methods
@@ -286,10 +291,22 @@ def stop_llm(self, invocation: LLMInvocation) -> LLMInvocation:
286291
"""Finalize an LLM invocation successfully and end its span."""
287292
return self._stop(invocation)
288293

294+
@overload
289295
def fail_llm(
290296
self, invocation: LLMInvocation, error: Error
297+
) -> LLMInvocation: ...
298+
299+
@overload
300+
def fail_llm(
301+
self, invocation: LLMInvocation, error: BaseException
302+
) -> LLMInvocation: ...
303+
304+
def fail_llm(
305+
self, invocation: LLMInvocation, error: Error | BaseException
291306
) -> LLMInvocation:
292307
"""Fail an LLM invocation and end its span with error status."""
308+
if isinstance(error, BaseException):
309+
error = Error(type=type(error), message=str(error))
293310
return self._fail(invocation, error)
294311

295312
@contextmanager
@@ -312,7 +329,7 @@ def llm(
312329
try:
313330
yield invocation
314331
except Exception as exc:
315-
self.fail_llm(invocation, Error(message=str(exc), type=type(exc)))
332+
self.fail_llm(invocation, exc)
316333
raise
317334
self.stop_llm(invocation)
318335

@@ -334,7 +351,7 @@ def embedding(
334351
try:
335352
yield invocation
336353
except Exception as exc:
337-
self.fail(invocation, Error(message=str(exc), type=type(exc)))
354+
self.fail(invocation, exc)
338355
raise
339356
self.stop(invocation)
340357

@@ -364,7 +381,7 @@ def workflow(
364381
yield invocation
365382
except Exception as exc:
366383
try:
367-
self.fail(invocation, Error(message=str(exc), type=type(exc)))
384+
self.fail(invocation, exc)
368385
except Exception: # pylint: disable=broad-except
369386
_logger.warning(
370387
"Failed to record workflow failure", exc_info=True

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,9 @@ def record(
8181

8282
# Calculate duration from span timing or invocation monotonic start
8383
duration_seconds: Optional[float] = None
84-
if invocation.monotonic_start_s is not None:
84+
if invocation._monotonic_start_s is not None:
8585
duration_seconds = max(
86-
timeit.default_timer() - invocation.monotonic_start_s,
86+
timeit.default_timer() - invocation._monotonic_start_s,
8787
0.0,
8888
)
8989

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def _get_llm_common_attributes(
6767

6868
return {
6969
GenAI.GEN_AI_OPERATION_NAME: invocation.operation_name,
70-
**{key: value for key, value in optional_attrs if value is not None},
70+
**{key: value for key, value in optional_attrs if value},
7171
}
7272

7373

@@ -79,14 +79,14 @@ def _get_embedding_common_attributes(
7979
Returns a dictionary of attributes.
8080
"""
8181
optional_attrs = (
82+
(GenAI.GEN_AI_PROVIDER_NAME, invocation.provider),
8283
(server_attributes.SERVER_ADDRESS, invocation.server_address),
8384
(server_attributes.SERVER_PORT, invocation.server_port),
8485
)
8586

8687
return {
8788
GenAI.GEN_AI_OPERATION_NAME: invocation.operation_name,
88-
GenAI.GEN_AI_PROVIDER_NAME: invocation.provider,
89-
**{key: value for key, value in optional_attrs if value is not None},
89+
**{key: value for key, value in optional_attrs if value},
9090
}
9191

9292

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

Lines changed: 33 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -256,24 +256,42 @@ def _new_str_any_dict() -> dict[str, Any]:
256256

257257
@dataclass
258258
class GenAIInvocation:
259-
context_token: ContextToken | None = None
260-
span: Span | None = None
259+
"""
260+
Base class for all GenAI invocation types. Manages the lifecycle of a single
261+
GenAI operation (LLM call, embedding, tool execution, workflow, etc.).
262+
"""
263+
264+
span: Span | None = field(default=None, init=False)
265+
_context_token: ContextToken | None = field(default=None, init=False)
266+
_monotonic_start_s: float | None = field(default=None, init=False)
267+
268+
operation_name: str = ""
269+
"""
270+
GenAI operation name (maps to gen_ai.operation.name). Subclasses hardcode
271+
this to one of the GenAiOperationNameValues enum values. Callers may
272+
override it, but this is not recommended.
273+
"""
274+
261275
attributes: dict[str, Any] = field(default_factory=_new_str_any_dict)
262-
error_type: str | None = None
276+
"""
277+
Additional attributes to set on spans and/or events. Not set on metrics.
278+
"""
263279

264-
monotonic_start_s: float | None = None
280+
metric_attributes: dict[str, Any] = field(
281+
default_factory=_new_str_any_dict
282+
)
265283
"""
266-
Monotonic start time in seconds (from timeit.default_timer) used for
267-
duration calculations to avoid mixing clock sources. This is populated
268-
by the TelemetryHandler when starting an invocation.
284+
Additional attributes to set on metrics. Must be low cardinality.
285+
Not set on spans or events.
269286
"""
270287

271288

272289
@dataclass
273290
class WorkflowInvocation(GenAIInvocation):
274291
"""
275-
Represents predetermined static sequence of operations eg: Agent, LLM, tool, and retrieval invocations.
276-
A workflow groups multiple operations together, accepting input(s) and producing final output(s).
292+
Represents a predetermined sequence of operations (e.g. agent, LLM, tool,
293+
and retrieval invocations). A workflow groups multiple operations together,
294+
accepting input(s) and producing final output(s).
277295
"""
278296

279297
name: str = ""
@@ -291,11 +309,7 @@ def __post_init__(self) -> None:
291309

292310
@dataclass
293311
class LLMInvocation(GenAIInvocation):
294-
"""
295-
Represents a single LLM call invocation. When creating an LLMInvocation object,
296-
only update the data attributes. The span and context_token attributes are
297-
set by the TelemetryHandler.
298-
"""
312+
"""Represents a single LLM chat/completion call."""
299313

300314
operation_name: str = GenAI.GenAiOperationNameValues.CHAT.value
301315
request_model: str | None = None
@@ -308,24 +322,12 @@ class LLMInvocation(GenAIInvocation):
308322
system_instruction: list[MessagePart] = field(
309323
default_factory=_new_system_instruction
310324
)
311-
provider: str | None = None
325+
provider: str = ""
312326
response_model_name: str | None = None
313327
response_id: str | None = None
314328
finish_reasons: list[str] | None = None
315329
input_tokens: int | None = None
316330
output_tokens: int | None = None
317-
attributes: dict[str, Any] = field(default_factory=_new_str_any_dict)
318-
"""
319-
Additional attributes to set on spans and/or events. These attributes
320-
will not be set on metrics.
321-
"""
322-
metric_attributes: dict[str, Any] = field(
323-
default_factory=_new_str_any_dict
324-
)
325-
"""
326-
Additional attributes to set on metrics. Must be of a low cardinality.
327-
These attributes will not be set on spans or events.
328-
"""
329331
temperature: float | None = None
330332
top_p: float | None = None
331333
frequency_penalty: float | None = None
@@ -339,15 +341,11 @@ class LLMInvocation(GenAIInvocation):
339341

340342
@dataclass
341343
class EmbeddingInvocation(GenAIInvocation):
342-
"""
343-
Represents a single embedding model invocation. When creating an
344-
EmbeddingInvocation object, only update the data attributes. The span
345-
and context_token attributes are set by the TelemetryHandler.
346-
"""
344+
"""Represents a single embedding model invocation."""
347345

348346
operation_name: str = GenAI.GenAiOperationNameValues.EMBEDDINGS.value
349347
request_model: str | None = None
350-
provider: str | None = None # e.g., azure.ai.openai, openai, aws.bedrock
348+
provider: str = "" # e.g., azure.ai.openai, openai, aws.bedrock
351349
server_address: str | None = None
352350
server_port: int | None = None
353351

@@ -358,33 +356,12 @@ class EmbeddingInvocation(GenAIInvocation):
358356
dimension_count: int | None = None
359357
response_model_name: str | None = None
360358

361-
attributes: dict[str, Any] = field(default_factory=_new_str_any_dict)
362-
"""
363-
Additional attributes to set on spans and/or events. These attributes
364-
will not be set on metrics.
365-
"""
366-
367-
metric_attributes: dict[str, Any] = field(
368-
default_factory=_new_str_any_dict
369-
)
370-
"""
371-
Additional attributes to set on metrics. Must be of a low cardinality.
372-
These attributes will not be set on spans or events.
373-
"""
374-
375359

376360
@dataclass()
377361
class ToolCall(GenAIInvocation):
378-
"""Represents a tool call for execution tracking with spans and metrics.
362+
"""Represents a tool call invocation for execute_tool span tracking.
379363
380-
This type extends GenAIInvocation (like LLMInvocation) for consistent lifecycle
381-
management across all invocation types. It is NOT used as a MessagePart directly -
382-
use ToolCallRequest for that purpose.
383-
384-
Inherits from GenAIInvocation:
385-
- context_token: Context tracking for span lifecycle
386-
- span: Active span reference
387-
- attributes: Custom attributes dict for extensibility
364+
Not used as a message part — use ToolCallRequest for that purpose.
388365
389366
Reference: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-spans.md#execute-tool-span
390367
@@ -414,9 +391,6 @@ class ToolCall(GenAIInvocation):
414391
# gen_ai.tool.call.result - Result returned by the tool (Opt-In, may contain sensitive data)
415392
tool_result: Any = None
416393

417-
# Timing field (not inherited from GenAIInvocation, matches LLMInvocation pattern)
418-
monotonic_start_s: float | None = None
419-
420394

421395
@dataclass
422396
class Error:

0 commit comments

Comments
 (0)