From e60c505affc360ca034f7cac489577e51cf834a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 14:45:20 +0000 Subject: [PATCH 1/7] Initial plan From e3efbb001e7a206e995559bb8565fea52b320adb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:08:51 +0000 Subject: [PATCH 2/7] feat: Replace parent_id string with Context object and add context propagation - Changed all scope classes to accept `parent_context: Context` instead of `parent_id: str` - Added `inject_trace_context()` method to OpenTelemetryScope for header propagation - Added `get_context()` method to get the scope's Context for passing to child scopes - Added `extract_trace_context()` utility function using OTel's standard `extract()` - Removed custom W3C trace context parsing in favor of OTel's propagation API - Updated OutputLoggingMiddleware to use extract_trace_context() - Updated all tests to use extract_trace_context() and parent_context Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../observability/core/__init__.py | 3 + .../observability/core/execute_tool_scope.py | 19 +- .../observability/core/inference_scope.py | 20 +- .../observability/core/invoke_agent_scope.py | 11 + .../observability/core/opentelemetry_scope.py | 60 +++- .../core/spans_scopes/output_scope.py | 22 +- .../observability/core/utils.py | 128 +------- .../middleware/output_logging_middleware.py | 11 +- .../core/test_execute_tool_scope.py | 12 +- .../core/test_inference_scope.py | 12 +- tests/observability/core/test_output_scope.py | 12 +- .../core/test_trace_context_propagation.py | 288 ++++++++++++++++++ .../test_output_logging_middleware.py | 6 +- 13 files changed, 445 insertions(+), 159 deletions(-) create mode 100644 tests/observability/core/test_trace_context_propagation.py diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py index a3915b6b..b630e52c 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py @@ -33,6 +33,7 @@ from .tool_call_details import ToolCallDetails from .tool_type import ToolType from .trace_processor.span_processor import SpanProcessor +from .utils import extract_trace_context __all__ = [ # Main SDK functions @@ -71,6 +72,8 @@ "ExecutionType", "InferenceOperationType", "ToolType", + # Utility functions + "extract_trace_context", # Constants # all constants from constants.py are exported via * ] diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py index 32ecc977..9da6a024 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py @@ -3,6 +3,7 @@ from datetime import datetime +from opentelemetry.context import Context from opentelemetry.trace import SpanKind from .agent_details import AgentDetails @@ -33,7 +34,7 @@ def start( agent_details: AgentDetails, tenant_details: TenantDetails, request: Request | None = None, - parent_id: str | None = None, + parent_context: Context | None = None, start_time: datetime | None = None, end_time: datetime | None = None, span_kind: SpanKind | None = None, @@ -45,8 +46,9 @@ def start( agent_details: The details of the agent making the call tenant_details: The details of the tenant request: Optional request details for additional context - parent_id: Optional parent Activity ID used to link this span to an upstream - operation + parent_context: Optional OpenTelemetry Context used to link this span to an + upstream operation. Use ``extract_trace_context()`` to convert a + Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. Useful when recording a tool call after execution has already completed. end_time: Optional explicit end time as a datetime object. When provided, @@ -63,7 +65,7 @@ def start( agent_details, tenant_details, request, - parent_id, + parent_context, start_time, end_time, span_kind, @@ -75,7 +77,7 @@ def __init__( agent_details: AgentDetails, tenant_details: TenantDetails, request: Request | None = None, - parent_id: str | None = None, + parent_context: Context | None = None, start_time: datetime | None = None, end_time: datetime | None = None, span_kind: SpanKind | None = None, @@ -87,8 +89,9 @@ def __init__( agent_details: The details of the agent making the call tenant_details: The details of the tenant request: Optional request details for additional context - parent_id: Optional parent Activity ID used to link this span to an upstream - operation + parent_context: Optional OpenTelemetry Context used to link this span to an + upstream operation. Use ``extract_trace_context()`` to convert a + Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. Useful when recording a tool call after execution has already completed. end_time: Optional explicit end time as a datetime object. When provided, @@ -103,7 +106,7 @@ def __init__( activity_name=f"{EXECUTE_TOOL_OPERATION_NAME} {details.tool_name}", agent_details=agent_details, tenant_details=tenant_details, - parent_id=parent_id, + parent_context=parent_context, start_time=start_time, end_time=end_time, ) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py index fa93d966..47337674 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py @@ -4,6 +4,8 @@ from datetime import datetime from typing import List +from opentelemetry.context import Context + from .agent_details import AgentDetails from .constants import ( CHANNEL_LINK_KEY, @@ -36,7 +38,7 @@ def start( agent_details: AgentDetails, tenant_details: TenantDetails, request: Request | None = None, - parent_id: str | None = None, + parent_context: Context | None = None, start_time: datetime | None = None, end_time: datetime | None = None, ) -> "InferenceScope": @@ -47,8 +49,9 @@ def start( agent_details: The details of the agent making the call tenant_details: The details of the tenant request: Optional request details for additional context - parent_id: Optional parent Activity ID used to link this span to an upstream - operation + parent_context: Optional OpenTelemetry Context used to link this span to an + upstream operation. Use ``extract_trace_context()`` to convert a + Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. @@ -56,7 +59,7 @@ def start( A new InferenceScope instance """ return InferenceScope( - details, agent_details, tenant_details, request, parent_id, start_time, end_time + details, agent_details, tenant_details, request, parent_context, start_time, end_time ) def __init__( @@ -65,7 +68,7 @@ def __init__( agent_details: AgentDetails, tenant_details: TenantDetails, request: Request | None = None, - parent_id: str | None = None, + parent_context: Context | None = None, start_time: datetime | None = None, end_time: datetime | None = None, ): @@ -76,8 +79,9 @@ def __init__( agent_details: The details of the agent making the call tenant_details: The details of the tenant request: Optional request details for additional context - parent_id: Optional parent Activity ID used to link this span to an upstream - operation + parent_context: Optional OpenTelemetry Context used to link this span to an + upstream operation. Use ``extract_trace_context()`` to convert a + Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. """ @@ -88,7 +92,7 @@ def __init__( activity_name=f"{details.operationName.value} {details.model}", agent_details=agent_details, tenant_details=tenant_details, - parent_id=parent_id, + parent_context=parent_context, start_time=start_time, end_time=end_time, ) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py index 31fb88db..2adb013e 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py @@ -6,6 +6,7 @@ import logging from datetime import datetime +from opentelemetry.context import Context from opentelemetry.trace import SpanKind from .agent_details import AgentDetails @@ -50,6 +51,7 @@ def start( request: Request | None = None, caller_agent_details: AgentDetails | None = None, caller_details: CallerDetails | None = None, + parent_context: Context | None = None, start_time: datetime | None = None, end_time: datetime | None = None, span_kind: SpanKind | None = None, @@ -63,6 +65,9 @@ def start( request: Optional request details for additional context caller_agent_details: Optional details of the caller agent caller_details: Optional details of the non-agentic caller + parent_context: Optional OpenTelemetry Context used to link this span to an + upstream operation. Use ``extract_trace_context()`` to convert a + Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. span_kind: Optional span kind override. Defaults to ``SpanKind.CLIENT``. @@ -77,6 +82,7 @@ def start( request, caller_agent_details, caller_details, + parent_context, start_time, end_time, span_kind, @@ -89,6 +95,7 @@ def __init__( request: Request | None = None, caller_agent_details: AgentDetails | None = None, caller_details: CallerDetails | None = None, + parent_context: Context | None = None, start_time: datetime | None = None, end_time: datetime | None = None, span_kind: SpanKind | None = None, @@ -101,6 +108,9 @@ def __init__( request: Optional request details for additional context caller_agent_details: Optional details of the caller agent caller_details: Optional details of the non-agentic caller + parent_context: Optional OpenTelemetry Context used to link this span to an + upstream operation. Use ``extract_trace_context()`` to convert a + Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. span_kind: Optional span kind override. Defaults to ``SpanKind.CLIENT``. @@ -118,6 +128,7 @@ def __init__( activity_name=activity_name, agent_details=invoke_agent_details.details, tenant_details=tenant_details, + parent_context=parent_context, start_time=start_time, end_time=end_time, ) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py index c9ee1387..f4ed1dce 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py @@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Any from opentelemetry import context, trace +from opentelemetry.context import Context +from opentelemetry.propagate import inject from opentelemetry.trace import ( Span, SpanKind, @@ -43,7 +45,7 @@ TELEMETRY_SDK_VERSION_KEY, TENANT_ID_KEY, ) -from .utils import get_sdk_version, parse_parent_id_to_context +from .utils import get_sdk_version if TYPE_CHECKING: from .agent_details import AgentDetails @@ -97,7 +99,7 @@ def __init__( activity_name: str, agent_details: "AgentDetails | None" = None, tenant_details: "TenantDetails | None" = None, - parent_id: str | None = None, + parent_context: Context | None = None, start_time: datetime | None = None, end_time: datetime | None = None, ): @@ -111,8 +113,9 @@ def __init__( activity_name: The name of the activity for display purposes agent_details: Optional agent details tenant_details: Optional tenant details - parent_id: Optional parent Activity ID used to link this span to an upstream - operation + parent_context: Optional OpenTelemetry Context used to link this span to an + upstream operation. Use ``extract_trace_context()`` to extract a + Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. Useful when recording an operation after it has already completed. end_time: Optional explicit end time as a datetime object. @@ -146,9 +149,8 @@ def __init__( activity_kind = SpanKind.CONSUMER # Get context for parent relationship - # If parent_id is provided, parse it and use it as the parent context + # If parent_context is provided, use it directly # Otherwise, use the current context - parent_context = parse_parent_id_to_context(parent_id) span_context = parent_context if parent_context else context.get_current() # Convert custom start time to OTel-compatible format (nanoseconds since epoch) @@ -286,6 +288,52 @@ def _end(self) -> None: else: self._span.end() + def get_context(self) -> Context | None: + """Get the OpenTelemetry context for this scope's span. + + This method returns a Context object containing this scope's span, + which can be used to propagate trace context to child operations + or downstream services. + + Returns: + A Context containing this scope's span, or None if telemetry + is disabled or no span exists. + """ + if self._span and self._is_telemetry_enabled(): + return set_span_in_context(self._span) + return None + + def inject_trace_context(self) -> dict[str, str]: + """Inject trace context headers for distributed tracing propagation. + + This method returns a dictionary of headers containing the trace + context (traceparent and tracestate) that can be used to propagate + the current span's context to downstream services via HTTP headers + or other transport mechanisms. + + The headers follow the W3C Trace Context specification and include: + - ``traceparent``: Contains version, trace-id, parent-id, and trace-flags + - ``tracestate``: Contains vendor-specific trace information (if any) + + Example usage:: + + >>> scope = OpenTelemetryScope(...) + >>> headers = scope.inject_trace_context() + >>> # Add headers to outgoing HTTP request + >>> requests.get("https://downstream-service/api", headers=headers) + + Returns: + A dictionary containing W3C trace context headers. Returns an + empty dictionary if telemetry is disabled or no span exists. + """ + headers: dict[str, str] = {} + if self._span and self._is_telemetry_enabled(): + # Create a context with the current span + ctx = set_span_in_context(self._span) + # Use the global propagator to inject trace context into headers + inject(headers, context=ctx) + return headers + def __enter__(self): """Enter the context manager and make span active.""" if self._span and self._is_telemetry_enabled(): diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py index 8f128adf..1b29c732 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py @@ -3,6 +3,8 @@ from datetime import datetime +from opentelemetry.context import Context + from ..agent_details import AgentDetails from ..constants import GEN_AI_OUTPUT_MESSAGES_KEY from ..models.response import Response @@ -23,7 +25,7 @@ def start( agent_details: AgentDetails, tenant_details: TenantDetails, response: Response, - parent_id: str | None = None, + parent_context: Context | None = None, start_time: datetime | None = None, end_time: datetime | None = None, ) -> "OutputScope": @@ -33,22 +35,25 @@ def start( agent_details: The details of the agent tenant_details: The details of the tenant response: The response details from the agent - parent_id: Optional parent Activity ID used to link this span to an upstream - operation + parent_context: Optional OpenTelemetry Context used to link this span to an + upstream operation. Use ``extract_trace_context()`` to convert a + Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. Returns: A new OutputScope instance """ - return OutputScope(agent_details, tenant_details, response, parent_id, start_time, end_time) + return OutputScope( + agent_details, tenant_details, response, parent_context, start_time, end_time + ) def __init__( self, agent_details: AgentDetails, tenant_details: TenantDetails, response: Response, - parent_id: str | None = None, + parent_context: Context | None = None, start_time: datetime | None = None, end_time: datetime | None = None, ): @@ -58,8 +63,9 @@ def __init__( agent_details: The details of the agent tenant_details: The details of the tenant response: The response details from the agent - parent_id: Optional parent Activity ID used to link this span to an upstream - operation + parent_context: Optional OpenTelemetry Context used to link this span to an + upstream operation. Use ``extract_trace_context()`` to convert a + Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. """ @@ -69,7 +75,7 @@ def __init__( activity_name=(f"{OUTPUT_OPERATION_NAME} {agent_details.agent_id}"), agent_details=agent_details, tenant_details=tenant_details, - parent_id=parent_id, + parent_context=parent_context, start_time=start_time, end_time=end_time, ) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py index 81231499..b8c91a85 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py @@ -19,7 +19,7 @@ EXCEPTION_MESSAGE, EXCEPTION_STACKTRACE, ) -from opentelemetry.trace import NonRecordingSpan, Span, SpanContext, TraceFlags, set_span_in_context +from opentelemetry.trace import Span from opentelemetry.util.types import AttributeValue from wrapt import ObjectProxy @@ -29,126 +29,30 @@ logger.addHandler(logging.NullHandler()) -# W3C Trace Context constants -W3C_TRACE_CONTEXT_VERSION = "00" -W3C_TRACE_ID_LENGTH = 32 # 32 hex chars = 128 bits -W3C_SPAN_ID_LENGTH = 16 # 16 hex chars = 64 bits +def extract_trace_context(headers: dict[str, str]) -> context.Context: + """Extract trace context from HTTP headers. - -def validate_w3c_trace_context_version(version: str) -> bool: - """Validate W3C Trace Context version. - - Args: - version: The version string to validate - - Returns: - True if valid, False otherwise - """ - return version == W3C_TRACE_CONTEXT_VERSION - - -def _is_valid_hex(hex_string: str) -> bool: - """Check if a string contains only valid hexadecimal characters. - - Args: - hex_string: The string to validate - - Returns: - True if all characters are valid hexadecimal (0-9, a-f, A-F), False otherwise - """ - return all(c in "0123456789abcdefABCDEF" for c in hex_string) - - -def validate_trace_id(trace_id_hex: str) -> bool: - """Validate W3C Trace Context trace_id format. + This function extracts W3C Trace Context from a dictionary of HTTP headers + using OpenTelemetry's standard propagation mechanism. Args: - trace_id_hex: The trace_id hex string to validate (should be 32 hex chars) + headers: Dictionary of HTTP headers containing trace context. + Expected keys include ``traceparent`` and optionally ``tracestate``. Returns: - True if valid (32 hex chars), False otherwise - """ - return len(trace_id_hex) == W3C_TRACE_ID_LENGTH and _is_valid_hex(trace_id_hex) - + An OpenTelemetry Context containing the extracted trace information. + If no valid trace context is found, returns an empty context. -def validate_span_id(span_id_hex: str) -> bool: - """Validate W3C Trace Context span_id format. + Example:: - Args: - span_id_hex: The span_id hex string to validate (should be 16 hex chars) - - Returns: - True if valid (16 hex chars), False otherwise + >>> headers = {"traceparent": "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01"} + >>> parent_context = extract_trace_context(headers) + >>> with InferenceScope.start(details, agent, tenant, parent_context=parent_context): + ... pass """ - return len(span_id_hex) == W3C_SPAN_ID_LENGTH and _is_valid_hex(span_id_hex) + from opentelemetry.propagate import extract - -def parse_parent_id_to_context(parent_id: str | None) -> context.Context | None: - """Parse a W3C trace context parent ID and return a context with the parent span. - - The parent_id format is expected to be W3C Trace Context format: - "00-{trace_id}-{span_id}-{trace_flags}" - Example: "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01" - - Args: - parent_id: The W3C Trace Context format parent ID string - - Returns: - A context containing the parent span, or None if parent_id is invalid - """ - if not parent_id: - return None - - try: - # W3C Trace Context format: "00-{trace_id}-{span_id}-{trace_flags}" - parts = parent_id.split("-") - if len(parts) != 4: - logger.warning(f"Invalid parent_id format (expected 4 parts): {parent_id}") - return None - - version, trace_id_hex, span_id_hex, trace_flags_hex = parts - - # Validate W3C Trace Context version - if not validate_w3c_trace_context_version(version): - logger.warning(f"Unsupported W3C Trace Context version: {version}") - return None - - # Validate trace_id (must be 32 hex chars) - if not validate_trace_id(trace_id_hex): - logger.warning( - f"Invalid trace_id (expected {W3C_TRACE_ID_LENGTH} hex chars): '{trace_id_hex}'" - ) - return None - - # Validate span_id (must be 16 hex chars) - if not validate_span_id(span_id_hex): - logger.warning( - f"Invalid span_id (expected {W3C_SPAN_ID_LENGTH} hex chars): '{span_id_hex}'" - ) - return None - - # Parse the hex values - trace_id = int(trace_id_hex, 16) - span_id = int(span_id_hex, 16) - trace_flags = TraceFlags(int(trace_flags_hex, 16)) - - # Create a SpanContext from the parsed values - parent_span_context = SpanContext( - trace_id=trace_id, - span_id=span_id, - is_remote=True, - trace_flags=trace_flags, - ) - - # Create a NonRecordingSpan with the parent context - parent_span = NonRecordingSpan(parent_span_context) - - # Create a context with the parent span - return set_span_in_context(parent_span) - - except (ValueError, IndexError) as e: - logger.warning(f"Failed to parse parent_id '{parent_id}': {e}") - return None + return extract(headers) def safe_json_dumps(obj: Any, **kwargs: Any) -> str: diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py index d69070a2..3230614f 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py @@ -24,6 +24,7 @@ from microsoft_agents_a365.observability.core.models.response import Response from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope from microsoft_agents_a365.observability.core.tenant_details import TenantDetails +from microsoft_agents_a365.observability.core.utils import extract_trace_context from ..scope_helpers.utils import ( get_execution_type_pair, @@ -32,7 +33,7 @@ logger = logging.getLogger(__name__) A365_PARENT_SPAN_KEY = "A365ParentSpanId" -"""TurnState key for the parent span reference.""" +"""TurnState key for the parent span reference (W3C traceparent string).""" def _derive_agent_details(context: TurnContext) -> AgentDetails | None: @@ -176,7 +177,11 @@ async def handler( return parent_id: str | None = turn_context.turn_state.get(A365_PARENT_SPAN_KEY) - if not parent_id: + parent_context = None + if parent_id: + # Convert W3C traceparent string to Context using OpenTelemetry's extract + parent_context = extract_trace_context({"traceparent": parent_id}) + else: logger.warning( "[OutputLoggingMiddleware] No parent span ref in turn_state under " "'%s'. OutputScope will not be linked to a parent.", @@ -187,7 +192,7 @@ async def handler( agent_details=agent_details, tenant_details=tenant_details, response=Response(messages=messages), - parent_id=parent_id, + parent_context=parent_context, ) # Set additional attributes on the scope diff --git a/tests/observability/core/test_execute_tool_scope.py b/tests/observability/core/test_execute_tool_scope.py index 5c295c0f..4279467c 100644 --- a/tests/observability/core/test_execute_tool_scope.py +++ b/tests/observability/core/test_execute_tool_scope.py @@ -16,6 +16,7 @@ TenantDetails, ToolCallDetails, configure, + extract_trace_context, get_tracer_provider, ) from microsoft_agents_a365.observability.core.config import _telemetry_manager @@ -132,14 +133,17 @@ def test_request_metadata_set_on_span(self): request.source_metadata.description, ) - def test_execute_tool_scope_with_parent_id(self): - """Test ExecuteToolScope uses parent_id to link span to parent context.""" + def test_execute_tool_scope_with_parent_context(self): + """Test ExecuteToolScope uses parent_context to link span to parent context.""" parent_trace_id = "1234567890abcdef1234567890abcdef" parent_span_id = "abcdefabcdef1234" - parent_id = f"00-{parent_trace_id}-{parent_span_id}-01" + traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" + + # Extract context from traceparent header + parent_context = extract_trace_context({"traceparent": traceparent}) with ExecuteToolScope.start( - self.tool_details, self.agent_details, self.tenant_details, parent_id=parent_id + self.tool_details, self.agent_details, self.tenant_details, parent_context=parent_context ): pass diff --git a/tests/observability/core/test_inference_scope.py b/tests/observability/core/test_inference_scope.py index b3fad3b8..b43b92c8 100644 --- a/tests/observability/core/test_inference_scope.py +++ b/tests/observability/core/test_inference_scope.py @@ -16,6 +16,7 @@ SourceMetadata, TenantDetails, configure, + extract_trace_context, get_tracer_provider, ) from microsoft_agents_a365.observability.core.agent_details import AgentDetails @@ -337,8 +338,8 @@ def test_record_thought_process(self): # Should not raise an exception self.assertTrue(hasattr(scope, "record_thought_process")) - def test_inference_scope_with_parent_id(self): - """Test InferenceScope uses parent_id to link span to parent context.""" + def test_inference_scope_with_parent_context(self): + """Test InferenceScope uses parent_context to link span to parent context.""" details = InferenceCallDetails( operationName=InferenceOperationType.CHAT, model="gpt-4", @@ -347,10 +348,13 @@ def test_inference_scope_with_parent_id(self): parent_trace_id = "1234567890abcdef1234567890abcdef" parent_span_id = "abcdefabcdef1234" - parent_id = f"00-{parent_trace_id}-{parent_span_id}-01" + traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" + + # Extract context from traceparent header + parent_context = extract_trace_context({"traceparent": traceparent}) with InferenceScope.start( - details, self.agent_details, self.tenant_details, parent_id=parent_id + details, self.agent_details, self.tenant_details, parent_context=parent_context ): pass diff --git a/tests/observability/core/test_output_scope.py b/tests/observability/core/test_output_scope.py index c1886657..103ffa11 100644 --- a/tests/observability/core/test_output_scope.py +++ b/tests/observability/core/test_output_scope.py @@ -11,6 +11,7 @@ AgentDetails, TenantDetails, configure, + extract_trace_context, get_tracer_provider, ) from microsoft_agents_a365.observability.core.config import _telemetry_manager @@ -107,15 +108,18 @@ def test_record_output_messages_appends(self): self.assertIn("Appended 2", output_value) self.assertIn("Appended 3", output_value) - def test_output_scope_with_parent_id(self): - """Test OutputScope uses parent_id to link span to parent context.""" + def test_output_scope_with_parent_context(self): + """Test OutputScope uses parent_context to link span to parent context.""" response = Response(messages=["Test"]) parent_trace_id = "1234567890abcdef1234567890abcdef" parent_span_id = "abcdefabcdef1234" - parent_id = f"00-{parent_trace_id}-{parent_span_id}-01" + traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" + + # Extract context from traceparent header + parent_context = extract_trace_context({"traceparent": traceparent}) with OutputScope.start( - self.agent_details, self.tenant_details, response, parent_id=parent_id + self.agent_details, self.tenant_details, response, parent_context=parent_context ): pass diff --git a/tests/observability/core/test_trace_context_propagation.py b/tests/observability/core/test_trace_context_propagation.py new file mode 100644 index 00000000..c6f2568b --- /dev/null +++ b/tests/observability/core/test_trace_context_propagation.py @@ -0,0 +1,288 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for trace context propagation functionality.""" + +import os +import sys +import unittest +from pathlib import Path + +import pytest +from microsoft_agents_a365.observability.core import ( + AgentDetails, + ExecuteToolScope, + InferenceCallDetails, + InferenceOperationType, + InferenceScope, + InvokeAgentDetails, + InvokeAgentScope, + TenantDetails, + ToolCallDetails, + configure, + extract_trace_context, + get_tracer_provider, +) +from microsoft_agents_a365.observability.core.config import _telemetry_manager +from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + +class TestTraceContextPropagation(unittest.TestCase): + """Unit tests for trace context propagation functionality.""" + + @classmethod + def setUpClass(cls): + """Set up test environment once for all tests.""" + os.environ["ENABLE_A365_OBSERVABILITY"] = "true" + + configure( + service_name="test-trace-propagation-service", + service_namespace="test-namespace", + ) + + cls.tenant_details = TenantDetails(tenant_id="12345678-1234-5678-1234-567812345678") + cls.agent_details = AgentDetails( + agent_id="test-agent-123", + agent_name="Test Agent", + agent_description="A test agent for trace propagation testing", + ) + + def setUp(self): + super().setUp() + + _telemetry_manager._tracer_provider = None + _telemetry_manager._span_processors = {} + OpenTelemetryScope._tracer = None + + configure( + service_name="test-trace-propagation-service", + service_namespace="test-namespace", + ) + + self.span_exporter = InMemorySpanExporter() + tracer_provider = get_tracer_provider() + tracer_provider.add_span_processor(SimpleSpanProcessor(self.span_exporter)) + + def tearDown(self): + super().tearDown() + self.span_exporter.clear() + + def test_inject_trace_context_returns_headers(self): + """Test that inject_trace_context returns traceparent and tracestate headers.""" + details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, + model="gpt-4", + providerName="openai", + ) + + scope = InferenceScope.start(details, self.agent_details, self.tenant_details) + + if scope is not None: + headers = scope.inject_trace_context() + + # Should contain at least traceparent header + self.assertIn("traceparent", headers) + + # Validate traceparent format: 00-{trace_id}-{span_id}-{flags} + traceparent = headers["traceparent"] + parts = traceparent.split("-") + self.assertEqual(len(parts), 4, "traceparent should have 4 parts") + self.assertEqual(parts[0], "00", "version should be 00") + self.assertEqual(len(parts[1]), 32, "trace_id should be 32 hex chars") + self.assertEqual(len(parts[2]), 16, "span_id should be 16 hex chars") + self.assertEqual(len(parts[3]), 2, "flags should be 2 hex chars") + + scope.dispose() + + def test_inject_trace_context_contains_span_id(self): + """Test that injected headers contain the correct span ID.""" + details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, + model="gpt-4", + providerName="openai", + ) + + scope = InferenceScope.start(details, self.agent_details, self.tenant_details) + + if scope is not None: + headers = scope.inject_trace_context() + scope.dispose() + + # Get the span from exported spans + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span to be created") + + span = finished_spans[-1] + expected_span_id = f"{span.context.span_id:016x}" + + # Verify the traceparent contains the span_id + traceparent = headers["traceparent"] + parts = traceparent.split("-") + self.assertEqual(parts[2], expected_span_id) + + def test_get_context_returns_context_object(self): + """Test that get_context returns a valid Context object.""" + details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, + model="gpt-4", + providerName="openai", + ) + + scope = InferenceScope.start(details, self.agent_details, self.tenant_details) + + if scope is not None: + ctx = scope.get_context() + + # Should return a Context object + self.assertIsNotNone(ctx) + + scope.dispose() + + def test_context_propagation_via_inject_extract(self): + """Test that context can be propagated using inject/extract pattern.""" + # Create parent scope + parent_details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, + model="gpt-4", + providerName="openai", + ) + + parent_scope = InferenceScope.start( + parent_details, self.agent_details, self.tenant_details + ) + + # Get injected headers from parent + headers = parent_scope.inject_trace_context() + parent_scope.dispose() + + # Extract context from headers (simulating receiving via HTTP) + parent_context = extract_trace_context(headers) + + # Create child scope using extracted context + tool_details = ToolCallDetails( + tool_name="search_tool", + arguments='{"query": "test"}', + tool_call_id="call-123", + ) + + child_scope = ExecuteToolScope.start( + tool_details, + self.agent_details, + self.tenant_details, + parent_context=parent_context, + ) + child_scope.dispose() + + # Verify parent-child relationship + finished_spans = self.span_exporter.get_finished_spans() + self.assertEqual(len(finished_spans), 2, "Expected 2 spans (parent and child)") + + parent_span = finished_spans[0] + child_span = finished_spans[1] + + # Child should have parent's trace_id + self.assertEqual( + parent_span.context.trace_id, + child_span.context.trace_id, + "Child span should have same trace_id as parent", + ) + + # Child's parent should be the parent span + self.assertIsNotNone(child_span.parent) + self.assertEqual( + child_span.parent.span_id, + parent_span.context.span_id, + "Child's parent_id should match parent's span_id", + ) + + def test_inject_headers_for_http_propagation(self): + """Test that injected headers can be used for HTTP request propagation.""" + # Create a scope + details = InferenceCallDetails( + operationName=InferenceOperationType.CHAT, + model="gpt-4", + providerName="openai", + ) + + scope = InferenceScope.start(details, self.agent_details, self.tenant_details) + + if scope is not None: + headers = scope.inject_trace_context() + scope.dispose() + + # Parse the traceparent to get trace_id and span_id + traceparent = headers["traceparent"] + parts = traceparent.split("-") + trace_id = parts[1] + span_id = parts[2] + + # Simulate receiving these headers and creating a child scope + parent_ctx = extract_trace_context(headers) + + # Create new scope using received context + child_details = InferenceCallDetails( + operationName=InferenceOperationType.TEXT_COMPLETION, + model="gpt-3.5-turbo", + providerName="openai", + ) + + child_scope = InferenceScope.start( + child_details, self.agent_details, self.tenant_details, parent_context=parent_ctx + ) + if child_scope is not None: + child_scope.dispose() + + # Verify spans are properly linked + finished_spans = self.span_exporter.get_finished_spans() + self.assertEqual(len(finished_spans), 2) + + parent_span = finished_spans[0] + child_span = finished_spans[1] + + # Should have same trace_id + self.assertEqual( + f"{parent_span.context.trace_id:032x}", + f"{child_span.context.trace_id:032x}", + ) + + def test_invoke_agent_scope_with_parent_context(self): + """Test InvokeAgentScope with parent_context parameter.""" + from urllib.parse import urlparse + + parent_trace_id = "1234567890abcdef1234567890abcdef" + parent_span_id = "abcdefabcdef1234" + traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" + + parent_context = extract_trace_context({"traceparent": traceparent}) + + invoke_details = InvokeAgentDetails( + endpoint=urlparse("https://example.com/agent"), + details=self.agent_details, + session_id="session-123", + ) + + with InvokeAgentScope.start( + invoke_details, self.tenant_details, parent_context=parent_context + ) as scope: + headers = scope.inject_trace_context() + self.assertIn("traceparent", headers) + + finished_spans = self.span_exporter.get_finished_spans() + self.assertTrue(finished_spans, "Expected at least one span to be created") + + span = finished_spans[-1] + + # Verify span inherits parent's trace_id + span_trace_id = f"{span.context.trace_id:032x}" + self.assertEqual(span_trace_id, parent_trace_id) + + # Verify span's parent_span_id matches + self.assertIsNotNone(span.parent, "Expected span to have a parent") + span_parent_id = f"{span.parent.span_id:016x}" + self.assertEqual(span_parent_id, parent_span_id) + + +if __name__ == "__main__": + sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) diff --git a/tests/observability/hosting/middleware/test_output_logging_middleware.py b/tests/observability/hosting/middleware/test_output_logging_middleware.py index c7d89389..31015d09 100644 --- a/tests/observability/hosting/middleware/test_output_logging_middleware.py +++ b/tests/observability/hosting/middleware/test_output_logging_middleware.py @@ -164,7 +164,7 @@ async def test_send_handler_creates_output_scope_for_messages(): @pytest.mark.asyncio async def test_send_handler_uses_parent_span_from_turn_state(): - """Send handler should pass parent_id from turn_state to OutputScope.""" + """Send handler should pass parent_context from turn_state to OutputScope.""" middleware = OutputLoggingMiddleware() ctx = _make_turn_context() @@ -188,7 +188,9 @@ async def test_send_handler_uses_parent_span_from_turn_state(): await handler(ctx, activities, send_next) call_kwargs = mock_output_scope_cls.start.call_args - assert call_kwargs.kwargs["parent_id"] == parent_id + # parent_context should be set (extracted from traceparent header) + assert "parent_context" in call_kwargs.kwargs + assert call_kwargs.kwargs["parent_context"] is not None @pytest.mark.asyncio From 535ce33d513df2bf47a44666c7bd34b517d6b2e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:12:12 +0000 Subject: [PATCH 3/7] refactor: Address code review feedback - move imports to module level Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> --- .../observability/core/utils.py | 3 +-- .../core/test_trace_context_propagation.py | 14 ++++---------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py index b8c91a85..e1d231de 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py @@ -15,6 +15,7 @@ from typing import Any, Generic, TypeVar, cast from opentelemetry import context +from opentelemetry.propagate import extract from opentelemetry.semconv.attributes.exception_attributes import ( EXCEPTION_MESSAGE, EXCEPTION_STACKTRACE, @@ -50,8 +51,6 @@ def extract_trace_context(headers: dict[str, str]) -> context.Context: >>> with InferenceScope.start(details, agent, tenant, parent_context=parent_context): ... pass """ - from opentelemetry.propagate import extract - return extract(headers) diff --git a/tests/observability/core/test_trace_context_propagation.py b/tests/observability/core/test_trace_context_propagation.py index c6f2568b..057e5d74 100644 --- a/tests/observability/core/test_trace_context_propagation.py +++ b/tests/observability/core/test_trace_context_propagation.py @@ -4,9 +4,8 @@ """Tests for trace context propagation functionality.""" import os -import sys import unittest -from pathlib import Path +from urllib.parse import urlparse import pytest from microsoft_agents_a365.observability.core import ( @@ -212,12 +211,6 @@ def test_inject_headers_for_http_propagation(self): headers = scope.inject_trace_context() scope.dispose() - # Parse the traceparent to get trace_id and span_id - traceparent = headers["traceparent"] - parts = traceparent.split("-") - trace_id = parts[1] - span_id = parts[2] - # Simulate receiving these headers and creating a child scope parent_ctx = extract_trace_context(headers) @@ -249,8 +242,6 @@ def test_inject_headers_for_http_propagation(self): def test_invoke_agent_scope_with_parent_context(self): """Test InvokeAgentScope with parent_context parameter.""" - from urllib.parse import urlparse - parent_trace_id = "1234567890abcdef1234567890abcdef" parent_span_id = "abcdefabcdef1234" traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" @@ -285,4 +276,7 @@ def test_invoke_agent_scope_with_parent_context(self): if __name__ == "__main__": + import sys + from pathlib import Path + sys.exit(pytest.main([str(Path(__file__))] + sys.argv[1:])) From 3ec84ffc5313fd466112d22a67ec4e2772f24b51 Mon Sep 17 00:00:00 2001 From: Nikhil Navakiran Date: Wed, 18 Mar 2026 22:57:38 +0530 Subject: [PATCH 4/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../observability/core/opentelemetry_scope.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py index f4ed1dce..5aa1e7eb 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py @@ -315,12 +315,14 @@ def inject_trace_context(self) -> dict[str, str]: - ``traceparent``: Contains version, trace-id, parent-id, and trace-flags - ``tracestate``: Contains vendor-specific trace information (if any) - Example usage:: + Example usage: - >>> scope = OpenTelemetryScope(...) - >>> headers = scope.inject_trace_context() - >>> # Add headers to outgoing HTTP request - >>> requests.get("https://downstream-service/api", headers=headers) + .. code-block:: python + + scope = OpenTelemetryScope(...) + headers = scope.inject_trace_context() + # Add headers to outgoing HTTP request + requests.get("https://downstream-service/api", headers=headers) Returns: A dictionary containing W3C trace context headers. Returns an From 6fd7a56c08f86c377d9a4938ce7713e2f3cf11b4 Mon Sep 17 00:00:00 2001 From: Nikhil Navakiran Date: Wed, 18 Mar 2026 22:58:04 +0530 Subject: [PATCH 5/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../observability/core/utils.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py index e1d231de..251078e7 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py @@ -46,10 +46,16 @@ def extract_trace_context(headers: dict[str, str]) -> context.Context: Example:: - >>> headers = {"traceparent": "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01"} - >>> parent_context = extract_trace_context(headers) - >>> with InferenceScope.start(details, agent, tenant, parent_context=parent_context): - ... pass + .. code-block:: python + + headers = { + "traceparent": "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01" + } + parent_context = extract_trace_context(headers) + with InferenceScope.start( + details, agent, tenant, parent_context=parent_context + ): + pass """ return extract(headers) From 192a7dc4b4f9634a5af852d9a0d1f8f081cb287d Mon Sep 17 00:00:00 2001 From: Nikhil Navakiran Date: Wed, 18 Mar 2026 22:58:28 +0530 Subject: [PATCH 6/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../hosting/middleware/output_logging_middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py index 3230614f..9380e330 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py @@ -32,8 +32,8 @@ logger = logging.getLogger(__name__) +# TurnState key for the parent span reference (W3C traceparent string). A365_PARENT_SPAN_KEY = "A365ParentSpanId" -"""TurnState key for the parent span reference (W3C traceparent string).""" def _derive_agent_details(context: TurnContext) -> AgentDetails | None: From 74f2a8524c84ea44f299801fd6cc97088c5898cb Mon Sep 17 00:00:00 2001 From: "Nikhil Chitlur Navakiran (from Dev Box)" Date: Wed, 18 Mar 2026 23:42:17 +0530 Subject: [PATCH 7/7] clean up usage of preant context propogation --- .../observability/core/__init__.py | 5 +-- .../observability/core/execute_tool_scope.py | 4 +-- .../observability/core/inference_scope.py | 4 +-- .../observability/core/invoke_agent_scope.py | 4 +-- .../observability/core/opentelemetry_scope.py | 19 ++++------- .../core/spans_scopes/output_scope.py | 4 +-- .../observability/core/utils.py | 34 ++++++++++++++++--- .../observability/hosting/__init__.py | 7 ++-- .../hosting/middleware/__init__.py | 4 +-- .../middleware/output_logging_middleware.py | 19 +++++------ .../core/test_execute_tool_scope.py | 9 +++-- .../core/test_inference_scope.py | 4 +-- tests/observability/core/test_output_scope.py | 4 +-- .../core/test_trace_context_propagation.py | 28 +++++++-------- .../test_output_logging_middleware.py | 6 ++-- 15 files changed, 89 insertions(+), 66 deletions(-) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py index b630e52c..036f60e1 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/__init__.py @@ -33,7 +33,7 @@ from .tool_call_details import ToolCallDetails from .tool_type import ToolType from .trace_processor.span_processor import SpanProcessor -from .utils import extract_trace_context +from .utils import extract_context_from_headers, get_traceparent __all__ = [ # Main SDK functions @@ -73,7 +73,8 @@ "InferenceOperationType", "ToolType", # Utility functions - "extract_trace_context", + "extract_context_from_headers", + "get_traceparent", # Constants # all constants from constants.py are exported via * ] diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py index 9da6a024..073fe9c8 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/execute_tool_scope.py @@ -47,7 +47,7 @@ def start( tenant_details: The details of the tenant request: Optional request details for additional context parent_context: Optional OpenTelemetry Context used to link this span to an - upstream operation. Use ``extract_trace_context()`` to convert a + upstream operation. Use ``extract_context_from_headers()`` to convert a Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. Useful when recording a tool call after execution has already completed. @@ -90,7 +90,7 @@ def __init__( tenant_details: The details of the tenant request: Optional request details for additional context parent_context: Optional OpenTelemetry Context used to link this span to an - upstream operation. Use ``extract_trace_context()`` to convert a + upstream operation. Use ``extract_context_from_headers()`` to convert a Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. Useful when recording a tool call after execution has already completed. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py index 47337674..7015253b 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/inference_scope.py @@ -50,7 +50,7 @@ def start( tenant_details: The details of the tenant request: Optional request details for additional context parent_context: Optional OpenTelemetry Context used to link this span to an - upstream operation. Use ``extract_trace_context()`` to convert a + upstream operation. Use ``extract_context_from_headers()`` to convert a Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. @@ -80,7 +80,7 @@ def __init__( tenant_details: The details of the tenant request: Optional request details for additional context parent_context: Optional OpenTelemetry Context used to link this span to an - upstream operation. Use ``extract_trace_context()`` to convert a + upstream operation. Use ``extract_context_from_headers()`` to convert a Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py index 2adb013e..0a005f03 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py @@ -66,7 +66,7 @@ def start( caller_agent_details: Optional details of the caller agent caller_details: Optional details of the non-agentic caller parent_context: Optional OpenTelemetry Context used to link this span to an - upstream operation. Use ``extract_trace_context()`` to convert a + upstream operation. Use ``extract_context_from_headers()`` to convert a Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. @@ -109,7 +109,7 @@ def __init__( caller_agent_details: Optional details of the caller agent caller_details: Optional details of the non-agentic caller parent_context: Optional OpenTelemetry Context used to link this span to an - upstream operation. Use ``extract_trace_context()`` to convert a + upstream operation. Use ``extract_context_from_headers()`` to convert a Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py index 5aa1e7eb..2fc6b6f2 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py @@ -114,7 +114,7 @@ def __init__( agent_details: Optional agent details tenant_details: Optional tenant details parent_context: Optional OpenTelemetry Context used to link this span to an - upstream operation. Use ``extract_trace_context()`` to extract a + upstream operation. Use ``extract_context_from_headers()`` to extract a Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. Useful when recording an operation after it has already completed. @@ -303,24 +303,19 @@ def get_context(self) -> Context | None: return set_span_in_context(self._span) return None - def inject_trace_context(self) -> dict[str, str]: - """Inject trace context headers for distributed tracing propagation. + def inject_context_to_headers(self) -> dict[str, str]: + """Inject this span's trace context into W3C HTTP headers. - This method returns a dictionary of headers containing the trace - context (traceparent and tracestate) that can be used to propagate - the current span's context to downstream services via HTTP headers - or other transport mechanisms. - - The headers follow the W3C Trace Context specification and include: - - ``traceparent``: Contains version, trace-id, parent-id, and trace-flags - - ``tracestate``: Contains vendor-specific trace information (if any) + Returns a dictionary of headers containing ``traceparent`` and + optionally ``tracestate`` that can be forwarded to downstream services + or stored for later context propagation. Example usage: .. code-block:: python scope = OpenTelemetryScope(...) - headers = scope.inject_trace_context() + headers = scope.inject_context_to_headers() # Add headers to outgoing HTTP request requests.get("https://downstream-service/api", headers=headers) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py index 1b29c732..06689967 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py @@ -36,7 +36,7 @@ def start( tenant_details: The details of the tenant response: The response details from the agent parent_context: Optional OpenTelemetry Context used to link this span to an - upstream operation. Use ``extract_trace_context()`` to convert a + upstream operation. Use ``extract_context_from_headers()`` to convert a Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. @@ -64,7 +64,7 @@ def __init__( tenant_details: The details of the tenant response: The response details from the agent parent_context: Optional OpenTelemetry Context used to link this span to an - upstream operation. Use ``extract_trace_context()`` to convert a + upstream operation. Use ``extract_context_from_headers()`` to convert a Context from HTTP headers containing W3C traceparent. start_time: Optional explicit start time as a datetime object. end_time: Optional explicit end time as a datetime object. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py index 251078e7..4e824a3e 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py @@ -30,11 +30,12 @@ logger.addHandler(logging.NullHandler()) -def extract_trace_context(headers: dict[str, str]) -> context.Context: - """Extract trace context from HTTP headers. +def extract_context_from_headers(headers: dict[str, str]) -> context.Context: + """Extract an OpenTelemetry Context from W3C trace HTTP headers. - This function extracts W3C Trace Context from a dictionary of HTTP headers - using OpenTelemetry's standard propagation mechanism. + Parses ``traceparent`` (and optionally ``tracestate``) headers and returns + an OpenTelemetry Context that can be passed as ``parent_context`` to any + scope's ``start()`` method. Args: headers: Dictionary of HTTP headers containing trace context. @@ -51,7 +52,7 @@ def extract_trace_context(headers: dict[str, str]) -> context.Context: headers = { "traceparent": "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01" } - parent_context = extract_trace_context(headers) + parent_context = extract_context_from_headers(headers) with InferenceScope.start( details, agent, tenant, parent_context=parent_context ): @@ -60,6 +61,29 @@ def extract_trace_context(headers: dict[str, str]) -> context.Context: return extract(headers) +def get_traceparent(headers: dict[str, str]) -> str | None: + """Return the W3C ``traceparent`` value from a headers dictionary. + + Args: + headers: Dictionary of HTTP headers, typically obtained from + :meth:`OpenTelemetryScope.inject_context_to_headers`. + + Returns: + The traceparent string (e.g. + ``"00---"``), or ``None`` if the + key is not present. + + Example:: + + .. code-block:: python + + # Extract traceparent from incoming HTTP request headers + traceparent = get_traceparent(request.headers) + turn_context.turn_state[A365_PARENT_TRACEPARENT_KEY] = traceparent + """ + return headers.get("traceparent") + + def safe_json_dumps(obj: Any, **kwargs: Any) -> str: return json.dumps(obj, default=str, ensure_ascii=False, **kwargs) diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py index 476be985..6b41ec88 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/__init__.py @@ -10,12 +10,15 @@ ObservabilityHostingManager, ObservabilityHostingOptions, ) -from .middleware.output_logging_middleware import A365_PARENT_SPAN_KEY, OutputLoggingMiddleware +from .middleware.output_logging_middleware import ( + A365_PARENT_TRACEPARENT_KEY, + OutputLoggingMiddleware, +) __all__ = [ "BaggageMiddleware", "OutputLoggingMiddleware", - "A365_PARENT_SPAN_KEY", + "A365_PARENT_TRACEPARENT_KEY", "ObservabilityHostingManager", "ObservabilityHostingOptions", ] diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/__init__.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/__init__.py index f556fc0f..7459b245 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/__init__.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/__init__.py @@ -3,12 +3,12 @@ from .baggage_middleware import BaggageMiddleware from .observability_hosting_manager import ObservabilityHostingManager, ObservabilityHostingOptions -from .output_logging_middleware import A365_PARENT_SPAN_KEY, OutputLoggingMiddleware +from .output_logging_middleware import A365_PARENT_TRACEPARENT_KEY, OutputLoggingMiddleware __all__ = [ "BaggageMiddleware", "OutputLoggingMiddleware", - "A365_PARENT_SPAN_KEY", + "A365_PARENT_TRACEPARENT_KEY", "ObservabilityHostingManager", "ObservabilityHostingOptions", ] diff --git a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py index 9380e330..4e58d0c1 100644 --- a/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py +++ b/libraries/microsoft-agents-a365-observability-hosting/microsoft_agents_a365/observability/hosting/middleware/output_logging_middleware.py @@ -24,7 +24,7 @@ from microsoft_agents_a365.observability.core.models.response import Response from microsoft_agents_a365.observability.core.spans_scopes.output_scope import OutputScope from microsoft_agents_a365.observability.core.tenant_details import TenantDetails -from microsoft_agents_a365.observability.core.utils import extract_trace_context +from microsoft_agents_a365.observability.core.utils import extract_context_from_headers from ..scope_helpers.utils import ( get_execution_type_pair, @@ -32,8 +32,8 @@ logger = logging.getLogger(__name__) -# TurnState key for the parent span reference (W3C traceparent string). -A365_PARENT_SPAN_KEY = "A365ParentSpanId" +# TurnState key for the parent trace context (W3C traceparent string). +A365_PARENT_TRACEPARENT_KEY = "A365ParentTraceparent" def _derive_agent_details(context: TurnContext) -> AgentDetails | None: @@ -110,7 +110,7 @@ def _derive_execution_type(context: TurnContext) -> str | None: class OutputLoggingMiddleware: """Middleware that creates :class:`OutputScope` spans for outgoing messages. - Links to a parent span when :data:`A365_PARENT_SPAN_KEY` is set in + Links to a parent span when :data:`A365_PARENT_TRACEPARENT_KEY` is set in ``turn_state``. **Privacy note:** Outgoing message content is captured verbatim as span @@ -176,16 +176,15 @@ async def handler( await send_next() return - parent_id: str | None = turn_context.turn_state.get(A365_PARENT_SPAN_KEY) + traceparent: str | None = turn_context.turn_state.get(A365_PARENT_TRACEPARENT_KEY) parent_context = None - if parent_id: - # Convert W3C traceparent string to Context using OpenTelemetry's extract - parent_context = extract_trace_context({"traceparent": parent_id}) + if traceparent: + parent_context = extract_context_from_headers({"traceparent": traceparent}) else: logger.warning( - "[OutputLoggingMiddleware] No parent span ref in turn_state under " + "[OutputLoggingMiddleware] No traceparent in turn_state under " "'%s'. OutputScope will not be linked to a parent.", - A365_PARENT_SPAN_KEY, + A365_PARENT_TRACEPARENT_KEY, ) output_scope = OutputScope.start( diff --git a/tests/observability/core/test_execute_tool_scope.py b/tests/observability/core/test_execute_tool_scope.py index 4279467c..20744252 100644 --- a/tests/observability/core/test_execute_tool_scope.py +++ b/tests/observability/core/test_execute_tool_scope.py @@ -16,7 +16,7 @@ TenantDetails, ToolCallDetails, configure, - extract_trace_context, + extract_context_from_headers, get_tracer_provider, ) from microsoft_agents_a365.observability.core.config import _telemetry_manager @@ -140,10 +140,13 @@ def test_execute_tool_scope_with_parent_context(self): traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" # Extract context from traceparent header - parent_context = extract_trace_context({"traceparent": traceparent}) + parent_context = extract_context_from_headers({"traceparent": traceparent}) with ExecuteToolScope.start( - self.tool_details, self.agent_details, self.tenant_details, parent_context=parent_context + self.tool_details, + self.agent_details, + self.tenant_details, + parent_context=parent_context, ): pass diff --git a/tests/observability/core/test_inference_scope.py b/tests/observability/core/test_inference_scope.py index b43b92c8..f67b3785 100644 --- a/tests/observability/core/test_inference_scope.py +++ b/tests/observability/core/test_inference_scope.py @@ -16,7 +16,7 @@ SourceMetadata, TenantDetails, configure, - extract_trace_context, + extract_context_from_headers, get_tracer_provider, ) from microsoft_agents_a365.observability.core.agent_details import AgentDetails @@ -351,7 +351,7 @@ def test_inference_scope_with_parent_context(self): traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" # Extract context from traceparent header - parent_context = extract_trace_context({"traceparent": traceparent}) + parent_context = extract_context_from_headers({"traceparent": traceparent}) with InferenceScope.start( details, self.agent_details, self.tenant_details, parent_context=parent_context diff --git a/tests/observability/core/test_output_scope.py b/tests/observability/core/test_output_scope.py index 103ffa11..64a519c2 100644 --- a/tests/observability/core/test_output_scope.py +++ b/tests/observability/core/test_output_scope.py @@ -11,7 +11,7 @@ AgentDetails, TenantDetails, configure, - extract_trace_context, + extract_context_from_headers, get_tracer_provider, ) from microsoft_agents_a365.observability.core.config import _telemetry_manager @@ -116,7 +116,7 @@ def test_output_scope_with_parent_context(self): traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" # Extract context from traceparent header - parent_context = extract_trace_context({"traceparent": traceparent}) + parent_context = extract_context_from_headers({"traceparent": traceparent}) with OutputScope.start( self.agent_details, self.tenant_details, response, parent_context=parent_context diff --git a/tests/observability/core/test_trace_context_propagation.py b/tests/observability/core/test_trace_context_propagation.py index 057e5d74..eb17dda8 100644 --- a/tests/observability/core/test_trace_context_propagation.py +++ b/tests/observability/core/test_trace_context_propagation.py @@ -19,7 +19,7 @@ TenantDetails, ToolCallDetails, configure, - extract_trace_context, + extract_context_from_headers, get_tracer_provider, ) from microsoft_agents_a365.observability.core.config import _telemetry_manager @@ -68,8 +68,8 @@ def tearDown(self): super().tearDown() self.span_exporter.clear() - def test_inject_trace_context_returns_headers(self): - """Test that inject_trace_context returns traceparent and tracestate headers.""" + def test_inject_context_to_headers_returns_headers(self): + """Test that inject_context_to_headers returns traceparent and tracestate headers.""" details = InferenceCallDetails( operationName=InferenceOperationType.CHAT, model="gpt-4", @@ -79,7 +79,7 @@ def test_inject_trace_context_returns_headers(self): scope = InferenceScope.start(details, self.agent_details, self.tenant_details) if scope is not None: - headers = scope.inject_trace_context() + headers = scope.inject_context_to_headers() # Should contain at least traceparent header self.assertIn("traceparent", headers) @@ -95,7 +95,7 @@ def test_inject_trace_context_returns_headers(self): scope.dispose() - def test_inject_trace_context_contains_span_id(self): + def test_inject_context_to_headers_contains_span_id(self): """Test that injected headers contain the correct span ID.""" details = InferenceCallDetails( operationName=InferenceOperationType.CHAT, @@ -106,7 +106,7 @@ def test_inject_trace_context_contains_span_id(self): scope = InferenceScope.start(details, self.agent_details, self.tenant_details) if scope is not None: - headers = scope.inject_trace_context() + headers = scope.inject_context_to_headers() scope.dispose() # Get the span from exported spans @@ -148,16 +148,14 @@ def test_context_propagation_via_inject_extract(self): providerName="openai", ) - parent_scope = InferenceScope.start( - parent_details, self.agent_details, self.tenant_details - ) + parent_scope = InferenceScope.start(parent_details, self.agent_details, self.tenant_details) # Get injected headers from parent - headers = parent_scope.inject_trace_context() + headers = parent_scope.inject_context_to_headers() parent_scope.dispose() # Extract context from headers (simulating receiving via HTTP) - parent_context = extract_trace_context(headers) + parent_context = extract_context_from_headers(headers) # Create child scope using extracted context tool_details = ToolCallDetails( @@ -208,11 +206,11 @@ def test_inject_headers_for_http_propagation(self): scope = InferenceScope.start(details, self.agent_details, self.tenant_details) if scope is not None: - headers = scope.inject_trace_context() + headers = scope.inject_context_to_headers() scope.dispose() # Simulate receiving these headers and creating a child scope - parent_ctx = extract_trace_context(headers) + parent_ctx = extract_context_from_headers(headers) # Create new scope using received context child_details = InferenceCallDetails( @@ -246,7 +244,7 @@ def test_invoke_agent_scope_with_parent_context(self): parent_span_id = "abcdefabcdef1234" traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" - parent_context = extract_trace_context({"traceparent": traceparent}) + parent_context = extract_context_from_headers({"traceparent": traceparent}) invoke_details = InvokeAgentDetails( endpoint=urlparse("https://example.com/agent"), @@ -257,7 +255,7 @@ def test_invoke_agent_scope_with_parent_context(self): with InvokeAgentScope.start( invoke_details, self.tenant_details, parent_context=parent_context ) as scope: - headers = scope.inject_trace_context() + headers = scope.inject_context_to_headers() self.assertIn("traceparent", headers) finished_spans = self.span_exporter.get_finished_spans() diff --git a/tests/observability/hosting/middleware/test_output_logging_middleware.py b/tests/observability/hosting/middleware/test_output_logging_middleware.py index 31015d09..3ec3749f 100644 --- a/tests/observability/hosting/middleware/test_output_logging_middleware.py +++ b/tests/observability/hosting/middleware/test_output_logging_middleware.py @@ -12,7 +12,7 @@ ) from microsoft_agents.hosting.core import TurnContext from microsoft_agents_a365.observability.hosting.middleware.output_logging_middleware import ( - A365_PARENT_SPAN_KEY, + A365_PARENT_TRACEPARENT_KEY, OutputLoggingMiddleware, ) @@ -168,8 +168,8 @@ async def test_send_handler_uses_parent_span_from_turn_state(): middleware = OutputLoggingMiddleware() ctx = _make_turn_context() - parent_id = "00-1af7651916cd43dd8448eb211c80319c-c7ad6b7169203331-01" - ctx.turn_state[A365_PARENT_SPAN_KEY] = parent_id + traceparent = "00-1af7651916cd43dd8448eb211c80319c-c7ad6b7169203331-01" + ctx.turn_state[A365_PARENT_TRACEPARENT_KEY] = traceparent await middleware.on_turn(ctx, AsyncMock())