mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Tracing for workflows (#480)
* workflow tracing design doc * add tracing implementation for workflow * fix bug caused by double wrapping of sub workflow request * add unit tests for tracing * add documentation for workflow tracing * remove unnecessary file * update aspire command * fix tests * proper serialization of subworkflows and add workflow.definition * add serialization test * fix subworkflow serialization * workflow_id --> id * update workflow sample to address comments * update naming; use costant * use NoOpTracer instead of nullcontext * use span event instead of attribtutes for status * fix typing * add workflow.build span * rename methods for clarity * ensure all source trace contexts are propagated in fan in
This commit is contained in:
committed by
GitHub
Unverified
parent
78125f019a
commit
379e3b9a00
@@ -105,3 +105,11 @@ __all__ = [
|
||||
"intercepts_request",
|
||||
"validate_workflow_graph",
|
||||
]
|
||||
|
||||
|
||||
# Rebuild models to resolve forward references after all imports are complete
|
||||
import contextlib
|
||||
|
||||
with contextlib.suppress(AttributeError, TypeError, ValueError):
|
||||
# Rebuild WorkflowExecutor to resolve Workflow forward reference
|
||||
WorkflowExecutor.model_rebuild()
|
||||
|
||||
@@ -49,14 +49,36 @@ class EdgeRunner(ABC):
|
||||
return self._executors[executor_id].can_handle(message_data)
|
||||
|
||||
async def _execute_on_target(
|
||||
self, target_id: str, source_id: str, message_data: Any, shared_state: SharedState, ctx: RunnerContext
|
||||
self, target_id: str, source_id: str, message: Message, shared_state: SharedState, ctx: RunnerContext
|
||||
) -> None:
|
||||
"""Execute a message on a target executor."""
|
||||
"""Execute a message on a target executor with trace context."""
|
||||
if target_id not in self._executors:
|
||||
raise RuntimeError(f"Target executor {target_id} not found.")
|
||||
|
||||
target_executor = self._executors[target_id]
|
||||
await target_executor.execute(message_data, WorkflowContext(target_id, [source_id], shared_state, ctx))
|
||||
|
||||
# Handle both old single trace context format and new multiple trace contexts format
|
||||
trace_contexts = getattr(message, "trace_contexts", None)
|
||||
source_span_ids = getattr(message, "source_span_ids", None)
|
||||
|
||||
# Backwards compatibility: if old format is used, convert to new format
|
||||
if trace_contexts is None and hasattr(message, "trace_context") and message.trace_context:
|
||||
trace_contexts = [message.trace_context]
|
||||
if source_span_ids is None and hasattr(message, "source_span_id") and message.source_span_id:
|
||||
source_span_ids = [message.source_span_id]
|
||||
|
||||
# Create WorkflowContext with trace contexts from message
|
||||
workflow_context: WorkflowContext[Any] = WorkflowContext(
|
||||
target_id,
|
||||
[source_id],
|
||||
shared_state,
|
||||
ctx,
|
||||
trace_contexts=trace_contexts, # Pass trace contexts to WorkflowContext
|
||||
source_span_ids=source_span_ids, # Pass source span IDs for linking
|
||||
)
|
||||
|
||||
# Execute with trace context in WorkflowContext
|
||||
await target_executor.execute(message.data, workflow_context)
|
||||
|
||||
|
||||
class SingleEdgeRunner(EdgeRunner):
|
||||
@@ -73,9 +95,7 @@ class SingleEdgeRunner(EdgeRunner):
|
||||
|
||||
if self._can_handle(self._edge.target_id, message.data):
|
||||
if self._edge.should_route(message.data):
|
||||
await self._execute_on_target(
|
||||
self._edge.target_id, self._edge.source_id, message.data, shared_state, ctx
|
||||
)
|
||||
await self._execute_on_target(self._edge.target_id, self._edge.source_id, message, shared_state, ctx)
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -108,7 +128,7 @@ class FanOutEdgeRunner(EdgeRunner):
|
||||
edge = self._target_map.get(message.target_id)
|
||||
if edge and self._can_handle(edge.target_id, message.data):
|
||||
if edge.should_route(message.data):
|
||||
await self._execute_on_target(edge.target_id, edge.source_id, message.data, shared_state, ctx)
|
||||
await self._execute_on_target(edge.target_id, edge.source_id, message, shared_state, ctx)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -117,7 +137,7 @@ class FanOutEdgeRunner(EdgeRunner):
|
||||
"""Send the message to the edge."""
|
||||
if self._can_handle(edge.target_id, message.data):
|
||||
if edge.should_route(message.data):
|
||||
await self._execute_on_target(edge.target_id, edge.source_id, message.data, shared_state, ctx)
|
||||
await self._execute_on_target(edge.target_id, edge.source_id, message, shared_state, ctx)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -159,8 +179,20 @@ class FanInEdgeRunner(EdgeRunner):
|
||||
self._buffer.clear()
|
||||
# Send aggregated data to target
|
||||
aggregated_data = [msg.data for msg in messages_to_send]
|
||||
|
||||
# Collect all trace contexts and source span IDs for fan-in linking
|
||||
trace_contexts = [msg.trace_context for msg in messages_to_send if msg.trace_context]
|
||||
source_span_ids = [msg.source_span_id for msg in messages_to_send if msg.source_span_id]
|
||||
|
||||
# Create a new Message object for the aggregated data
|
||||
aggregated_message = Message(
|
||||
data=aggregated_data,
|
||||
source_id=self._edge_group.__class__.__name__,
|
||||
trace_contexts=trace_contexts,
|
||||
source_span_ids=source_span_ids,
|
||||
)
|
||||
await self._execute_on_target(
|
||||
self._edges[0].target_id, self._edge_group.__class__.__name__, aggregated_data, shared_state, ctx
|
||||
self._edges[0].target_id, self._edge_group.__class__.__name__, aggregated_message, shared_state, ctx
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -78,27 +78,44 @@ class Executor(AFBaseModel):
|
||||
Returns:
|
||||
An awaitable that resolves to the result of the execution.
|
||||
"""
|
||||
# Create processing span for tracing (gracefully handles disabled tracing)
|
||||
from ._telemetry import workflow_tracer
|
||||
|
||||
source_trace_contexts = getattr(context, "_trace_contexts", None)
|
||||
source_span_ids = getattr(context, "_source_span_ids", None)
|
||||
|
||||
# Handle case where Message wrapper is passed instead of raw data
|
||||
from ._runner_context import Message
|
||||
|
||||
# Lazy registration for SubWorkflowRequestInfo if we have interceptors
|
||||
if self._request_interceptors and message.__class__.__name__ == "SubWorkflowRequestInfo":
|
||||
# Directly handle SubWorkflowRequestInfo
|
||||
if isinstance(message, Message):
|
||||
message = message.data
|
||||
|
||||
with workflow_tracer.create_processing_span(
|
||||
self.id,
|
||||
self.__class__.__name__,
|
||||
type(message).__name__,
|
||||
source_trace_contexts=source_trace_contexts,
|
||||
source_span_ids=source_span_ids,
|
||||
):
|
||||
# Lazy registration for SubWorkflowRequestInfo if we have interceptors
|
||||
if self._request_interceptors and message.__class__.__name__ == "SubWorkflowRequestInfo":
|
||||
# Directly handle SubWorkflowRequestInfo
|
||||
await context.add_event(ExecutorInvokeEvent(self.id))
|
||||
await self._handle_sub_workflow_request(message, context)
|
||||
await context.add_event(ExecutorCompletedEvent(self.id))
|
||||
return
|
||||
|
||||
handler: Callable[[Any, WorkflowContext[Any]], Any] | None = None
|
||||
for message_type in self._handlers:
|
||||
if is_instance_of(message, message_type):
|
||||
handler = self._handlers[message_type]
|
||||
break
|
||||
|
||||
if handler is None:
|
||||
raise RuntimeError(f"Executor {self.__class__.__name__} cannot handle message of type {type(message)}.")
|
||||
await context.add_event(ExecutorInvokeEvent(self.id))
|
||||
await self._handle_sub_workflow_request(message, context)
|
||||
await handler(message, context)
|
||||
await context.add_event(ExecutorCompletedEvent(self.id))
|
||||
return
|
||||
|
||||
handler: Callable[[Any, WorkflowContext[Any]], Any] | None = None
|
||||
for message_type in self._handlers:
|
||||
if is_instance_of(message, message_type):
|
||||
handler = self._handlers[message_type]
|
||||
break
|
||||
|
||||
if handler is None:
|
||||
raise RuntimeError(f"Executor {self.__class__.__name__} cannot handle message of type {type(message)}.")
|
||||
await context.add_event(ExecutorInvokeEvent(self.id))
|
||||
await handler(message, context)
|
||||
await context.add_event(ExecutorCompletedEvent(self.id))
|
||||
|
||||
def _discover_handlers(self) -> None:
|
||||
"""Discover message handlers and request interceptors in the executor class."""
|
||||
@@ -196,17 +213,13 @@ class Executor(AFBaseModel):
|
||||
|
||||
if correlated_response.is_handled:
|
||||
# Send response back to sub-workflow
|
||||
from ._runner_context import Message
|
||||
|
||||
response_message = Message(
|
||||
source_id=self.id,
|
||||
target_id=request.sub_workflow_id,
|
||||
data=SubWorkflowResponse(
|
||||
await ctx.send_message(
|
||||
SubWorkflowResponse(
|
||||
request_id=request.request_id,
|
||||
data=correlated_response.data,
|
||||
),
|
||||
target_id=request.sub_workflow_id,
|
||||
)
|
||||
await ctx.send_message(response_message)
|
||||
else:
|
||||
# Forward WITH CONTEXT PRESERVED
|
||||
# Update the data if interceptor provided a modified request
|
||||
@@ -214,13 +227,7 @@ class Executor(AFBaseModel):
|
||||
request.data = correlated_response.forward_request
|
||||
|
||||
# Send the inner request to RequestInfoExecutor to create external request
|
||||
from ._runner_context import Message
|
||||
|
||||
forward_message = Message(
|
||||
source_id=self.id,
|
||||
data=request,
|
||||
)
|
||||
await ctx.send_message(forward_message)
|
||||
await ctx.send_message(request)
|
||||
else:
|
||||
# Legacy support: direct return means handled
|
||||
await ctx.send_message(
|
||||
@@ -234,10 +241,7 @@ class Executor(AFBaseModel):
|
||||
|
||||
# No interceptor found - forward inner request to RequestInfoExecutor
|
||||
# This sends the original request to RequestInfoExecutor
|
||||
from ._runner_context import Message
|
||||
|
||||
passthrough_message = Message(source_id=self.id, data=request.data)
|
||||
await ctx.send_message(passthrough_message)
|
||||
await ctx.send_message(request.data)
|
||||
|
||||
def can_handle(self, message: Any) -> bool:
|
||||
"""Check if the executor can handle a given message type.
|
||||
@@ -357,7 +361,7 @@ def handler(
|
||||
return await func(self, message, ctx)
|
||||
|
||||
# Preserve the original function signature for introspection during validation
|
||||
with contextlib.suppress(Exception):
|
||||
with contextlib.suppress(AttributeError, TypeError):
|
||||
wrapper.__signature__ = sig # type: ignore[attr-defined]
|
||||
|
||||
wrapper._handler_spec = { # type: ignore
|
||||
@@ -806,15 +810,19 @@ class WorkflowExecutor(Executor):
|
||||
are intercepted by parent workflows.
|
||||
"""
|
||||
|
||||
def __init__(self, workflow: "Workflow", id: str | None = None):
|
||||
workflow: "Workflow" = Field(description="The workflow to execute as a sub-workflow")
|
||||
|
||||
def __init__(self, workflow: "Workflow", id: str | None = None, **kwargs: Any):
|
||||
"""Initialize the WorkflowExecutor.
|
||||
|
||||
Args:
|
||||
workflow: The workflow to execute as a sub-workflow.
|
||||
id: Optional unique identifier for this executor.
|
||||
**kwargs: Additional keyword arguments passed to the parent constructor.
|
||||
"""
|
||||
super().__init__(id)
|
||||
self._workflow = workflow
|
||||
kwargs.update({"workflow": workflow})
|
||||
super().__init__(id, **kwargs)
|
||||
|
||||
# Track pending external responses by request_id
|
||||
self._pending_responses: dict[str, Any] = {} # request_id -> response_data
|
||||
# Track workflow state for proper resumption - support multiple concurrent requests
|
||||
@@ -846,7 +854,7 @@ class WorkflowExecutor(Executor):
|
||||
|
||||
try:
|
||||
# Run the sub-workflow and collect all events
|
||||
events = [event async for event in self._workflow.run_streaming(input_data)]
|
||||
events = [event async for event in self.workflow.run_streaming(input_data)]
|
||||
|
||||
# Count requests and initialize response tracking
|
||||
request_count = 0
|
||||
@@ -932,7 +940,7 @@ class WorkflowExecutor(Executor):
|
||||
responses_to_send = dict(self._collected_responses)
|
||||
self._collected_responses.clear() # Clear for next batch
|
||||
|
||||
result_events = [event async for event in self._workflow.send_responses_streaming(responses_to_send)]
|
||||
result_events = [event async for event in self.workflow.send_responses_streaming(responses_to_send)]
|
||||
|
||||
# Process the result events
|
||||
new_request_count = 0
|
||||
|
||||
@@ -179,7 +179,16 @@ class Runner:
|
||||
f"from sub-workflow '{sub_request.sub_workflow_id}' "
|
||||
f"to executor '{executor.id}' for interception."
|
||||
)
|
||||
await executor.execute(sub_request, self._ctx) # type: ignore[arg-type]
|
||||
# Create WorkflowContext with trace context from message
|
||||
workflow_ctx: WorkflowContext[Any] = WorkflowContext(
|
||||
executor.id,
|
||||
[message.source_id],
|
||||
self._shared_state,
|
||||
self._ctx,
|
||||
trace_contexts=[message.trace_context] if message.trace_context else None,
|
||||
source_span_ids=[message.source_span_id] if message.source_span_id else None,
|
||||
)
|
||||
await executor.execute(sub_request, workflow_ctx)
|
||||
interceptor_found = True
|
||||
break
|
||||
if interceptor_found:
|
||||
@@ -192,18 +201,20 @@ class Runner:
|
||||
request_info_executor = self._find_request_info_executor()
|
||||
|
||||
if request_info_executor:
|
||||
workflow_ctx: WorkflowContext[None] = WorkflowContext(
|
||||
request_info_workflow_ctx: WorkflowContext[None] = WorkflowContext(
|
||||
request_info_executor.id,
|
||||
["Runner"],
|
||||
[message.source_id],
|
||||
self._shared_state,
|
||||
self._ctx,
|
||||
trace_contexts=[message.trace_context] if message.trace_context else None,
|
||||
source_span_ids=[message.source_span_id] if message.source_span_id else None,
|
||||
)
|
||||
logger.info(
|
||||
f"Sending sub-workflow request of type '{sub_request.data.__class__.__name__}' "
|
||||
f"from sub-workflow '{sub_request.sub_workflow_id}' to RequestInfoExecutor "
|
||||
f"'{request_info_executor.id}'"
|
||||
)
|
||||
await request_info_executor.execute(sub_request, workflow_ctx)
|
||||
await request_info_executor.execute(sub_request, request_info_workflow_ctx)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Sub-workflow request of type '{sub_request.data.__class__.__name__}' "
|
||||
|
||||
@@ -24,6 +24,22 @@ class Message:
|
||||
source_id: str
|
||||
target_id: str | None = None
|
||||
|
||||
# OpenTelemetry trace context fields for message propagation
|
||||
# These are plural to support fan-in scenarios where multiple messages are aggregated
|
||||
trace_contexts: list[dict[str, str]] | None = None # W3C Trace Context headers from multiple sources
|
||||
source_span_ids: list[str] | None = None # Publishing span IDs for linking from multiple sources
|
||||
|
||||
# Backward compatibility properties
|
||||
@property
|
||||
def trace_context(self) -> dict[str, str] | None:
|
||||
"""Get the first trace context for backward compatibility."""
|
||||
return self.trace_contexts[0] if self.trace_contexts else None
|
||||
|
||||
@property
|
||||
def source_span_id(self) -> str | None:
|
||||
"""Get the first source span ID for backward compatibility."""
|
||||
return self.source_span_ids[0] if self.source_span_ids else None
|
||||
|
||||
|
||||
class CheckpointState(TypedDict):
|
||||
messages: dict[str, list[dict[str, Any]]]
|
||||
@@ -268,7 +284,14 @@ class InProcRunnerContext:
|
||||
serializable_messages: dict[str, list[dict[str, Any]]] = {}
|
||||
for source_id, message_list in self._messages.items():
|
||||
serializable_messages[source_id] = [
|
||||
{"data": msg.data, "source_id": msg.source_id, "target_id": msg.target_id} for msg in message_list
|
||||
{
|
||||
"data": msg.data,
|
||||
"source_id": msg.source_id,
|
||||
"target_id": msg.target_id,
|
||||
"trace_contexts": msg.trace_contexts,
|
||||
"source_span_ids": msg.source_span_ids,
|
||||
}
|
||||
for msg in message_list
|
||||
]
|
||||
return {
|
||||
"messages": serializable_messages,
|
||||
@@ -287,6 +310,8 @@ class InProcRunnerContext:
|
||||
data=msg.get("data"),
|
||||
source_id=msg.get("source_id", ""),
|
||||
target_id=msg.get("target_id"),
|
||||
trace_contexts=msg.get("trace_contexts"),
|
||||
source_span_ids=msg.get("source_span_ids"),
|
||||
)
|
||||
for msg in message_list
|
||||
]
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
from agent_framework._pydantic import AFBaseSettings
|
||||
from opentelemetry.trace import Link, NoOpTracer, SpanKind, StatusCode, get_current_span, get_tracer
|
||||
from opentelemetry.trace.span import SpanContext
|
||||
from opentelemetry.util.types import Attributes
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._workflow import Workflow
|
||||
|
||||
|
||||
# Span name constants
|
||||
_WORKFLOW_BUILD_SPAN = "workflow.build"
|
||||
_WORKFLOW_RUN_SPAN = "workflow.run"
|
||||
_EXECUTOR_PROCESS_SPAN = "executor.process"
|
||||
_MESSAGE_SEND_SPAN = "message.send"
|
||||
|
||||
|
||||
class WorkflowDiagnosticSettings(AFBaseSettings):
|
||||
"""Settings for workflow tracing diagnostics."""
|
||||
|
||||
env_prefix: ClassVar[str] = "AGENT_FRAMEWORK_WORKFLOW_"
|
||||
enable_otel_diagnostics: bool = False
|
||||
|
||||
@property
|
||||
def ENABLED(self) -> bool:
|
||||
return self.enable_otel_diagnostics
|
||||
|
||||
|
||||
class WorkflowTracer:
|
||||
"""Central tracing coordinator for workflow system.
|
||||
|
||||
Manages OpenTelemetry span creation and relationships for:
|
||||
- Workflow build spans (workflow.build)
|
||||
- Workflow execution spans (workflow.run)
|
||||
- Executor processing spans (executor.process)
|
||||
- Message sending spans (message.send)
|
||||
|
||||
Implements span linking for causality without unwanted nesting.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.settings = WorkflowDiagnosticSettings()
|
||||
self.tracer = get_tracer("agent_framework") if self.settings.ENABLED else NoOpTracer()
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self.settings.ENABLED
|
||||
|
||||
def create_workflow_run_span(self, workflow: "Workflow") -> Any:
|
||||
"""Create a workflow execution span."""
|
||||
attributes: dict[str, str | int] = {
|
||||
"workflow.id": workflow.id,
|
||||
}
|
||||
|
||||
return self.tracer.start_as_current_span(_WORKFLOW_RUN_SPAN, kind=SpanKind.INTERNAL, attributes=attributes)
|
||||
|
||||
def create_workflow_build_span(self) -> Any:
|
||||
"""Create a workflow build span."""
|
||||
return self.tracer.start_as_current_span(_WORKFLOW_BUILD_SPAN, kind=SpanKind.INTERNAL)
|
||||
|
||||
def create_processing_span(
|
||||
self,
|
||||
executor_id: str,
|
||||
executor_type: str,
|
||||
message_type: str,
|
||||
source_trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
) -> Any:
|
||||
"""Create an executor processing span with optional links to source spans.
|
||||
|
||||
Processing spans are created as children of the current workflow span and
|
||||
linked (not nested) to the source publishing spans for causality tracking.
|
||||
This supports multiple links for fan-in scenarios.
|
||||
"""
|
||||
# Create links to source spans for causality without nesting
|
||||
links = []
|
||||
if source_trace_contexts and source_span_ids:
|
||||
# Create links for all source spans (supporting fan-in with multiple sources)
|
||||
for trace_context, span_id in zip(source_trace_contexts, source_span_ids, strict=False):
|
||||
try:
|
||||
# Extract trace and span IDs from the trace context
|
||||
# This is a simplified approach - in production you'd want more robust parsing
|
||||
traceparent = trace_context.get("traceparent", "")
|
||||
if traceparent:
|
||||
# traceparent format: "00-{trace_id}-{parent_span_id}-{trace_flags}"
|
||||
parts = traceparent.split("-")
|
||||
if len(parts) >= 3:
|
||||
trace_id_hex = parts[1]
|
||||
# Use the source_span_id that was saved from the publishing span
|
||||
|
||||
# Create span context for linking
|
||||
span_context = SpanContext(
|
||||
trace_id=int(trace_id_hex, 16),
|
||||
span_id=int(span_id, 16),
|
||||
is_remote=True,
|
||||
)
|
||||
links.append(Link(span_context))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
# If linking fails, continue without link (graceful degradation)
|
||||
pass
|
||||
|
||||
return self.tracer.start_as_current_span(
|
||||
_EXECUTOR_PROCESS_SPAN,
|
||||
kind=SpanKind.INTERNAL,
|
||||
attributes={
|
||||
"executor.id": executor_id,
|
||||
"executor.type": executor_type,
|
||||
"message.type": message_type,
|
||||
},
|
||||
links=links,
|
||||
)
|
||||
|
||||
def create_sending_span(self, message_type: str, target_executor_id: str | None = None) -> Any:
|
||||
"""Create a message send span.
|
||||
|
||||
Sending spans are created as children of the current processing span
|
||||
to track message emission for distributed tracing.
|
||||
"""
|
||||
attributes: dict[str, str] = {
|
||||
"message.type": message_type,
|
||||
}
|
||||
if target_executor_id is not None:
|
||||
attributes["message.destination_executor_id"] = target_executor_id
|
||||
|
||||
return self.tracer.start_as_current_span(
|
||||
_MESSAGE_SEND_SPAN,
|
||||
kind=SpanKind.PRODUCER,
|
||||
attributes=attributes,
|
||||
)
|
||||
|
||||
def add_workflow_event(self, event_name: str, attributes: Attributes | None = None) -> None:
|
||||
"""Add an event to the current workflow span.
|
||||
|
||||
Args:
|
||||
event_name: Name of the event (e.g., "workflow.started", "workflow.completed")
|
||||
attributes: Optional attributes to attach to the event
|
||||
"""
|
||||
span = get_current_span()
|
||||
if span and span.is_recording():
|
||||
span.add_event(event_name, attributes)
|
||||
|
||||
def add_workflow_error_event(self, error: Exception, attributes: Attributes | None = None) -> None:
|
||||
"""Add an error event to the current workflow span.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred
|
||||
attributes: Optional additional attributes to attach to the event
|
||||
"""
|
||||
span = get_current_span()
|
||||
if span and span.is_recording():
|
||||
event_attributes: dict[str, str | bool | int | float] = {
|
||||
"error.message": str(error),
|
||||
"error.type": type(error).__name__,
|
||||
}
|
||||
if attributes:
|
||||
# Safely merge attributes, ensuring type compatibility
|
||||
for key, value in attributes.items():
|
||||
if isinstance(value, (str, bool, int, float)):
|
||||
event_attributes[key] = value
|
||||
span.add_event("workflow.error", event_attributes)
|
||||
span.set_status(StatusCode.ERROR, str(error))
|
||||
|
||||
def set_workflow_build_span_attributes(self, workflow: "Workflow") -> None:
|
||||
"""Set workflow attributes on the current span.
|
||||
|
||||
Args:
|
||||
workflow: The workflow instance to extract attributes from
|
||||
"""
|
||||
span = get_current_span()
|
||||
if span and span.is_recording():
|
||||
span.set_attributes({
|
||||
"workflow.id": workflow.id,
|
||||
"workflow.definition": workflow.model_dump_json(),
|
||||
})
|
||||
|
||||
def add_build_event(self, event_name: str, attributes: Attributes | None = None) -> None:
|
||||
"""Add an event to the current workflow build span.
|
||||
|
||||
Args:
|
||||
event_name: Name of the build event (e.g., "build.started", "build.validation_completed")
|
||||
attributes: Optional attributes to attach to the event
|
||||
"""
|
||||
span = get_current_span()
|
||||
if span and span.is_recording():
|
||||
span.add_event(event_name, attributes)
|
||||
|
||||
def add_build_error_event(self, error: Exception, attributes: Attributes | None = None) -> None:
|
||||
"""Add an error event to the current workflow build span.
|
||||
|
||||
Args:
|
||||
error: The exception that occurred during build
|
||||
attributes: Optional additional attributes to attach to the event
|
||||
"""
|
||||
span = get_current_span()
|
||||
if span and span.is_recording():
|
||||
event_attributes: dict[str, str | bool | int | float] = {
|
||||
"build.error.message": str(error),
|
||||
"build.error.type": type(error).__name__,
|
||||
}
|
||||
if attributes:
|
||||
# Safely merge attributes, ensuring type compatibility
|
||||
for key, value in attributes.items():
|
||||
if isinstance(value, (str, bool, int, float)):
|
||||
event_attributes[key] = value
|
||||
span.add_event("build.error", event_attributes)
|
||||
span.set_status(StatusCode.ERROR, str(error))
|
||||
|
||||
|
||||
# Global workflow tracer instance
|
||||
workflow_tracer = WorkflowTracer()
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable, Callable, Sequence
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Sequence
|
||||
from typing import Any
|
||||
|
||||
from agent_framework._pydantic import AFBaseModel
|
||||
@@ -88,7 +88,7 @@ class Workflow(AFBaseModel):
|
||||
max_iterations: int = Field(
|
||||
default=DEFAULT_MAX_ITERATIONS, description="Maximum number of iterations the workflow will run"
|
||||
)
|
||||
workflow_id: str = Field(
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()), description="Unique identifier for this workflow instance"
|
||||
)
|
||||
|
||||
@@ -114,14 +114,14 @@ class Workflow(AFBaseModel):
|
||||
# Convert start_executor to string ID if it's an Executor instance
|
||||
start_executor_id = start_executor.id if isinstance(start_executor, Executor) else start_executor
|
||||
|
||||
workflow_id = str(uuid.uuid4())
|
||||
id = str(uuid.uuid4())
|
||||
|
||||
kwargs.update({
|
||||
"edge_groups": edge_groups,
|
||||
"executors": executors,
|
||||
"start_executor_id": start_executor_id,
|
||||
"max_iterations": max_iterations,
|
||||
"workflow_id": workflow_id,
|
||||
"id": id,
|
||||
})
|
||||
|
||||
super().__init__(**kwargs)
|
||||
@@ -135,9 +135,39 @@ class Workflow(AFBaseModel):
|
||||
self._shared_state,
|
||||
runner_context,
|
||||
max_iterations=max_iterations,
|
||||
workflow_id=workflow_id,
|
||||
workflow_id=id,
|
||||
)
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
"""Custom serialization that properly handles WorkflowExecutor nested workflows."""
|
||||
data = super().model_dump(**kwargs)
|
||||
|
||||
# Ensure WorkflowExecutor instances have their workflow field serialized
|
||||
if "executors" in data:
|
||||
executors_data = data["executors"]
|
||||
for executor_id, executor_data in executors_data.items():
|
||||
# Check if this is a WorkflowExecutor that might be missing its workflow field
|
||||
if (
|
||||
isinstance(executor_data, dict)
|
||||
and executor_data.get("type") == "WorkflowExecutor"
|
||||
and "workflow" not in executor_data
|
||||
):
|
||||
# Get the original executor object and serialize its workflow
|
||||
original_executor = self.executors.get(executor_id)
|
||||
if original_executor and hasattr(original_executor, "workflow"):
|
||||
from ._executor import WorkflowExecutor
|
||||
|
||||
if isinstance(original_executor, WorkflowExecutor):
|
||||
executor_data["workflow"] = original_executor.workflow.model_dump(**kwargs)
|
||||
|
||||
return data
|
||||
|
||||
def model_dump_json(self, **kwargs: Any) -> str:
|
||||
"""Custom JSON serialization that properly handles WorkflowExecutor nested workflows."""
|
||||
import json
|
||||
|
||||
return json.dumps(self.model_dump(**kwargs))
|
||||
|
||||
def get_start_executor(self) -> Executor:
|
||||
"""Get the starting executor of the workflow.
|
||||
|
||||
@@ -150,6 +180,48 @@ class Workflow(AFBaseModel):
|
||||
"""Get the list of executors in the workflow."""
|
||||
return list(self.executors.values())
|
||||
|
||||
async def _run_workflow_with_tracing(
|
||||
self, initial_executor_fn: Callable[[], Awaitable[None]] | None = None, reset_context: bool = True
|
||||
) -> AsyncIterable[WorkflowEvent]:
|
||||
"""Private method to run workflow with proper tracing.
|
||||
|
||||
All workflow entry points create a NEW workflow span. It is the responsibility
|
||||
of external callers to maintain context across different workflow runs.
|
||||
|
||||
Args:
|
||||
initial_executor_fn: Optional function to execute initial executor
|
||||
reset_context: Whether to reset the context for a new run
|
||||
|
||||
Yields:
|
||||
WorkflowEvent: The events generated during the workflow execution.
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from ._telemetry import workflow_tracer
|
||||
|
||||
# Create workflow span that encompasses the entire execution
|
||||
with workflow_tracer.create_workflow_run_span(self):
|
||||
try:
|
||||
# Add workflow started event
|
||||
workflow_tracer.add_workflow_event("workflow.started")
|
||||
|
||||
# Reset context for a new run if supported
|
||||
if reset_context:
|
||||
self._runner.context.reset_for_new_run(self._shared_state)
|
||||
|
||||
# Execute initial setup if provided
|
||||
if initial_executor_fn:
|
||||
await initial_executor_fn()
|
||||
|
||||
# All executor executions happen within workflow span
|
||||
async for event in self._runner.run_until_convergence():
|
||||
yield event
|
||||
|
||||
# Success
|
||||
workflow_tracer.add_workflow_event("workflow.completed")
|
||||
except Exception as e:
|
||||
workflow_tracer.add_workflow_error_event(e)
|
||||
raise
|
||||
|
||||
async def run_streaming(self, message: Any) -> AsyncIterable[WorkflowEvent]:
|
||||
"""Run the workflow with a starting message and stream events.
|
||||
|
||||
@@ -159,22 +231,22 @@ class Workflow(AFBaseModel):
|
||||
Yields:
|
||||
WorkflowEvent: The events generated during the workflow execution.
|
||||
"""
|
||||
# Reset context for a new run if supported
|
||||
self._runner.context.reset_for_new_run(self._shared_state)
|
||||
|
||||
executor = self.get_start_executor()
|
||||
async def initial_execution() -> None:
|
||||
executor = self.get_start_executor()
|
||||
await executor.execute(
|
||||
message,
|
||||
WorkflowContext(
|
||||
executor.id,
|
||||
[self.__class__.__name__],
|
||||
self._shared_state,
|
||||
self._runner.context,
|
||||
trace_contexts=None, # No parent trace context for workflow start
|
||||
source_span_ids=None, # No source span for workflow start
|
||||
),
|
||||
)
|
||||
|
||||
await executor.execute(
|
||||
message,
|
||||
WorkflowContext(
|
||||
executor.id,
|
||||
[self.__class__.__name__],
|
||||
self._shared_state,
|
||||
self._runner.context,
|
||||
),
|
||||
)
|
||||
|
||||
async for event in self._runner.run_until_convergence():
|
||||
async for event in self._run_workflow_with_tracing(initial_executor_fn=initial_execution, reset_context=True):
|
||||
yield event
|
||||
|
||||
async def run_streaming_from_checkpoint(
|
||||
@@ -199,41 +271,48 @@ class Workflow(AFBaseModel):
|
||||
ValueError: If neither checkpoint_storage is provided nor checkpointing is enabled.
|
||||
RuntimeError: If checkpoint restoration fails.
|
||||
"""
|
||||
has_checkpointing = self._runner.context.has_checkpointing()
|
||||
|
||||
if not has_checkpointing and not checkpoint_storage:
|
||||
raise ValueError(
|
||||
"Cannot restore from checkpoint: either provide checkpoint_storage parameter "
|
||||
"or build workflow with WorkflowBuilder.with_checkpointing(checkpoint_storage)."
|
||||
)
|
||||
async def checkpoint_restoration() -> None:
|
||||
has_checkpointing = self._runner.context.has_checkpointing()
|
||||
|
||||
if has_checkpointing:
|
||||
# restore via Runner so shared state and iteration are synchronized
|
||||
restored = await self._runner.restore_from_checkpoint(checkpoint_id)
|
||||
else:
|
||||
if checkpoint_storage is None:
|
||||
raise ValueError("checkpoint_storage cannot be None.")
|
||||
restored = await self._restore_from_external_checkpoint(checkpoint_id, checkpoint_storage)
|
||||
if not has_checkpointing and not checkpoint_storage:
|
||||
raise ValueError(
|
||||
"Cannot restore from checkpoint: either provide checkpoint_storage parameter "
|
||||
"or build workflow with WorkflowBuilder.with_checkpointing(checkpoint_storage)."
|
||||
)
|
||||
|
||||
if not restored:
|
||||
raise RuntimeError(f"Failed to restore from checkpoint: {checkpoint_id}")
|
||||
if has_checkpointing:
|
||||
# restore via Runner so shared state and iteration are synchronized
|
||||
restored = await self._runner.restore_from_checkpoint(checkpoint_id)
|
||||
else:
|
||||
if checkpoint_storage is None:
|
||||
raise ValueError("checkpoint_storage cannot be None.")
|
||||
restored = await self._restore_from_external_checkpoint(checkpoint_id, checkpoint_storage)
|
||||
|
||||
if responses:
|
||||
request_info_executor = self._find_request_info_executor()
|
||||
if request_info_executor:
|
||||
for request_id, response_data in responses.items():
|
||||
await request_info_executor.handle_response(
|
||||
response_data,
|
||||
request_id,
|
||||
WorkflowContext(
|
||||
request_info_executor.id,
|
||||
[self.__class__.__name__],
|
||||
self._shared_state,
|
||||
self._runner.context,
|
||||
),
|
||||
)
|
||||
if not restored:
|
||||
raise RuntimeError(f"Failed to restore from checkpoint: {checkpoint_id}")
|
||||
|
||||
async for event in self._runner.run_until_convergence():
|
||||
if responses:
|
||||
request_info_executor = self._find_request_info_executor()
|
||||
if request_info_executor:
|
||||
for request_id, response_data in responses.items():
|
||||
await request_info_executor.handle_response(
|
||||
response_data,
|
||||
request_id,
|
||||
WorkflowContext(
|
||||
request_info_executor.id,
|
||||
[self.__class__.__name__],
|
||||
self._shared_state,
|
||||
self._runner.context,
|
||||
trace_contexts=None, # No parent trace context for new workflow span
|
||||
source_span_ids=None, # No source span for response handling
|
||||
),
|
||||
)
|
||||
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=checkpoint_restoration,
|
||||
reset_context=False, # Don't reset context when resuming from checkpoint
|
||||
):
|
||||
yield event
|
||||
|
||||
async def send_responses_streaming(self, responses: dict[str, Any]) -> AsyncIterable[WorkflowEvent]:
|
||||
@@ -246,26 +325,35 @@ class Workflow(AFBaseModel):
|
||||
Yields:
|
||||
WorkflowEvent: The events generated during the workflow execution after sending the responses.
|
||||
"""
|
||||
request_info_executor = self._find_request_info_executor()
|
||||
if not request_info_executor:
|
||||
raise ValueError("No RequestInfoExecutor found in workflow.")
|
||||
|
||||
async def _handle_response(response: Any, request_id: str) -> None:
|
||||
"""Handle the response from the RequestInfoExecutor."""
|
||||
await request_info_executor.handle_response(
|
||||
response,
|
||||
request_id,
|
||||
WorkflowContext(
|
||||
request_info_executor.id,
|
||||
[self.__class__.__name__],
|
||||
self._shared_state,
|
||||
self._runner.context,
|
||||
),
|
||||
)
|
||||
async def send_responses() -> None:
|
||||
request_info_executor = self._find_request_info_executor()
|
||||
if not request_info_executor:
|
||||
raise ValueError("No RequestInfoExecutor found in workflow.")
|
||||
|
||||
await asyncio.gather(*[_handle_response(response, request_id) for request_id, response in responses.items()])
|
||||
async def _handle_response(response: Any, request_id: str) -> None:
|
||||
"""Handle the response from the RequestInfoExecutor."""
|
||||
await request_info_executor.handle_response(
|
||||
response,
|
||||
request_id,
|
||||
WorkflowContext(
|
||||
request_info_executor.id,
|
||||
[self.__class__.__name__],
|
||||
self._shared_state,
|
||||
self._runner.context,
|
||||
trace_contexts=None, # No parent trace context for new workflow span
|
||||
source_span_ids=None, # No source span for response handling
|
||||
),
|
||||
)
|
||||
|
||||
async for event in self._runner.run_until_convergence():
|
||||
await asyncio.gather(*[
|
||||
_handle_response(response, request_id) for request_id, response in responses.items()
|
||||
])
|
||||
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=send_responses,
|
||||
reset_context=False, # Don't reset context when sending responses
|
||||
):
|
||||
yield event
|
||||
|
||||
async def run(self, message: Any) -> WorkflowRunResult:
|
||||
@@ -437,7 +525,13 @@ class Workflow(AFBaseModel):
|
||||
from ._runner_context import Message as _Msg
|
||||
|
||||
await self._runner.context.send_message(
|
||||
_Msg(data=msg_data.get("data"), source_id=source_id, target_id=target_id)
|
||||
_Msg(
|
||||
data=msg_data.get("data"),
|
||||
source_id=source_id,
|
||||
target_id=target_id,
|
||||
trace_contexts=msg_data.get("trace_contexts"),
|
||||
source_span_ids=msg_data.get("source_span_ids"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -652,14 +746,42 @@ class WorkflowBuilder:
|
||||
WorkflowValidationError: If workflow validation fails (includes EdgeDuplicationError,
|
||||
TypeCompatibilityError, and GraphConnectivityError subclasses).
|
||||
"""
|
||||
if not self._start_executor:
|
||||
raise ValueError("Starting executor must be set using set_start_executor before building the workflow.")
|
||||
# Import here to avoid circular imports
|
||||
from ._telemetry import workflow_tracer
|
||||
|
||||
validate_workflow_graph(self._edge_groups, self._executors, self._start_executor)
|
||||
# Create workflow build span that includes validation and workflow creation
|
||||
with workflow_tracer.create_workflow_build_span():
|
||||
try:
|
||||
# Add workflow build started event
|
||||
workflow_tracer.add_build_event("build.started")
|
||||
|
||||
context = InProcRunnerContext(self._checkpoint_storage)
|
||||
if not self._start_executor:
|
||||
raise ValueError(
|
||||
"Starting executor must be set using set_start_executor before building the workflow."
|
||||
)
|
||||
|
||||
return Workflow(self._edge_groups, self._executors, self._start_executor, context, self._max_iterations)
|
||||
# Perform validation before creating the workflow
|
||||
validate_workflow_graph(self._edge_groups, self._executors, self._start_executor)
|
||||
|
||||
# Add validation completed event
|
||||
workflow_tracer.add_build_event("build.validation_completed")
|
||||
|
||||
# endregion
|
||||
context = InProcRunnerContext(self._checkpoint_storage)
|
||||
|
||||
# Create workflow instance after validation
|
||||
workflow = Workflow(
|
||||
self._edge_groups, self._executors, self._start_executor, context, self._max_iterations
|
||||
)
|
||||
|
||||
# Set workflow attributes on the span
|
||||
workflow_tracer.set_workflow_build_span_attributes(workflow)
|
||||
|
||||
# Add workflow build completed event
|
||||
workflow_tracer.add_build_event("build.completed")
|
||||
|
||||
return workflow
|
||||
|
||||
except Exception as e:
|
||||
# The method already includes sufficient error info (error.message, error.type)
|
||||
workflow_tracer.add_build_error_event(e)
|
||||
raise
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from opentelemetry.propagate import inject
|
||||
|
||||
from ._events import WorkflowEvent
|
||||
from ._runner_context import Message, RunnerContext
|
||||
from ._shared_state import SharedState
|
||||
from ._telemetry import workflow_tracer
|
||||
|
||||
T_Out = TypeVar("T_Out")
|
||||
|
||||
@@ -22,6 +25,8 @@ class WorkflowContext(Generic[T_Out]):
|
||||
source_executor_ids: list[str],
|
||||
shared_state: SharedState,
|
||||
runner_context: RunnerContext,
|
||||
trace_contexts: list[dict[str, str]] | None = None,
|
||||
source_span_ids: list[str] | None = None,
|
||||
):
|
||||
"""Initialize the executor context with the given workflow context.
|
||||
|
||||
@@ -32,12 +37,18 @@ class WorkflowContext(Generic[T_Out]):
|
||||
messages to the same executor.
|
||||
shared_state: The shared state for the workflow.
|
||||
runner_context: The runner context that provides methods to send messages and events.
|
||||
trace_contexts: Optional trace contexts from multiple sources for OpenTelemetry propagation.
|
||||
source_span_ids: Optional source span IDs from multiple sources for linking (not for nesting).
|
||||
"""
|
||||
self._executor_id = executor_id
|
||||
self._source_executor_ids = source_executor_ids
|
||||
self._runner_context = runner_context
|
||||
self._shared_state = shared_state
|
||||
|
||||
# Store trace contexts and source span IDs for linking (supporting multiple sources)
|
||||
self._trace_contexts = trace_contexts or []
|
||||
self._source_span_ids = source_span_ids or []
|
||||
|
||||
if not self._source_executor_ids:
|
||||
raise ValueError("source_executor_ids cannot be empty. At least one source executor ID is required.")
|
||||
|
||||
@@ -49,13 +60,20 @@ class WorkflowContext(Generic[T_Out]):
|
||||
target_id: The ID of the target executor to send the message to.
|
||||
If None, the message will be sent to all target executors.
|
||||
"""
|
||||
await self._runner_context.send_message(
|
||||
Message(
|
||||
data=message,
|
||||
source_id=self._executor_id,
|
||||
target_id=target_id,
|
||||
)
|
||||
)
|
||||
# Create publishing span (inherits current trace context automatically)
|
||||
with workflow_tracer.create_sending_span(type(message).__name__, target_id) as span:
|
||||
# Create Message wrapper
|
||||
msg = Message(data=message, source_id=self._executor_id, target_id=target_id)
|
||||
|
||||
# Inject current trace context if tracing enabled
|
||||
if workflow_tracer.enabled and span and span.is_recording():
|
||||
trace_context: dict[str, str] = {}
|
||||
inject(trace_context) # Inject current trace context for message propagation
|
||||
|
||||
msg.trace_contexts = [trace_context]
|
||||
msg.source_span_ids = [format(span.get_span_context().span_id, "016x")]
|
||||
|
||||
await self._runner_context.send_message(msg)
|
||||
|
||||
async def add_event(self, event: WorkflowEvent) -> None:
|
||||
"""Add an event to the workflow context."""
|
||||
|
||||
@@ -15,6 +15,9 @@ from agent_framework_workflow._edge import (
|
||||
SwitchCaseEdgeGroupCase,
|
||||
SwitchCaseEdgeGroupDefault,
|
||||
)
|
||||
from agent_framework_workflow._executor import (
|
||||
WorkflowExecutor,
|
||||
)
|
||||
|
||||
|
||||
class SampleExecutor(Executor):
|
||||
@@ -398,6 +401,110 @@ class TestSerializationWorkflowClasses:
|
||||
"JSON SwitchCaseEdgeGroup edges should not have condition_name"
|
||||
)
|
||||
|
||||
def test_nested_workflow_executor_serialization(self) -> None:
|
||||
"""Test complete serialization of deeply nested WorkflowExecutors (subworkflows within subworkflows).
|
||||
|
||||
This test verifies that nested WorkflowExecutor objects are fully serialized with their
|
||||
complete workflow structures, including deeply nested workflows and all their executors.
|
||||
"""
|
||||
# Create innermost workflow
|
||||
inner_executor = SampleExecutor(id="inner-exec")
|
||||
inner_workflow = WorkflowBuilder().set_start_executor(inner_executor).set_max_iterations(10).build()
|
||||
|
||||
# Create middle workflow with WorkflowExecutor
|
||||
inner_workflow_executor = WorkflowExecutor(workflow=inner_workflow, id="inner-workflow-exec")
|
||||
middle_executor = SampleExecutor(id="middle-exec")
|
||||
middle_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(middle_executor)
|
||||
.add_edge(middle_executor, inner_workflow_executor)
|
||||
.set_max_iterations(20)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Create outer workflow with nested WorkflowExecutor
|
||||
middle_workflow_executor = WorkflowExecutor(workflow=middle_workflow, id="middle-workflow-exec")
|
||||
outer_executor = SampleExecutor(id="outer-exec")
|
||||
outer_workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(outer_executor)
|
||||
.add_edge(outer_executor, middle_workflow_executor)
|
||||
.set_max_iterations(30)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Test serialization of the nested structure
|
||||
data = outer_workflow.model_dump()
|
||||
|
||||
# Verify outer structure
|
||||
assert data["start_executor_id"] == "outer-exec"
|
||||
assert data["max_iterations"] == 30
|
||||
assert "outer-exec" in data["executors"]
|
||||
assert "middle-workflow-exec" in data["executors"]
|
||||
|
||||
# Verify middle WorkflowExecutor is present with full nested workflow serialization
|
||||
middle_exec_data = data["executors"]["middle-workflow-exec"]
|
||||
assert middle_exec_data["type"] == "WorkflowExecutor"
|
||||
assert middle_exec_data["id"] == "middle-workflow-exec"
|
||||
|
||||
# Verify the nested workflow is fully serialized
|
||||
assert "workflow" in middle_exec_data, "WorkflowExecutor should include nested workflow in serialization"
|
||||
middle_workflow_data = middle_exec_data["workflow"]
|
||||
assert "start_executor_id" in middle_workflow_data
|
||||
assert "executors" in middle_workflow_data
|
||||
assert "max_iterations" in middle_workflow_data
|
||||
assert middle_workflow_data["start_executor_id"] == "middle-exec"
|
||||
assert middle_workflow_data["max_iterations"] == 20
|
||||
|
||||
# Verify the deeply nested executors are present
|
||||
assert "middle-exec" in middle_workflow_data["executors"]
|
||||
assert "inner-workflow-exec" in middle_workflow_data["executors"]
|
||||
|
||||
# Verify the innermost WorkflowExecutor is also fully serialized
|
||||
inner_workflow_exec_data = middle_workflow_data["executors"]["inner-workflow-exec"]
|
||||
assert inner_workflow_exec_data["type"] == "WorkflowExecutor"
|
||||
assert "workflow" in inner_workflow_exec_data, "Deeply nested WorkflowExecutor should also include its workflow"
|
||||
innermost_workflow_data = inner_workflow_exec_data["workflow"]
|
||||
assert "start_executor_id" in innermost_workflow_data
|
||||
assert "executors" in innermost_workflow_data
|
||||
assert "max_iterations" in innermost_workflow_data
|
||||
assert innermost_workflow_data["start_executor_id"] == "inner-exec"
|
||||
assert innermost_workflow_data["max_iterations"] == 10
|
||||
assert "inner-exec" in innermost_workflow_data["executors"]
|
||||
|
||||
# Test JSON serialization preserves the complete nested structure
|
||||
json_str = outer_workflow.model_dump_json()
|
||||
parsed = json.loads(json_str)
|
||||
|
||||
# Verify the complete structure is preserved in JSON
|
||||
middle_exec_json = parsed["executors"]["middle-workflow-exec"]
|
||||
assert middle_exec_json["type"] == "WorkflowExecutor"
|
||||
assert middle_exec_json["id"] == "middle-workflow-exec"
|
||||
|
||||
# Verify nested workflow is present in JSON
|
||||
assert "workflow" in middle_exec_json, "JSON serialization should include nested workflow"
|
||||
middle_workflow_json = middle_exec_json["workflow"]
|
||||
assert middle_workflow_json["start_executor_id"] == "middle-exec"
|
||||
assert middle_workflow_json["max_iterations"] == 20
|
||||
assert "middle-exec" in middle_workflow_json["executors"]
|
||||
assert "inner-workflow-exec" in middle_workflow_json["executors"]
|
||||
|
||||
# Verify deeply nested structure in JSON
|
||||
inner_workflow_exec_json = middle_workflow_json["executors"]["inner-workflow-exec"]
|
||||
assert inner_workflow_exec_json["type"] == "WorkflowExecutor"
|
||||
assert "workflow" in inner_workflow_exec_json, "Deeply nested WorkflowExecutor should be in JSON"
|
||||
innermost_workflow_json = inner_workflow_exec_json["workflow"]
|
||||
assert innermost_workflow_json["start_executor_id"] == "inner-exec"
|
||||
assert innermost_workflow_json["max_iterations"] == 10
|
||||
assert "inner-exec" in innermost_workflow_json["executors"]
|
||||
|
||||
# Test that WorkflowExecutor also serializes correctly when accessed directly
|
||||
direct_middle_data = middle_workflow_executor.model_dump()
|
||||
assert "workflow" in direct_middle_data
|
||||
assert direct_middle_data["type"] == "WorkflowExecutor"
|
||||
assert "executors" in direct_middle_data["workflow"]
|
||||
assert "inner-workflow-exec" in direct_middle_data["workflow"]["executors"]
|
||||
|
||||
def test_switch_case_edge_group_serialization_with_named_condition(self) -> None:
|
||||
"""Test that SwitchCaseEdgeGroup with named condition function serializes condition_name correctly."""
|
||||
|
||||
@@ -440,7 +547,7 @@ class TestSerializationWorkflowClasses:
|
||||
assert "executors" in data
|
||||
assert "start_executor_id" in data
|
||||
assert "max_iterations" in data
|
||||
assert "workflow_id" in data
|
||||
assert "id" in data
|
||||
|
||||
assert data["start_executor_id"] == "executor1"
|
||||
assert "executor1" in data["executors"]
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import os
|
||||
from collections.abc import Generator
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||
|
||||
from agent_framework_workflow import WorkflowBuilder
|
||||
from agent_framework_workflow._executor import Executor, handler
|
||||
from agent_framework_workflow._runner_context import InProcRunnerContext, Message
|
||||
from agent_framework_workflow._shared_state import SharedState
|
||||
from agent_framework_workflow._telemetry import WorkflowTracer, workflow_tracer
|
||||
from agent_framework_workflow._workflow import Workflow
|
||||
from agent_framework_workflow._workflow_context import WorkflowContext
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tracing_enabled() -> Generator[None, None, None]:
|
||||
"""Enable tracing for tests."""
|
||||
original_value = os.environ.get("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS")
|
||||
os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS"] = "true"
|
||||
|
||||
# Force reload the settings to pick up the environment variable
|
||||
from agent_framework_workflow._telemetry import WorkflowDiagnosticSettings
|
||||
|
||||
workflow_tracer.settings = WorkflowDiagnosticSettings()
|
||||
|
||||
yield
|
||||
|
||||
# Restore original value
|
||||
if original_value is None:
|
||||
os.environ.pop("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS", None)
|
||||
else:
|
||||
os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS"] = original_value
|
||||
|
||||
# Reload settings again
|
||||
workflow_tracer.settings = WorkflowDiagnosticSettings()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def span_exporter(tracing_enabled: Any) -> Generator[InMemorySpanExporter, None, None]:
|
||||
"""Set up OpenTelemetry test infrastructure."""
|
||||
# Use the built-in InMemorySpanExporter for better compatibility
|
||||
exporter = InMemorySpanExporter()
|
||||
tracer_provider = TracerProvider()
|
||||
tracer_provider.add_span_processor(SimpleSpanProcessor(exporter))
|
||||
|
||||
# Store original tracer
|
||||
original_tracer = workflow_tracer.tracer
|
||||
|
||||
# Set up our test tracer
|
||||
workflow_tracer.tracer = tracer_provider.get_tracer("agent_framework")
|
||||
|
||||
yield exporter
|
||||
|
||||
# Clean up
|
||||
exporter.clear()
|
||||
workflow_tracer.tracer = original_tracer
|
||||
|
||||
|
||||
class MockExecutor(Executor):
|
||||
"""Mock executor for testing."""
|
||||
|
||||
def __init__(self, id: str = "mock_executor") -> None:
|
||||
super().__init__(id=id)
|
||||
# Use private field to avoid Pydantic validation
|
||||
self._processed_messages: list[str] = []
|
||||
|
||||
@handler
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
"""Handle string messages."""
|
||||
self._processed_messages.append(message)
|
||||
await ctx.send_message(f"processed: {message}")
|
||||
|
||||
@property
|
||||
def processed_messages(self) -> list[str]:
|
||||
"""Access to processed messages for testing."""
|
||||
return self._processed_messages
|
||||
|
||||
|
||||
class SecondExecutor(Executor):
|
||||
"""Second executor for testing message chains."""
|
||||
|
||||
def __init__(self, id: str = "second_executor") -> None:
|
||||
super().__init__(id=id)
|
||||
# Use private field to avoid Pydantic validation
|
||||
self._processed_messages: list[str] = []
|
||||
|
||||
@handler
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext[None]) -> None:
|
||||
"""Handle string messages."""
|
||||
self._processed_messages.append(message)
|
||||
|
||||
@property
|
||||
def processed_messages(self) -> list[str]:
|
||||
"""Access to processed messages for testing."""
|
||||
return self._processed_messages
|
||||
|
||||
|
||||
class ProcessingExecutor(Executor):
|
||||
"""Executor that processes and forwards messages with a custom prefix."""
|
||||
|
||||
def __init__(self, id: str, prefix: str = "processed") -> None:
|
||||
super().__init__(id=id)
|
||||
# Use private field to avoid Pydantic validation
|
||||
self._processed_messages: list[str] = []
|
||||
self._prefix = prefix
|
||||
|
||||
@handler
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
"""Handle string messages and send them forward with prefix."""
|
||||
self._processed_messages.append(message)
|
||||
await ctx.send_message(f"{self._prefix}: {message}")
|
||||
|
||||
@property
|
||||
def processed_messages(self) -> list[str]:
|
||||
return self._processed_messages
|
||||
|
||||
|
||||
class FanInAggregator(Executor):
|
||||
"""Fan-in aggregator that expects a list of inputs."""
|
||||
|
||||
def __init__(self, id: str = "aggregator") -> None:
|
||||
super().__init__(id=id)
|
||||
# Use private field to avoid Pydantic validation
|
||||
self._processed_messages: list[Any] = []
|
||||
|
||||
@handler
|
||||
async def handle_aggregated_data(self, messages: list[str], ctx: WorkflowContext[None]) -> None:
|
||||
# Process aggregated messages from fan-in
|
||||
aggregated = f"aggregated: {', '.join(messages)}"
|
||||
self._processed_messages.append(aggregated)
|
||||
|
||||
@property
|
||||
def processed_messages(self) -> list[Any]:
|
||||
"""Access to processed messages for testing."""
|
||||
return self._processed_messages
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_tracer_configuration() -> None:
|
||||
"""Test that workflow tracer can be enabled and disabled."""
|
||||
# Test disabled by default
|
||||
tracer = WorkflowTracer()
|
||||
assert not tracer.enabled
|
||||
|
||||
# Test enabled with environment variable
|
||||
original_value = os.environ.get("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS")
|
||||
os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS"] = "true"
|
||||
|
||||
# Force reload the settings to pick up the environment variable
|
||||
from agent_framework_workflow._telemetry import WorkflowDiagnosticSettings
|
||||
|
||||
tracer.settings = WorkflowDiagnosticSettings()
|
||||
|
||||
assert tracer.enabled
|
||||
|
||||
# Restore original value
|
||||
if original_value is None:
|
||||
os.environ.pop("AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS", None)
|
||||
else:
|
||||
os.environ["AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS"] = original_value
|
||||
|
||||
# Reload settings again
|
||||
tracer.settings = WorkflowDiagnosticSettings()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_span_creation_and_attributes(tracing_enabled: Any, span_exporter: InMemorySpanExporter) -> None:
|
||||
"""Test creation and attributes of all span types (workflow, processing, sending)."""
|
||||
# Create a mock workflow object
|
||||
mock_workflow = cast(
|
||||
Workflow,
|
||||
type(
|
||||
"MockWorkflow",
|
||||
(),
|
||||
{
|
||||
"id": "test-workflow-123",
|
||||
"max_iterations": 100,
|
||||
"model_dump_json": lambda self: '{"id": "test-workflow-123", "type": "mock"}',
|
||||
},
|
||||
)(),
|
||||
)
|
||||
|
||||
# Test all span types in nested context
|
||||
with workflow_tracer.create_workflow_run_span(mock_workflow) as workflow_span:
|
||||
workflow_tracer.add_workflow_event("workflow.started")
|
||||
|
||||
with (
|
||||
workflow_tracer.create_processing_span("executor-456", "TestExecutor", "TestMessage") as processing_span,
|
||||
workflow_tracer.create_sending_span("ResponseMessage", "target-789") as sending_span,
|
||||
):
|
||||
# Verify all spans are recording
|
||||
assert workflow_span is not None and workflow_span.is_recording()
|
||||
assert processing_span is not None and processing_span.is_recording()
|
||||
assert sending_span is not None and sending_span.is_recording()
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 3
|
||||
|
||||
# Check workflow span
|
||||
workflow_span = next(s for s in spans if s.name == "workflow.run")
|
||||
assert workflow_span.kind == trace.SpanKind.INTERNAL
|
||||
assert workflow_span.attributes is not None
|
||||
assert workflow_span.attributes.get("workflow.id") == "test-workflow-123"
|
||||
assert workflow_span.events is not None
|
||||
event_names = [event.name for event in workflow_span.events]
|
||||
assert "workflow.started" in event_names
|
||||
|
||||
# Check processing span
|
||||
processing_span = next(s for s in spans if s.name == "executor.process")
|
||||
assert processing_span.kind == trace.SpanKind.INTERNAL
|
||||
assert processing_span.attributes is not None
|
||||
assert processing_span.attributes.get("executor.id") == "executor-456"
|
||||
assert processing_span.attributes.get("executor.type") == "TestExecutor"
|
||||
assert processing_span.attributes.get("message.type") == "TestMessage"
|
||||
|
||||
# Check sending span
|
||||
sending_span = next(s for s in spans if s.name == "message.send")
|
||||
assert sending_span.kind == trace.SpanKind.PRODUCER
|
||||
assert sending_span.attributes is not None
|
||||
assert sending_span.attributes.get("message.type") == "ResponseMessage"
|
||||
assert sending_span.attributes.get("message.destination_executor_id") == "target-789"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_context_handling(tracing_enabled: Any, span_exporter: InMemorySpanExporter) -> None:
|
||||
"""Test trace context propagation and handling in messages and executors."""
|
||||
shared_state = SharedState()
|
||||
ctx = InProcRunnerContext()
|
||||
executor = MockExecutor("test-executor")
|
||||
|
||||
# Test trace context propagation in messages
|
||||
workflow_ctx: WorkflowContext[str] = WorkflowContext(
|
||||
"test-executor",
|
||||
["source"],
|
||||
shared_state,
|
||||
ctx,
|
||||
trace_contexts=[{"traceparent": "00-12345678901234567890123456789012-1234567890123456-01"}],
|
||||
source_span_ids=["1234567890123456"],
|
||||
)
|
||||
|
||||
# Send a message (this should create a sending span and propagate trace context)
|
||||
await workflow_ctx.send_message("test message")
|
||||
|
||||
# Check that message was created with trace context
|
||||
messages = await ctx.drain_messages()
|
||||
assert len(messages) == 1
|
||||
message_list = list(messages.values())[0]
|
||||
assert len(message_list) == 1
|
||||
message = message_list[0]
|
||||
assert message.trace_context is not None
|
||||
assert message.source_span_id is not None
|
||||
|
||||
# Test executor trace context handling
|
||||
await executor.execute("test message", workflow_ctx)
|
||||
|
||||
# Check that spans were created with proper attributes
|
||||
spans = span_exporter.get_finished_spans()
|
||||
processing_spans = [s for s in spans if s.name == "executor.process"]
|
||||
sending_spans = [s for s in spans if s.name == "message.send"]
|
||||
|
||||
assert len(processing_spans) >= 1
|
||||
assert len(sending_spans) >= 1
|
||||
|
||||
# Verify processing span attributes
|
||||
processing_span = processing_spans[0]
|
||||
assert processing_span.attributes is not None
|
||||
assert processing_span.attributes.get("executor.id") == "test-executor"
|
||||
assert processing_span.attributes.get("executor.type") == "MockExecutor"
|
||||
assert processing_span.attributes.get("message.type") == "str"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trace_context_disabled_when_tracing_disabled() -> None:
|
||||
"""Test that no trace context is added when tracing is disabled."""
|
||||
# Tracing should be disabled by default
|
||||
shared_state = SharedState()
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
workflow_ctx: WorkflowContext[str] = WorkflowContext(
|
||||
"test-executor",
|
||||
["source"],
|
||||
shared_state,
|
||||
ctx,
|
||||
)
|
||||
|
||||
# Send a message
|
||||
await workflow_ctx.send_message("test message")
|
||||
|
||||
# Check that message was created without trace context
|
||||
messages = await ctx.drain_messages()
|
||||
message = list(messages.values())[0][0]
|
||||
|
||||
# When tracing is disabled, trace_context should be None
|
||||
assert message.trace_context is None
|
||||
assert message.source_span_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_to_end_workflow_tracing(tracing_enabled: Any, span_exporter: InMemorySpanExporter) -> None:
|
||||
"""Test end-to-end tracing including workflow build, execution, and span linking with fan-in edges."""
|
||||
# Create executors for fan-in scenario
|
||||
executor1 = MockExecutor("executor1")
|
||||
executor2 = ProcessingExecutor("executor2", "second")
|
||||
executor3 = ProcessingExecutor("executor3", "third")
|
||||
aggregator = FanInAggregator("aggregator")
|
||||
|
||||
# Create workflow with fan-in: executor1 -> [executor2, executor3] -> aggregator
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.set_start_executor(executor1)
|
||||
.add_fan_out_edges(executor1, [executor2, executor3])
|
||||
.add_fan_in_edges([executor2, executor3], aggregator)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Verify build span was created
|
||||
build_spans = [s for s in span_exporter.get_finished_spans() if s.name == "workflow.build"]
|
||||
assert len(build_spans) == 1
|
||||
|
||||
build_span = build_spans[0]
|
||||
assert build_span.attributes is not None
|
||||
assert build_span.attributes.get("workflow.id") == workflow.id
|
||||
assert build_span.attributes.get("workflow.definition") is not None
|
||||
definition = build_span.attributes.get("workflow.definition")
|
||||
assert definition == workflow.model_dump_json()
|
||||
|
||||
# Check build events
|
||||
assert build_span.events is not None
|
||||
build_event_names = [event.name for event in build_span.events]
|
||||
assert "build.started" in build_event_names
|
||||
assert "build.validation_completed" in build_event_names
|
||||
assert "build.completed" in build_event_names
|
||||
|
||||
# Clear spans to separate build from run tracing
|
||||
span_exporter.clear()
|
||||
|
||||
# Run workflow (this should create run spans)
|
||||
events = []
|
||||
async for event in workflow.run_streaming("test input"):
|
||||
events.append(event)
|
||||
|
||||
# Verify workflow executed correctly
|
||||
assert len(executor1.processed_messages) == 1
|
||||
assert executor1.processed_messages[0] == "test input"
|
||||
assert len(executor2.processed_messages) == 1
|
||||
assert executor2.processed_messages[0] == "processed: test input"
|
||||
assert len(executor3.processed_messages) == 1
|
||||
assert executor3.processed_messages[0] == "processed: test input" # executor3 receives from executor1 via fan-out
|
||||
assert len(aggregator.processed_messages) == 1
|
||||
# The aggregator should receive both processed messages from executor2 and executor3
|
||||
aggregated_msg = aggregator.processed_messages[0]
|
||||
assert "second: processed: test input" in aggregated_msg
|
||||
assert "third: processed: test input" in aggregated_msg
|
||||
|
||||
# Check run spans (build spans should not be present after clear)
|
||||
spans = span_exporter.get_finished_spans()
|
||||
|
||||
# Should have workflow span, processing spans, and sending spans
|
||||
workflow_spans = [s for s in spans if s.name == "workflow.run"]
|
||||
processing_spans = [s for s in spans if s.name == "executor.process"]
|
||||
sending_spans = [s for s in spans if s.name == "message.send"]
|
||||
build_spans_after_run = [s for s in spans if s.name == "workflow.build"]
|
||||
|
||||
assert len(workflow_spans) == 1
|
||||
assert len(processing_spans) >= 4 # executor1, executor2, executor3, aggregator
|
||||
assert len(sending_spans) >= 3 # Messages sent between executors
|
||||
assert len(build_spans_after_run) == 0 # No build spans should be present after clear
|
||||
|
||||
# Verify workflow span events
|
||||
workflow_span = workflow_spans[0]
|
||||
assert workflow_span.events is not None
|
||||
event_names = [event.name for event in workflow_span.events]
|
||||
assert "workflow.started" in event_names
|
||||
assert "workflow.completed" in event_names
|
||||
|
||||
# Test fan-in span linking: find the aggregator's processing span
|
||||
aggregator_spans = [s for s in processing_spans if s.attributes and s.attributes.get("executor.id") == "aggregator"]
|
||||
assert len(aggregator_spans) == 1
|
||||
|
||||
aggregator_span = aggregator_spans[0]
|
||||
# The aggregator span should have links to the source spans (from executor2 and executor3)
|
||||
# This tests that FanInEdgeRunner properly handles multiple trace contexts and span IDs
|
||||
assert aggregator_span.links is not None
|
||||
|
||||
# Find the sending spans from executor2 and executor3 by checking parent relationships
|
||||
executor2_processing_spans = [
|
||||
s for s in processing_spans if s.attributes and s.attributes.get("executor.id") == "executor2"
|
||||
]
|
||||
executor3_processing_spans = [
|
||||
s for s in processing_spans if s.attributes and s.attributes.get("executor.id") == "executor3"
|
||||
]
|
||||
|
||||
# Get span IDs from processing spans
|
||||
executor2_processing_span_ids = {format(s.context.span_id, "016x") for s in executor2_processing_spans if s.context}
|
||||
executor3_processing_span_ids = {format(s.context.span_id, "016x") for s in executor3_processing_spans if s.context}
|
||||
|
||||
executor2_sending_spans = [
|
||||
s for s in sending_spans if s.parent and format(s.parent.span_id, "016x") in executor2_processing_span_ids
|
||||
]
|
||||
executor3_sending_spans = [
|
||||
s for s in sending_spans if s.parent and format(s.parent.span_id, "016x") in executor3_processing_span_ids
|
||||
]
|
||||
|
||||
# Verify that we have sending spans from both executors
|
||||
assert len(executor2_sending_spans) >= 1, "Should have at least one sending span from executor2"
|
||||
assert len(executor3_sending_spans) >= 1, "Should have at least one sending span from executor3"
|
||||
|
||||
# Verify that the aggregator span links point to the correct source spans
|
||||
linked_span_ids = {link.context.span_id for link in aggregator_span.links}
|
||||
|
||||
# Should have links from both executor2 and executor3's sending spans
|
||||
executor2_span_ids = {s.context.span_id for s in executor2_sending_spans if s.context}
|
||||
executor3_span_ids = {s.context.span_id for s in executor3_sending_spans if s.context}
|
||||
|
||||
# At least one span from each executor should be linked
|
||||
assert bool(linked_span_ids & executor2_span_ids), "Aggregator should link to executor2's sending span"
|
||||
assert bool(linked_span_ids & executor3_span_ids), "Aggregator should link to executor3's sending span"
|
||||
|
||||
# Should have at least 2 links (one from each source executor)
|
||||
assert len(aggregator_span.links) >= 2, f"Expected at least 2 links, got {len(aggregator_span.links)}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_error_handling_in_tracing(tracing_enabled: Any, span_exporter: InMemorySpanExporter) -> None:
|
||||
"""Test that workflow errors are properly recorded in traces."""
|
||||
|
||||
class FailingExecutor(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="failing_executor")
|
||||
|
||||
@handler
|
||||
async def handle_message(self, message: str, ctx: WorkflowContext[None]) -> None:
|
||||
raise ValueError("Test error")
|
||||
|
||||
failing_executor = FailingExecutor()
|
||||
workflow = WorkflowBuilder().set_start_executor(failing_executor).build()
|
||||
|
||||
# Run workflow and expect error
|
||||
with pytest.raises(ValueError, match="Test error"):
|
||||
async for _ in workflow.run_streaming("test input"):
|
||||
pass
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
|
||||
# Find workflow span
|
||||
workflow_spans = [s for s in spans if s.name == "workflow.run"]
|
||||
assert len(workflow_spans) == 1
|
||||
|
||||
workflow_span = workflow_spans[0]
|
||||
|
||||
# Verify error event and status are recorded
|
||||
assert workflow_span.events is not None
|
||||
event_names = [event.name for event in workflow_span.events]
|
||||
assert "workflow.started" in event_names
|
||||
assert "workflow.error" in event_names
|
||||
assert workflow_span.status.status_code.name == "ERROR"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_trace_context_serialization() -> None:
|
||||
"""Test that message trace context is properly serialized/deserialized."""
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
# Create message with trace context
|
||||
message = Message(
|
||||
data="test",
|
||||
source_id="source",
|
||||
target_id="target",
|
||||
trace_contexts=[{"traceparent": "00-trace-span-01"}],
|
||||
source_span_ids=["span123"],
|
||||
)
|
||||
|
||||
await ctx.send_message(message)
|
||||
|
||||
# Get checkpoint state (which serializes messages)
|
||||
state = await ctx.get_checkpoint_state()
|
||||
|
||||
# Check serialized message includes trace context
|
||||
serialized_msg = state["messages"]["source"][0]
|
||||
assert serialized_msg["trace_contexts"] == [{"traceparent": "00-trace-span-01"}]
|
||||
assert serialized_msg["source_span_ids"] == ["span123"]
|
||||
|
||||
# Test deserialization
|
||||
await ctx.set_checkpoint_state(state)
|
||||
restored_messages = await ctx.drain_messages()
|
||||
|
||||
restored_msg = list(restored_messages.values())[0][0]
|
||||
assert restored_msg.trace_context == {"traceparent": "00-trace-span-01"} # Test backward compatibility
|
||||
assert restored_msg.source_span_id == "span123" # Test backward compatibility
|
||||
assert restored_msg.trace_contexts == [{"traceparent": "00-trace-span-01"}] # Test new format
|
||||
assert restored_msg.source_span_ids == ["span123"] # Test new format
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_build_error_tracing(tracing_enabled: Any, span_exporter: InMemorySpanExporter) -> None:
|
||||
"""Test that build errors are properly recorded in build spans."""
|
||||
|
||||
# Test validation error by not setting start executor
|
||||
builder = WorkflowBuilder()
|
||||
|
||||
with pytest.raises(ValueError, match="Starting executor must be set"):
|
||||
builder.build()
|
||||
|
||||
spans = span_exporter.get_finished_spans()
|
||||
assert len(spans) == 1
|
||||
|
||||
build_span = spans[0]
|
||||
assert build_span.name == "workflow.build"
|
||||
|
||||
# Verify error status and events
|
||||
assert build_span.status.status_code.name == "ERROR"
|
||||
assert build_span.events is not None
|
||||
|
||||
event_names = [event.name for event in build_span.events]
|
||||
assert "build.started" in event_names
|
||||
assert "build.error" in event_names
|
||||
|
||||
# Check error event attributes
|
||||
error_events = [event for event in build_span.events if event.name == "build.error"]
|
||||
assert len(error_events) == 1
|
||||
|
||||
error_event = error_events[0]
|
||||
assert error_event.attributes is not None
|
||||
assert "Starting executor must be set" in str(error_event.attributes.get("build.error.message"))
|
||||
assert error_event.attributes.get("build.error.type") == "ValueError"
|
||||
@@ -288,7 +288,7 @@ async def test_fan_in():
|
||||
def simple_executor() -> Executor:
|
||||
class SimpleExecutor(Executor):
|
||||
@handler
|
||||
async def handle_message(self, message: Message, context: WorkflowContext[None]) -> None:
|
||||
async def handle_message(self, message: str, context: WorkflowContext[None]) -> None:
|
||||
pass
|
||||
|
||||
return SimpleExecutor(id="test_executor")
|
||||
|
||||
@@ -2,3 +2,4 @@ CONNECTION_STRING="..."
|
||||
OTLP_ENDPOINT="http://localhost:4317/"
|
||||
AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS=true
|
||||
AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE=true
|
||||
AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS=true
|
||||
|
||||
@@ -4,9 +4,12 @@ This sample project shows how a Python application can be configured to send Age
|
||||
|
||||
In this sample, we provide options to send telemetry to [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/app-insights-overview), [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/overview?tabs=bash), and console output.
|
||||
|
||||
> **Quick Start**: For local development without Azure setup, you can use the [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) which runs locally via Docker and provides an excellent telemetry viewing experience for OpenTelemetry data.
|
||||
|
||||
> Note that it is also possible to use other Application Performance Management (APM) vendors. An example is [Prometheus](https://prometheus.io/docs/introduction/overview/). Please refer to this [link](https://opentelemetry.io/docs/languages/python/exporters/) to learn more about exporters.
|
||||
|
||||
For more information, please refer to the following resources:
|
||||
|
||||
1. [Azure Monitor OpenTelemetry Exporter](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/monitor/azure-monitor-opentelemetry-exporter)
|
||||
2. [Aspire Dashboard for Python Apps](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone-for-python?tabs=flask%2Cwindows)
|
||||
3. [Python Logging](https://docs.python.org/3/library/logging.html)
|
||||
@@ -19,13 +22,18 @@ The Agent Framework Python SDK is designed to efficiently generate comprehensive
|
||||
## Configuration
|
||||
|
||||
### Required resources
|
||||
|
||||
2. OpenAI or [Azure OpenAI](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/create-resource?pivots=web-portal)
|
||||
|
||||
### Optional resources
|
||||
|
||||
1. [Application Insights](https://learn.microsoft.com/en-us/azure/azure-monitor/app/create-workspace-resource)
|
||||
2. [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone-for-python?tabs=flask%2Cwindows#start-the-aspire-dashboard)
|
||||
|
||||
### Dependencies
|
||||
|
||||
You will also need to install the following dependencies to your virtual environment to run this sample:
|
||||
|
||||
```bash
|
||||
# For Azure ApplicationInsights/AzureMonitor
|
||||
uv pip install azure-monitor-opentelemetry azure-monitor-opentelemetry-exporter
|
||||
@@ -39,16 +47,18 @@ uv pip install opentelemetry-exporter-otlp-proto-grpc
|
||||
2. Create a `.env` file if one doesn't already exist in this folder. Please refer to the [example file](./.env.example).
|
||||
> Note that `CONNECTION_STRING` and `SAMPLE_OTLP_ENDPOINT` are optional. If you don't configure them, everything will get outputted to the console.
|
||||
> Set `AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS=true` to enable basic telemetry and `AGENT_FRAMEWORK_GENAI_ENABLE_OTEL_DIAGNOSTICS_SENSITIVE=true` to include sensitive information like prompts and responses.
|
||||
> Set `AGENT_FRAMEWORK_WORKFLOW_ENABLE_OTEL_DIAGNOSTICS=true` to enable workflow telemetry for the workflow samples.
|
||||
> Sensitive information should only be enabled in a development or test environment. It is not recommended to enable this in production environments as it may expose sensitive data.
|
||||
3. Activate your python virtual environment, and then run `python scenarios.py` or `python interactive.py`.
|
||||
3. Activate your python virtual environment, and then run `python scenarios.py`, `python interactive.py`, `python agent.py`, or `python workflow.py`.
|
||||
|
||||
> This will output the Operation/Trace ID, which can be used later for filtering.
|
||||
|
||||
### Scenarios
|
||||
|
||||
This sample includes two different applications demonstrating Agent Framework telemetry:
|
||||
This sample includes multiple applications demonstrating Agent Framework telemetry:
|
||||
|
||||
#### scenarios.py
|
||||
|
||||
Organized into specific scenarios where the framework will generate useful telemetry data:
|
||||
|
||||
- `chat_client`: This is when a chat client is invoked directly (i.e. not streaming) with a weather tool function. **Information about the call to the underlying model and tool usage will be recorded**.
|
||||
@@ -58,8 +68,24 @@ Organized into specific scenarios where the framework will generate useful telem
|
||||
By default, running `python scenarios.py` will run all three scenarios. To run individual scenarios, use the `--scenario` command line argument. For example, `python scenarios.py --scenario chat_client`. For more information, please run `python scenarios.py -h`.
|
||||
|
||||
#### interactive.py
|
||||
|
||||
An interactive chat application that demonstrates telemetry collection in a conversational context. This sample includes the same `get_weather` tool function and allows for multi-turn conversations. Run `python interactive.py` and start chatting. Type 'exit' to quit the application. This sample only logs at the `WARNING` level, so you will not see as much telemetry data as in the `scenarios.py` sample.
|
||||
|
||||
#### agent.py
|
||||
|
||||
A sample demonstrating Agent Framework telemetry collection for agent-based workflows. This shows how telemetry is captured when using the Agent Framework's agent abstraction layer, including agent initialization, message processing, and tool execution within an agent context.
|
||||
|
||||
By default, running `python agent.py` will run all agent scenarios. To run individual scenarios, use the `--scenario` command line argument. For example, `python agent.py --scenario basic`. For more information, please run `python agent.py -h`.
|
||||
|
||||
#### workflow.py
|
||||
|
||||
A sample demonstrating workflow telemetry collection for the Agent Framework's workflow execution engine. This includes two scenarios:
|
||||
|
||||
- `sequential`: A simple sequential workflow that processes text through two connected executors (uppercase conversion followed by text reversal). **Information about workflow execution, executor processing, and message passing between executors will be recorded**.
|
||||
- `sub_workflow`: A more complex scenario demonstrating sub-workflow patterns with a parent workflow orchestrating multiple text processing tasks via sub-workflows. **Information about parent workflow execution, sub-workflow invocation, and cross-workflow communication will be recorded**.
|
||||
|
||||
By default, running `python workflow.py` will run all workflow scenarios. To run individual scenarios, use the `--scenario` command line argument. For example, `python workflow.py --scenario sequential`. For more information, please run `python workflow.py -h`.
|
||||
|
||||
## Application Insights/Azure Monitor
|
||||
|
||||
### Logs and traces
|
||||
@@ -100,15 +126,52 @@ dependencies
|
||||
|
||||
## Aspire Dashboard
|
||||
|
||||
The [Aspire Dashboard](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/standalone) is a local telemetry viewing tool that provides an excellent experience for viewing OpenTelemetry data without requiring Azure setup.
|
||||
|
||||
### Setting up Aspire Dashboard with Docker
|
||||
|
||||
The easiest way to run the Aspire Dashboard locally is using Docker:
|
||||
|
||||
```bash
|
||||
# Pull and run the Aspire Dashboard container
|
||||
docker run --rm -it -d \
|
||||
-p 18888:18888 \
|
||||
-p 4317:18889 \
|
||||
--name aspire-dashboard \
|
||||
mcr.microsoft.com/dotnet/aspire-dashboard:latest
|
||||
```
|
||||
|
||||
This will start the dashboard with:
|
||||
|
||||
- **Web UI**: Available at <http://localhost:18888>
|
||||
- **OTLP endpoint**: Available at `http://localhost:4317` for your applications to send telemetry data
|
||||
|
||||
### Configuring your application
|
||||
|
||||
Make sure your `.env` file includes the OTLP endpoint:
|
||||
|
||||
```bash
|
||||
OTLP_ENDPOINT=http://localhost:4317
|
||||
```
|
||||
|
||||
Or set it as an environment variable when running your samples:
|
||||
|
||||
```bash
|
||||
OTLP_ENDPOINT=http://localhost:4317 python scenarios.py
|
||||
```
|
||||
|
||||
### Viewing telemetry data
|
||||
|
||||
> Make sure you have the dashboard running to receive telemetry data.
|
||||
|
||||
Once the the sample finishes running, navigate to http://localhost:18888 in a web browser to see the telemetry data. Follow the instructions [here](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/explore) to authenticate to the dashboard and start exploring!
|
||||
Once your sample finishes running, navigate to <http://localhost:18888> in a web browser to see the telemetry data. Follow the [Aspire Dashboard exploration guide](https://learn.microsoft.com/en-us/dotnet/aspire/fundamentals/dashboard/explore) to authenticate to the dashboard and start exploring your traces, logs, and metrics!
|
||||
|
||||
## Console output
|
||||
|
||||
You won't have to deploy an Application Insights resource or install Docker to run Aspire Dashboard if you choose to inspect telemetry data in a console. However, it is difficult to navigate through all the spans and logs produced, so **this method is only recommended when you are just getting started**.
|
||||
|
||||
We recommend you to get started with the `chat_client` scenario as this generates the least amount of telemetry data. Below is similar to what you will see when you run `python scenarios.py --scenario chat_client`:
|
||||
|
||||
```Json
|
||||
{
|
||||
"name": "chat.completions gpt-4o",
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# type: ignore
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.workflow import (
|
||||
Executor,
|
||||
WorkflowBuilder,
|
||||
WorkflowCompletedEvent,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
)
|
||||
from azure.monitor.opentelemetry import configure_azure_monitor
|
||||
from opentelemetry import trace
|
||||
from opentelemetry._logs import set_logger_provider
|
||||
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.metrics import set_meter_provider
|
||||
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
||||
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, ConsoleLogExporter
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import ConsoleMetricExporter, PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.metrics.view import DropAggregation, View
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter
|
||||
from opentelemetry.semconv.attributes import service_attributes
|
||||
from opentelemetry.trace import SpanKind, set_tracer_provider
|
||||
from opentelemetry.trace.span import format_trace_id
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
"""Telemetry sample demonstrating OpenTelemetry integration with Agent Framework workflows.
|
||||
|
||||
This sample runs a simple sequential workflow with telemetry collection,
|
||||
showing telemetry collection for workflow execution, executor processing,
|
||||
and message publishing between executors.
|
||||
"""
|
||||
|
||||
|
||||
class TelemetrySampleSettings(BaseSettings):
|
||||
"""Settings for the telemetry sample application.
|
||||
|
||||
Optional settings are:
|
||||
- connection_string: str - The connection string for the Application Insights resource.
|
||||
This value can be found in the Overview section when examining
|
||||
your resource from the Azure portal.
|
||||
(Env var CONNECTION_STRING)
|
||||
- otlp_endpoint: str - The OTLP endpoint to send telemetry data to.
|
||||
Depending on the exporter used, you may find this value in different places.
|
||||
(Env var OTLP_ENDPOINT)
|
||||
|
||||
If no connection string or OTLP endpoint is provided, the telemetry data will be
|
||||
exported to the console.
|
||||
"""
|
||||
|
||||
connection_string: str | None = None
|
||||
otlp_endpoint: str | None = None
|
||||
|
||||
|
||||
# Load settings
|
||||
settings = TelemetrySampleSettings()
|
||||
|
||||
# Create a resource to represent the service/sample
|
||||
resource = Resource.create({service_attributes.SERVICE_NAME: "WorkflowTelemetryExample"})
|
||||
|
||||
if settings.connection_string:
|
||||
configure_azure_monitor(
|
||||
connection_string=settings.connection_string,
|
||||
enable_live_metrics=True,
|
||||
logger_name="agent_framework",
|
||||
)
|
||||
|
||||
|
||||
def set_up_logging():
|
||||
class LogFilter(logging.Filter):
|
||||
"""A filter to not process records from several subpackages."""
|
||||
|
||||
# These are the namespaces that we want to exclude from logging for the purposes of this demo.
|
||||
namespaces_to_exclude: list[str] = [
|
||||
"httpx",
|
||||
"openai",
|
||||
]
|
||||
|
||||
def filter(self, record):
|
||||
return not any([record.name.startswith(namespace) for namespace in self.namespaces_to_exclude])
|
||||
|
||||
exporters = []
|
||||
if settings.otlp_endpoint:
|
||||
exporters.append(OTLPLogExporter(endpoint=settings.otlp_endpoint))
|
||||
if not exporters:
|
||||
exporters.append(ConsoleLogExporter())
|
||||
|
||||
# Create and set a global logger provider for the application.
|
||||
logger_provider = LoggerProvider(resource=resource)
|
||||
# Log processors are initialized with an exporter which is responsible
|
||||
# for sending the telemetry data to a particular backend.
|
||||
for log_exporter in exporters:
|
||||
logger_provider.add_log_record_processor(BatchLogRecordProcessor(log_exporter))
|
||||
# Sets the global default logger provider
|
||||
set_logger_provider(logger_provider)
|
||||
|
||||
# Create a logging handler to write logging records, in OTLP format, to the exporter.
|
||||
handler = LoggingHandler()
|
||||
handler.addFilter(LogFilter())
|
||||
# Attach the handler to the root logger. `getLogger()` with no arguments returns the root logger.
|
||||
# Events from all child loggers will be processed by this handler.
|
||||
logger = logging.getLogger()
|
||||
logger.addHandler(handler)
|
||||
# Set the logging level to NOTSET to allow all records to be processed by the handler.
|
||||
logger.setLevel(logging.NOTSET)
|
||||
|
||||
|
||||
def set_up_tracing():
|
||||
exporters = []
|
||||
if settings.otlp_endpoint:
|
||||
exporters.append(OTLPSpanExporter(endpoint=settings.otlp_endpoint))
|
||||
if not exporters:
|
||||
exporters.append(ConsoleSpanExporter())
|
||||
|
||||
# Initialize a trace provider for the application. This is a factory for creating tracers.
|
||||
tracer_provider = TracerProvider(resource=resource)
|
||||
# Span processors are initialized with an exporter which is responsible
|
||||
# for sending the telemetry data to a particular backend.
|
||||
for exporter in exporters:
|
||||
tracer_provider.add_span_processor(BatchSpanProcessor(exporter))
|
||||
# Sets the global default tracer provider
|
||||
set_tracer_provider(tracer_provider)
|
||||
|
||||
|
||||
def set_up_metrics():
|
||||
exporters = []
|
||||
if settings.otlp_endpoint:
|
||||
exporters.append(OTLPMetricExporter(endpoint=settings.otlp_endpoint))
|
||||
if not exporters:
|
||||
exporters.append(ConsoleMetricExporter())
|
||||
|
||||
# Initialize a metric provider for the application. This is a factory for creating meters.
|
||||
metric_readers = [
|
||||
PeriodicExportingMetricReader(metric_exporter, export_interval_millis=5000) for metric_exporter in exporters
|
||||
]
|
||||
meter_provider = MeterProvider(
|
||||
metric_readers=metric_readers,
|
||||
resource=resource,
|
||||
views=[
|
||||
# Dropping all instrument names except for those starting with "agent_framework"
|
||||
View(instrument_name="*", aggregation=DropAggregation()),
|
||||
View(instrument_name="agent_framework*"),
|
||||
],
|
||||
)
|
||||
# Sets the global default meter provider
|
||||
set_meter_provider(meter_provider)
|
||||
|
||||
|
||||
# Executors for sequential workflow
|
||||
class UpperCaseExecutor(Executor):
|
||||
"""An executor that converts text to uppercase."""
|
||||
|
||||
@handler
|
||||
async def to_upper_case(self, text: str, ctx: WorkflowContext[str]) -> None:
|
||||
"""Execute the task by converting the input string to uppercase."""
|
||||
print(f"UpperCaseExecutor: Processing '{text}'")
|
||||
result = text.upper()
|
||||
print(f"UpperCaseExecutor: Result '{result}'")
|
||||
|
||||
# Send the result to the next executor in the workflow.
|
||||
await ctx.send_message(result)
|
||||
|
||||
|
||||
class ReverseTextExecutor(Executor):
|
||||
"""An executor that reverses text."""
|
||||
|
||||
@handler
|
||||
async def reverse_text(self, text: str, ctx: WorkflowContext[Any]) -> None:
|
||||
"""Execute the task by reversing the input string."""
|
||||
print(f"ReverseTextExecutor: Processing '{text}'")
|
||||
result = text[::-1]
|
||||
print(f"ReverseTextExecutor: Result '{result}'")
|
||||
|
||||
# Send the result with a workflow completion event.
|
||||
await ctx.add_event(WorkflowCompletedEvent(result))
|
||||
|
||||
|
||||
async def run_sequential_workflow() -> None:
|
||||
"""Run a simple sequential workflow demonstrating telemetry collection.
|
||||
|
||||
This workflow processes a string through two executors in sequence:
|
||||
1. UpperCaseExecutor converts the input to uppercase
|
||||
2. ReverseTextExecutor reverses the string and completes the workflow
|
||||
|
||||
Telemetry data collected includes:
|
||||
- Overall workflow execution spans
|
||||
- Individual executor processing spans
|
||||
- Message publishing between executors
|
||||
- Workflow completion events
|
||||
"""
|
||||
|
||||
tracer = trace.get_tracer(__name__)
|
||||
with tracer.start_as_current_span("Scenario: Sequential Workflow", kind=SpanKind.CLIENT) as current_span:
|
||||
print("Running scenario: Sequential Workflow")
|
||||
try:
|
||||
# Step 1: Create the executors.
|
||||
upper_case_executor = UpperCaseExecutor(id="upper_case_executor")
|
||||
reverse_text_executor = ReverseTextExecutor(id="reverse_text_executor")
|
||||
|
||||
# Step 2: Build the workflow with the defined edges.
|
||||
workflow = (
|
||||
WorkflowBuilder()
|
||||
.add_edge(upper_case_executor, reverse_text_executor)
|
||||
.set_start_executor(upper_case_executor)
|
||||
.build()
|
||||
)
|
||||
|
||||
# Step 3: Run the workflow with an initial message.
|
||||
input_text = "hello world"
|
||||
print(f"Starting workflow with input: '{input_text}'")
|
||||
|
||||
completion_event = None
|
||||
async for event in workflow.run_streaming(input_text):
|
||||
print(f"Event: {event}")
|
||||
if isinstance(event, WorkflowCompletedEvent):
|
||||
# The WorkflowCompletedEvent contains the final result.
|
||||
completion_event = event
|
||||
|
||||
if completion_event:
|
||||
print(f"Workflow completed with result: '{completion_event.data}'")
|
||||
else:
|
||||
print("Workflow completed without a completion event")
|
||||
|
||||
except Exception as e:
|
||||
current_span.record_exception(e)
|
||||
print(f"Error running workflow: {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run the telemetry sample with a simple sequential workflow."""
|
||||
# Set up the providers
|
||||
# This must be done before any other telemetry calls
|
||||
set_up_logging()
|
||||
set_up_tracing()
|
||||
set_up_metrics()
|
||||
|
||||
tracer = trace.get_tracer("agent_framework")
|
||||
with tracer.start_as_current_span("Sequential Workflow Scenario", kind=SpanKind.CLIENT) as current_span:
|
||||
print(f"Trace ID: {format_trace_id(current_span.get_span_context().trace_id)}")
|
||||
|
||||
# Run the sequential workflow scenario
|
||||
await run_sequential_workflow()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user