Skip to content

Commit 8e6ce17

Browse files
committed
Merge branch 'main' into toolcall-handler-and-spans
2 parents 7391f3b + 828138a commit 8e6ce17

6 files changed

Lines changed: 437 additions & 10 deletions

File tree

util/opentelemetry-util-genai/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
- Add ToolCall span lifecycle support
1111
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4356/](#4356))
12+
- Add support for workflow in genAI utils handler.
13+
([https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4366](#4366))
1214
- Enrich ToolCall type, breaking change: usage of ToolCall class renamed to ToolCallRequest
1315
([#4218](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4218))
1416
- Add EmbeddingInvocation span lifecycle support

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

Lines changed: 76 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363

6464
from __future__ import annotations
6565

66+
import logging
6667
import timeit
6768
from contextlib import contextmanager
6869
from typing import Iterator, TypeVar
@@ -88,10 +89,12 @@
8889
_apply_error_attributes,
8990
_apply_llm_finish_attributes,
9091
_apply_tool_call_attributes,
92+
_apply_workflow_finish_attributes,
9193
_finish_tool_call_span,
9294
_get_embedding_span_name,
9395
_get_llm_span_name,
9496
_get_tool_call_span_name,
97+
_get_workflow_span_name,
9598
_maybe_emit_llm_event,
9699
)
97100
from opentelemetry.util.genai.types import (
@@ -100,9 +103,27 @@
100103
GenAIInvocation,
101104
LLMInvocation,
102105
ToolCall,
106+
WorkflowInvocation,
103107
)
104108
from opentelemetry.util.genai.version import __version__
105109

110+
_logger = logging.getLogger(__name__)
111+
112+
113+
def _safe_detach(invocation: GenAIInvocation) -> None:
114+
"""Detach the context token if still present, as a safety net."""
115+
if invocation.context_token is not None:
116+
try:
117+
otel_context.detach(invocation.context_token)
118+
except Exception: # pylint: disable=broad-except
119+
pass
120+
if invocation.span is not None:
121+
try:
122+
invocation.span.end()
123+
except Exception: # pylint: disable=broad-except
124+
pass
125+
126+
106127
_T = TypeVar("_T", bound=GenAIInvocation)
107128

108129

@@ -158,20 +179,24 @@ def _record_metrics(
158179

159180
def _start(self, invocation: _T) -> _T:
160181
"""Start a GenAI invocation and create a pending span entry."""
161-
span_kind = SpanKind.CLIENT
162182
if isinstance(invocation, LLMInvocation):
163183
span_name = _get_llm_span_name(invocation)
184+
kind = SpanKind.CLIENT
164185
elif isinstance(invocation, EmbeddingInvocation):
165186
span_name = _get_embedding_span_name(invocation)
187+
kind = SpanKind.CLIENT
166188
elif isinstance(invocation, ToolCall):
167189
span_name = _get_tool_call_span_name(invocation)
168-
span_kind = SpanKind.INTERNAL
190+
kind = SpanKind.INTERNAL
191+
elif isinstance(invocation, WorkflowInvocation):
192+
span_name = _get_workflow_span_name(invocation)
193+
kind = SpanKind.INTERNAL
169194
else:
170195
span_name = ""
171-
196+
kind = SpanKind.CLIENT
172197
span = self._tracer.start_span(
173198
name=span_name,
174-
kind=span_kind,
199+
kind=kind,
175200
)
176201
if isinstance(invocation, ToolCall):
177202
_apply_tool_call_attributes(
@@ -203,6 +228,9 @@ def _stop(self, invocation: _T) -> _T:
203228
elif isinstance(invocation, ToolCall):
204229
_finish_tool_call_span(span, invocation, capture_content=True)
205230
self._record_metrics(invocation, span)
231+
elif isinstance(invocation, WorkflowInvocation):
232+
_apply_workflow_finish_attributes(span, invocation)
233+
# TODO: Add workflow metrics when supported
206234
finally:
207235
# Detach context and end span even if finishing fails
208236
otel_context.detach(invocation.context_token)
@@ -234,6 +262,10 @@ def _fail(self, invocation: _T, error: Error) -> _T:
234262
_finish_tool_call_span(span, invocation, capture_content=True)
235263
self._record_metrics(invocation, span, error_type=error_type)
236264
span.set_status(Status(StatusCode.ERROR, error.message))
265+
elif isinstance(invocation, WorkflowInvocation):
266+
_apply_workflow_finish_attributes(span, invocation)
267+
_apply_error_attributes(span, error, error_type)
268+
# TODO: Add workflow metrics when supported
237269
finally:
238270
# Detach context and end span even if finishing fails
239271
otel_context.detach(invocation.context_token)
@@ -347,6 +379,46 @@ def embedding(
347379
raise
348380
self.stop(invocation)
349381

382+
@contextmanager
383+
def workflow(
384+
self, invocation: WorkflowInvocation | None = None
385+
) -> Iterator[WorkflowInvocation]:
386+
"""Context manager for Workflow invocations.
387+
388+
Only set data attributes on the invocation object, do not modify the span or context.
389+
390+
Starts the span on entry. On normal exit, finalizes the invocation and ends the span.
391+
If an exception occurs inside the context, marks the span as error, ends it, and
392+
re-raises the original exception.
393+
"""
394+
if invocation is None:
395+
invocation = WorkflowInvocation()
396+
397+
try:
398+
self.start(invocation)
399+
except Exception: # pylint: disable=broad-except
400+
_logger.warning(
401+
"Failed to start workflow telemetry", exc_info=True
402+
)
403+
404+
try:
405+
yield invocation
406+
except Exception as exc:
407+
try:
408+
self.fail(invocation, Error(message=str(exc), type=type(exc)))
409+
except Exception: # pylint: disable=broad-except
410+
_logger.warning(
411+
"Failed to record workflow failure", exc_info=True
412+
)
413+
_safe_detach(invocation)
414+
raise
415+
416+
try:
417+
self.stop(invocation)
418+
except Exception: # pylint: disable=broad-except
419+
_logger.warning("Failed to stop workflow telemetry", exc_info=True)
420+
_safe_detach(invocation)
421+
350422

351423
def get_telemetry_handler(
352424
tracer_provider: TracerProvider | None = None,

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

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
MessagePart,
4141
OutputMessage,
4242
ToolCall,
43+
WorkflowInvocation,
4344
)
4445
from opentelemetry.util.genai.utils import (
4546
ContentCapturingMode,
@@ -109,7 +110,14 @@ def _get_embedding_span_name(invocation: EmbeddingInvocation) -> str:
109110
return _get_span_name(invocation)
110111

111112

112-
def _get_llm_messages_attributes_for_span(
113+
def _get_workflow_span_name(invocation: WorkflowInvocation) -> str:
114+
"""Get the span name for an Workflow invocation."""
115+
operation_name = invocation.operation_name
116+
name = invocation.name
117+
return f"{operation_name} {name}" if name else operation_name
118+
119+
120+
def _get_messages_attributes_for_span(
113121
input_messages: list[InputMessage],
114122
output_messages: list[OutputMessage],
115123
system_instruction: list[MessagePart] | None = None,
@@ -241,7 +249,7 @@ def _apply_llm_finish_attributes(
241249
attributes.update(_get_llm_request_attributes(invocation))
242250
attributes.update(_get_llm_response_attributes(invocation))
243251
attributes.update(
244-
_get_llm_messages_attributes_for_span(
252+
_get_messages_attributes_for_span(
245253
invocation.input_messages,
246254
invocation.output_messages,
247255
invocation.system_instruction,
@@ -346,6 +354,39 @@ def _get_llm_response_attributes(
346354
return {key: value for key, value in optional_attrs if value is not None}
347355

348356

357+
def _apply_workflow_finish_attributes(
358+
span: Span, invocation: WorkflowInvocation
359+
) -> None:
360+
"""Apply attributes/messages common to finish() paths."""
361+
362+
# Build all attributes by reusing the attribute getter functions
363+
attributes: dict[str, Any] = {}
364+
attributes.update(_get_workflow_common_attributes(invocation))
365+
attributes.update(
366+
_get_messages_attributes_for_span(
367+
invocation.input_messages,
368+
invocation.output_messages,
369+
)
370+
)
371+
attributes.update(invocation.attributes)
372+
373+
# Set all attributes on the span
374+
if attributes:
375+
span.set_attributes(attributes)
376+
377+
378+
def _get_workflow_common_attributes(
379+
invocation: WorkflowInvocation,
380+
) -> dict[str, Any]:
381+
"""Get common Workflow attributes shared by finish() and error() paths.
382+
383+
Returns a dictionary of attributes.
384+
"""
385+
return {
386+
GenAI.GEN_AI_OPERATION_NAME: invocation.operation_name,
387+
}
388+
389+
349390
def _get_embedding_response_attributes(
350391
invocation: EmbeddingInvocation,
351392
) -> dict[str, Any]:
@@ -463,6 +504,8 @@ def _finish_tool_call_span(
463504
"_get_llm_response_attributes",
464505
"_get_llm_span_name",
465506
"_maybe_emit_llm_event",
507+
"_get_workflow_common_attributes",
508+
"_apply_workflow_finish_attributes",
466509
"_apply_embedding_finish_attributes",
467510
"_get_embedding_common_attributes",
468511
"_get_embedding_request_attributes",
@@ -471,4 +514,5 @@ def _finish_tool_call_span(
471514
"_apply_tool_call_attributes",
472515
"_finish_tool_call_span",
473516
"_get_tool_call_span_name",
517+
"_get_workflow_span_name",
474518
]

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,9 @@ class WorkflowInvocation(GenAIInvocation):
285285
default_factory=_new_output_messages
286286
)
287287

288+
def __post_init__(self) -> None:
289+
self.operation_name = "invoke_workflow"
290+
288291

289292
@dataclass
290293
class LLMInvocation(GenAIInvocation):

0 commit comments

Comments
 (0)