Skip to content
435 changes: 339 additions & 96 deletions python/packages/core/agent_framework/_mcp.py

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion python/packages/core/agent_framework/_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1269,7 +1269,10 @@ async def invoke(
end_time_stamp = perf_counter()
attributes[OtelAttr.ERROR_TYPE] = type(exception).__name__
capture_exception(span=span, exception=exception, timestamp=time_ns())
logger.error(f"Function failed. Error: {exception}")
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED:
logger.error("Function failed. Error: %s", exception)
else:
logger.error("Function %s failed (%s).", self.name, type(exception).__name__)
raise
else:
if skip_parsing:
Expand Down
12 changes: 7 additions & 5 deletions python/packages/core/agent_framework/_workflows/_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,17 +105,19 @@ def from_exception(
cls,
exc: BaseException,
*,
include_sensitive_data: bool = True,
executor_id: str | None = None,
extra: dict[str, Any] | None = None,
) -> WorkflowErrorDetails:
tb = None
try:
tb = "".join(_traceback.format_exception(type(exc), exc, exc.__traceback__))
except Exception:
tb = None
if include_sensitive_data:
try:
tb = "".join(_traceback.format_exception(type(exc), exc, exc.__traceback__))
except Exception:
tb = None
return cls(
error_type=exc.__class__.__name__,
message=str(exc),
message=str(exc) if include_sensitive_data else exc.__class__.__name__,
traceback=tb,
executor_id=executor_id,
extra=extra,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -312,8 +312,14 @@ async def execute(
await handler(message, context)
except Exception as exc:
# Surface structured executor failure before propagating
from ..observability import OBSERVABILITY_SETTINGS

details = WorkflowErrorDetails.from_exception(
exc,
include_sensitive_data=OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED,
)
with _framework_event_origin():
failure_event = WorkflowEvent.executor_failed(self.id, WorkflowErrorDetails.from_exception(exc))
failure_event = WorkflowEvent.executor_failed(self.id, details)
await context.add_event(failure_event)
raise
with _framework_event_origin():
Expand Down
25 changes: 19 additions & 6 deletions python/packages/core/agent_framework/_workflows/_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -1056,7 +1056,13 @@ async def __call__(self, *args: Any, **kwargs: Any) -> R:
# from BaseException, NOT Exception, so it propagates past this handler
# without emitting a spurious executor_failed event. This is intentional
# — request_info is fully supported inside @step functions.
await ctx.add_event(WorkflowEvent.executor_failed(self.name, WorkflowErrorDetails.from_exception(exc)))
from ..observability import OBSERVABILITY_SETTINGS

details = WorkflowErrorDetails.from_exception(
exc,
include_sensitive_data=OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED,
)
await ctx.add_event(WorkflowEvent.executor_failed(self.name, details))
raise
ctx._set_cached_step_auto_request_info_count(
cache_key,
Expand Down Expand Up @@ -1696,16 +1702,23 @@ async def _on_step_completed() -> None:
for event in ctx._get_events():
yield event

details = WorkflowErrorDetails.from_exception(exc)
from ..observability import OBSERVABILITY_SETTINGS

details = WorkflowErrorDetails.from_exception(
exc,
include_sensitive_data=OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED,
)
yield _framework_event(WorkflowEvent.failed, details)
yield _framework_event(WorkflowEvent.status, WorkflowRunState.FAILED)

error_attributes = {
"error.type": type(exc).__name__,
}
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED:
error_attributes["error.message"] = str(exc)
Comment thread
eavanvalkenburg marked this conversation as resolved.
span.add_event(
name=OtelAttr.WORKFLOW_ERROR,
attributes={
"error.message": str(exc),
"error.type": type(exc).__name__,
},
attributes=error_attributes,
)
capture_exception(span, exception=exc)
raise
Expand Down
17 changes: 12 additions & 5 deletions python/packages/core/agent_framework/_workflows/_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -682,20 +682,27 @@ async def _run_workflow_with_tracing(
yield event

# Surface structured failure details before propagating exception
details = WorkflowErrorDetails.from_exception(exc)
from ..observability import OBSERVABILITY_SETTINGS

details = WorkflowErrorDetails.from_exception(
exc,
include_sensitive_data=OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED,
)
with _framework_event_origin():
failed_event = WorkflowEvent.failed(details)
yield failed_event
self._status = WorkflowRunState.FAILED
with _framework_event_origin():
failed_status = WorkflowEvent.status(WorkflowRunState.FAILED)
yield failed_status
error_attributes = {
"error.type": type(exc).__name__,
}
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED:
error_attributes["error.message"] = str(exc)
Comment thread
eavanvalkenburg marked this conversation as resolved.
span.add_event(
name=OtelAttr.WORKFLOW_ERROR,
attributes={
"error.message": str(exc),
"error.type": type(exc).__name__,
},
attributes=error_attributes,
)
capture_exception(span, exception=exc)
raise
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from .._agents import SupportsAgentRun
from .._telemetry import FeatureIndex, mark_feature_used
from ..observability import OtelAttr, capture_exception, create_workflow_span
from ..observability import OtelAttr, create_workflow_span
from ._agent_executor import AgentExecutor
from ._agent_utils import resolve_agent_id
from ._checkpoint import CheckpointStorage
Expand Down Expand Up @@ -881,10 +881,12 @@ async def process(self, text: str, ctx: WorkflowContext[Never, str]) -> None:
return workflow

except Exception as exc:
from ..observability import OBSERVABILITY_SETTINGS

attributes = {
OtelAttr.BUILD_ERROR_MESSAGE: str(exc),
OtelAttr.BUILD_ERROR_TYPE: type(exc).__name__,
}
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED:
attributes[OtelAttr.BUILD_ERROR_MESSAGE] = str(exc)
span.add_event(OtelAttr.BUILD_ERROR, attributes) # type: ignore[reportArgumentType, arg-type]
capture_exception(span, exc)
raise
101 changes: 73 additions & 28 deletions python/packages/core/agent_framework/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -1006,8 +1006,8 @@ class ObservabilitySettings:
metadata (e.g. token counts, operation names, durations) and does not include message
content. Enabling ``enable_sensitive_data`` (env var ``ENABLE_SENSITIVE_DATA``) is an
explicit, separate opt-in that additionally emits raw chat message content, function-call
arguments, and function-call results — treat that data as sensitive and ensure it is not sent
to, or retained by, a telemetry backend you have not secured appropriately.
arguments, function-call results, and exception messages — treat that data as sensitive and
ensure it is not sent to, or retained by, a telemetry backend you have not secured appropriately.

Keyword Args:
enable_instrumentation: Enable OpenTelemetry diagnostics. Default is True.
Expand Down Expand Up @@ -2734,6 +2734,8 @@ def create_mcp_client_span(
method_name: str,
target: str | None = None,
attributes: dict[str, Any] | None = None,
*,
capture_unhandled_exceptions: bool = True,
) -> Generator[trace.Span, Any, Any]:
"""Create an MCP client span per OTel MCP semantic conventions.

Expand All @@ -2746,6 +2748,9 @@ def create_mcp_client_span(
method_name: The MCP method name (e.g. ``initialize``, ``tools/call``).
target: Optional low-cardinality target (tool name, prompt name).
attributes: Additional span attributes.
capture_unhandled_exceptions: Whether exceptions escaping the context should be
recorded using the framework's sensitive-data policy. Internal MCP operations
that set protocol-specific error types disable this fallback.
"""
span_name = f"{method_name} {target}" if target else method_name
attrs: dict[str, Any] = {OtelAttr.MCP_METHOD_NAME: method_name}
Expand All @@ -2756,10 +2761,17 @@ def create_mcp_client_span(
with trace.use_span(
span=span,
end_on_exit=True,
record_exception=True,
set_status_on_exception=True,
record_exception=False,
set_status_on_exception=False,
) as current_span:
yield current_span
try:
yield current_span
except Exception as exception:
if capture_unhandled_exceptions:
capture_exception(current_span, exception)
elif OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED:
current_span.record_exception(exception)
raise


def set_mcp_span_error(
Expand All @@ -2775,7 +2787,10 @@ def set_mcp_span_error(
description: Optional description (e.g. JSON-RPC error message).
"""
span.set_attribute(OtelAttr.ERROR_TYPE, error_type)
span.set_status(trace.StatusCode.ERROR, description=description)
span.set_status(
trace.StatusCode.ERROR,
description=description if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED else None,
)


# endregion
Expand Down Expand Up @@ -3191,8 +3206,11 @@ def _get_span_attributes(**kwargs: Any) -> dict[str, Any]:
def capture_exception(span: trace.Span, exception: Exception, timestamp: int | None = None) -> None:
"""Set an error for spans."""
span.set_attribute(OtelAttr.ERROR_TYPE, type(exception).__name__)
span.record_exception(exception=exception, timestamp=timestamp)
span.set_status(status=trace.StatusCode.ERROR, description=repr(exception))
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED:
span.record_exception(exception=exception, timestamp=timestamp)
span.set_status(status=trace.StatusCode.ERROR, description=repr(exception))
else:
span.set_status(status=trace.StatusCode.ERROR)


def _capture_system_instructions_latest_experimental(
Expand Down Expand Up @@ -3646,20 +3664,39 @@ def _workflow_span_attributes(
return span_attributes or None


@contextlib.contextmanager
def _capture_span_exceptions(
context_manager: contextlib.AbstractContextManager[trace.Span],
) -> Generator[trace.Span]:
"""Apply the framework's sensitive-data policy to an OpenTelemetry span context."""
with context_manager as span:
try:
yield span
except Exception as exception:
capture_exception(span, exception)
raise


def create_workflow_span(
name: str,
attributes: Mapping[str, str | int] | None = None,
kind: trace.SpanKind = trace.SpanKind.INTERNAL,
) -> _AgnosticContextManager[trace.Span]:
) -> contextlib.AbstractContextManager[trace.Span]:
"""Create a generic workflow span attached as the current span.

Do not use this from an async generator that yields to callers: attaching
across a ``yield`` leaves OpenTelemetry's context token set when the
generator is later closed on GC from a different ``Context``. Streaming
workflow runs should use :func:`start_workflow_span` instead.
"""
return workflow_tracer().start_as_current_span(
name, kind=kind, attributes=_workflow_span_attributes(name, attributes)
return _capture_span_exceptions(
workflow_tracer().start_as_current_span(
name,
kind=kind,
attributes=_workflow_span_attributes(name, attributes),
record_exception=False,
set_status_on_exception=False,
)
)


Expand All @@ -3686,7 +3723,7 @@ def create_processing_span(
payload_type: str,
source_trace_contexts: list[dict[str, str]] | None = None,
source_span_ids: list[str] | None = None,
) -> _AgnosticContextManager[trace.Span]:
) -> contextlib.AbstractContextManager[trace.Span]:
"""Create an executor processing span with optional links to source spans.

Processing spans are created as children of the current workflow span and
Expand Down Expand Up @@ -3726,16 +3763,20 @@ def create_processing_span(
)
links.append(trace.Link(span_context))

return workflow_tracer().start_as_current_span(
f"{OtelAttr.EXECUTOR_PROCESS_SPAN} {executor_id}",
kind=trace.SpanKind.INTERNAL,
attributes={
OtelAttr.EXECUTOR_ID: executor_id,
OtelAttr.EXECUTOR_TYPE: executor_type,
OtelAttr.MESSAGE_TYPE: message_type,
OtelAttr.MESSAGE_PAYLOAD_TYPE: payload_type,
},
links=links,
return _capture_span_exceptions(
workflow_tracer().start_as_current_span(
f"{OtelAttr.EXECUTOR_PROCESS_SPAN} {executor_id}",
kind=trace.SpanKind.INTERNAL,
attributes={
OtelAttr.EXECUTOR_ID: executor_id,
OtelAttr.EXECUTOR_TYPE: executor_type,
OtelAttr.MESSAGE_TYPE: message_type,
OtelAttr.MESSAGE_PAYLOAD_TYPE: payload_type,
},
links=links,
record_exception=False,
set_status_on_exception=False,
)
)


Expand All @@ -3746,7 +3787,7 @@ def create_edge_group_processing_span(
message_target_id: str | None = None,
source_trace_contexts: list[dict[str, str]] | None = None,
source_span_ids: list[str] | None = None,
) -> _AgnosticContextManager[trace.Span]:
) -> contextlib.AbstractContextManager[trace.Span]:
"""Create an edge group processing span with optional links to source spans.

Edge group processing spans track the processing operations in edge runners
Expand Down Expand Up @@ -3799,9 +3840,13 @@ def create_edge_group_processing_span(
# If linking fails, continue without link (graceful degradation)
pass

return workflow_tracer().start_as_current_span(
f"{OtelAttr.EDGE_GROUP_PROCESS_SPAN} {edge_group_type}",
kind=trace.SpanKind.INTERNAL,
attributes=attributes,
links=links,
return _capture_span_exceptions(
workflow_tracer().start_as_current_span(
f"{OtelAttr.EDGE_GROUP_PROCESS_SPAN} {edge_group_type}",
kind=trace.SpanKind.INTERNAL,
attributes=attributes,
links=links,
record_exception=False,
set_status_on_exception=False,
)
)
Loading
Loading