Skip to content

Commit 50b9b33

Browse files
Refactor GenAIInvocation to be a ContextManager to simplify code. Minor change to ToolInvocation. (#17)
* Make a few minor changes to utils.. Remove start_tool in favor of just using tool * Respond to copilot comments * Fix typecheck * Respond to PR comments * Fix changelog and lint issue * Fix typecheck * Fix typing.. --------- Co-authored-by: Liudmila Molkova <neskazu@gmail.com>
1 parent 6e7f203 commit 50b9b33

19 files changed

Lines changed: 297 additions & 208 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add `tool_result` as a parameter to `ToolInvocation`, add `tool_result` and `arguments` as span attributes to the `execute_tool` span if `ContentCapture` flag is set.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
deprecate all `start_` factories, update all `invocation` factories to return objects that can be used as ContextManager's

util/opentelemetry-util-genai/AGENTS.md

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ applicable ones.
2525
Every new operation type must follow this pattern:
2626

2727
```python
28-
invocation = handler.start_inference(provider, request_model, server_address=..., server_port=...)
28+
invocation = handler.inference(provider, request_model, server_address=..., server_port=...)
2929
invocation.temperature = ...
3030
try:
3131
response = client.call(...)
@@ -39,24 +39,23 @@ except Exception as exc:
3939

4040
Factory methods on `TelemetryHandler` (`handler.py`):
4141

42-
- `start_inference(provider, request_model, *, server_address, server_port)``InferenceInvocation`
43-
- `start_embedding(provider, request_model, *, server_address, server_port)``EmbeddingInvocation`
44-
- `start_tool(name, *, arguments, tool_call_id, tool_type, tool_description)``ToolInvocation`
45-
- `start_workflow(name)``WorkflowInvocation`
42+
- `inference(provider, request_model, *, server_address, server_port)``InferenceInvocation`
43+
- `embedding(provider, request_model, *, server_address, server_port)``EmbeddingInvocation`
44+
- `tool(name, *, arguments, tool_call_id, tool_type, tool_description)``ToolInvocation`
45+
- `workflow(name)``WorkflowInvocation`
4646

47-
Context manager equivalents (`handler.inference()`, `handler.embedding()`, `handler.tool()`,
48-
`handler.workflow()`) are available when the span lifetime maps cleanly to a `with` block.
47+
The returned object can also be used as a context manager (`with ... as invocation:`) when the span lifetime maps cleanly to a `with` block.
4948

50-
`start_*()` factories must map 1:1 to distinct semconv operation types (inference, embeddings,
49+
The above factories must map 1:1 to distinct semconv operation types (inference, embeddings,
5150
tool execution, agent invocation, workflow invocation). Names must match the operation
5251
unambiguously — for example, `create_agent` and `invoke_agent` are different operations, so a
53-
single `start_agent()` would be ambiguous and is not acceptable. Add a new factory per operation
52+
single `agent()` would be ambiguous and is not acceptable. Add a new factory per operation
5453
instead.
5554

56-
Factory names are Python-style singular verbs (`start_embedding`, `start_tool`); the op names
57-
they map to follow semconv (`embeddings`, `tool execution`, future operations).
55+
Factory names are Python-style singular verbs (`inference`, `embedding`, `tool`, `workflow`); the op names
56+
they map to follow semconv operations.
5857

59-
`start_*()` factories must accept all attributes that semconv marks as important for sampling
58+
Factory methods must accept all attributes that semconv marks as important for sampling
6059
decisions as parameters, so they are on the span at creation time. Attributes that are also
6160
marked required by semconv must be required parameters (no default value). Operation name
6261
is usually hardcoded in specific invocation and does not need to be passed.
@@ -65,7 +64,7 @@ is usually hardcoded in specific invocation and does not need to be passed.
6564

6665
**Never construct invocation types directly** (`InferenceInvocation(...)`, `ToolInvocation(...)`,
6766
etc.) in instrumentation or production code — direct construction skips span creation and context
68-
propagation, so all telemetry calls become no-ops. Always use `handler.start_*()`.
67+
propagation, so all telemetry calls become no-ops. Always use `handler.*()`.
6968

7069
## 3. Exception Handling
7170

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,8 @@
3636
class AgentInvocation(GenAIInvocation):
3737
"""Represents a single agent invocation (invoke_agent span).
3838
39-
Use handler.start_invoke_local_agent() / handler.start_invoke_remote_agent()
40-
or the handler.invoke_local_agent() / handler.invoke_remote_agent() context
41-
managers rather than constructing this directly.
39+
Use handler.invoke_local_agent() or handler.invoke_remote_agent()
40+
rather than constructing this directly.
4241
4342
Reference:
4443
Client span: https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-agent-spans.md#invoke-agent-client-span
@@ -59,7 +58,7 @@ def __init__(
5958
server_port: int | None = None,
6059
agent_name: str | None = None,
6160
) -> None:
62-
"""Use handler.start_invoke_local_agent() or handler.start_invoke_remote_agent() instead of calling this directly."""
61+
"""Use handler.invoke_local_agent() or handler.invoke_remote_agent() instead of calling this directly."""
6362
_operation_name = GenAI.GenAiOperationNameValues.INVOKE_AGENT.value
6463
super().__init__(
6564
tracer,

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,7 @@
2020
class EmbeddingInvocation(GenAIInvocation):
2121
"""Represents a single embedding model invocation.
2222
23-
Use handler.start_embedding(provider) or the handler.embedding(provider)
24-
context manager rather than constructing this directly.
23+
Use handler.embedding(provider) rather than constructing this directly.
2524
"""
2625

2726
def __init__(
@@ -36,7 +35,7 @@ def __init__(
3635
server_address: str | None = None,
3736
server_port: int | None = None,
3837
) -> None:
39-
"""Use handler.start_embedding(provider) or handler.embedding(provider) instead of calling this directly."""
38+
"""Use handler.embedding(provider) rather than calling this directly."""
4039
_operation_name = GenAI.GenAiOperationNameValues.EMBEDDINGS.value
4140
super().__init__(
4241
tracer,

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@
3737
class InferenceInvocation(GenAIInvocation):
3838
"""Represents a single LLM chat/completion call.
3939
40-
Use handler.start_inference(provider) or the handler.inference(provider)
41-
context manager rather than constructing this directly.
40+
Use handler.inference(provider) rather than constructing this directly.
4241
"""
4342

4443
def __init__(
@@ -57,7 +56,7 @@ def __init__(
5756
operation_name = (
5857
operation_name or GenAI.GenAiOperationNameValues.CHAT.value
5958
)
60-
"""Use handler.start_inference(provider) or handler.inference(provider) instead of calling this directly."""
59+
"""Use handler.inference(provider) rather than calling this directly."""
6160
super().__init__(
6261
tracer,
6362
metrics_recorder,

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

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
from __future__ import annotations
55

66
import timeit
7-
from abc import ABC, abstractmethod
8-
from contextlib import contextmanager
7+
from abc import abstractmethod
8+
from contextlib import AbstractContextManager
99
from contextvars import Token
1010
from dataclasses import asdict
11-
from typing import TYPE_CHECKING, Any, Generator, Sequence
11+
from types import TracebackType
12+
from typing import TYPE_CHECKING, Any, Sequence
1213

13-
from typing_extensions import Self, TypeAlias
14+
from typing_extensions import TypeAlias
1415

1516
from opentelemetry._logs import Logger, LogRecord
1617
from opentelemetry.context import Context, attach, detach
@@ -39,10 +40,11 @@
3940
if TYPE_CHECKING:
4041
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
4142

43+
4244
ContextToken: TypeAlias = Token[Context]
4345

4446

45-
class GenAIInvocation(ABC):
47+
class GenAIInvocation(AbstractContextManager["GenAIInvocation"]):
4648
"""
4749
Base class for all GenAI invocation types. Manages the lifecycle of a single
4850
GenAI operation (LLM call, embedding, tool execution, workflow, etc.).
@@ -165,15 +167,19 @@ def fail(self, error: Error | BaseException) -> None:
165167
error = Error(type=type(error), message=str(error))
166168
self._finish(error)
167169

168-
@contextmanager
169-
def _managed(self) -> Generator[Self, None, None]:
170-
"""Context manager that calls stop() on success or fail() on exception."""
171-
try:
172-
yield self
173-
except Exception as exc:
174-
self.fail(exc)
175-
raise
176-
self.stop()
170+
def __enter__(self) -> GenAIInvocation:
171+
return self
172+
173+
def __exit__(
174+
self,
175+
exc_type: type[BaseException] | None,
176+
exc_value: BaseException | None,
177+
traceback: TracebackType | None,
178+
) -> None:
179+
if exc_value is not None and isinstance(exc_value, Exception):
180+
self.fail(exc_value)
181+
else:
182+
self.stop()
177183

178184

179185
def get_content_attributes(

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

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
from opentelemetry.util.genai._invocation import Error, GenAIInvocation
1414
from opentelemetry.util.genai.completion_hook import CompletionHook
1515
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
16+
from opentelemetry.util.genai.utils import (
17+
should_capture_content_on_spans,
18+
)
19+
from opentelemetry.util.types import AttributeValue
1620

1721

1822
class ToolInvocation(GenAIInvocation):
@@ -43,10 +47,11 @@ def __init__(
4347
completion_hook: CompletionHook,
4448
name: str,
4549
*,
46-
arguments: Any = None,
50+
arguments: AttributeValue | None = None,
4751
tool_call_id: str | None = None,
4852
tool_type: str | None = None,
4953
tool_description: str | None = None,
54+
tool_result: AttributeValue | None = None,
5055
) -> None:
5156
"""Use handler.start_tool(name) or handler.tool(name) instead of calling this directly."""
5257
_operation_name = GenAI.GenAiOperationNameValues.EXECUTE_TOOL.value
@@ -58,12 +63,13 @@ def __init__(
5863
operation_name=_operation_name,
5964
span_name=f"{_operation_name} {name}" if name else _operation_name,
6065
)
66+
self.should_capture_content_on_span = should_capture_content_on_spans()
6167
self.name = name
68+
self.tool_result = tool_result
6269
self.arguments = arguments
6370
self.tool_call_id = tool_call_id
6471
self.tool_type = tool_type
6572
self.tool_description = tool_description
66-
self.tool_result: Any = None
6773
self._start(self._get_base_attributes())
6874

6975
def _get_base_attributes(self) -> dict[str, Any]:
@@ -73,6 +79,18 @@ def _get_base_attributes(self) -> dict[str, Any]:
7379
(GenAI.GEN_AI_TOOL_CALL_ID, self.tool_call_id),
7480
(GenAI.GEN_AI_TOOL_TYPE, self.tool_type),
7581
(GenAI.GEN_AI_TOOL_DESCRIPTION, self.tool_description),
82+
(
83+
GenAI.GEN_AI_TOOL_CALL_ARGUMENTS,
84+
self.arguments
85+
if self.should_capture_content_on_span
86+
else None,
87+
),
88+
(
89+
GenAI.GEN_AI_TOOL_CALL_RESULT,
90+
self.tool_result
91+
if self.should_capture_content_on_span
92+
else None,
93+
),
7694
)
7795
return {
7896
GenAI.GEN_AI_OPERATION_NAME: self._operation_name,
@@ -94,7 +112,18 @@ def _apply_finish(self, error: Error | None = None) -> None:
94112
(GenAI.GEN_AI_TOOL_CALL_ID, self.tool_call_id),
95113
(GenAI.GEN_AI_TOOL_TYPE, self.tool_type),
96114
(GenAI.GEN_AI_TOOL_DESCRIPTION, self.tool_description),
97-
(GenAI.GEN_AI_TOOL_CALL_ARGUMENTS, self.arguments),
115+
(
116+
GenAI.GEN_AI_TOOL_CALL_ARGUMENTS,
117+
self.arguments
118+
if self.should_capture_content_on_span
119+
else None,
120+
),
121+
(
122+
GenAI.GEN_AI_TOOL_CALL_RESULT,
123+
self.tool_result
124+
if self.should_capture_content_on_span
125+
else None,
126+
),
98127
)
99128
attributes: dict[str, Any] = {
100129
GenAI.GEN_AI_OPERATION_NAME: self._operation_name,

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ class WorkflowInvocation(GenAIInvocation):
3232
and retrieval invocations). A workflow groups multiple operations together,
3333
accepting input(s) and producing final output(s).
3434
35-
Use handler.start_workflow(name) or the handler.workflow(name) context
36-
manager rather than constructing this directly.
35+
Use handler.workflow(name) rather than constructing this directly.
3736
"""
3837

3938
def __init__(
@@ -44,7 +43,7 @@ def __init__(
4443
completion_hook: CompletionHook,
4544
name: str | None,
4645
) -> None:
47-
"""Use handler.start_workflow(name) or handler.workflow(name) instead of calling this directly."""
46+
"""Use handler.workflow(name) rather than calling this directly."""
4847
_operation_name = "invoke_workflow"
4948
super().__init__(
5049
tracer,

0 commit comments

Comments
 (0)