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..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,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_context_from_headers, get_traceparent __all__ = [ # Main SDK functions @@ -71,6 +72,9 @@ "ExecutionType", "InferenceOperationType", "ToolType", + # Utility functions + "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 32ecc977..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 @@ -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_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. 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_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. 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..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 @@ -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_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. @@ -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_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. """ @@ -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..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 @@ -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_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. 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_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. 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..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 @@ -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_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. 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,49 @@ 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_context_to_headers(self) -> dict[str, str]: + """Inject this span's trace context into W3C HTTP headers. + + 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_context_to_headers() + # 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..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 @@ -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_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. 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_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. """ @@ -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..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 @@ -15,11 +15,12 @@ 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, ) -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 +30,58 @@ 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_context_from_headers(headers: dict[str, str]) -> context.Context: + """Extract an OpenTelemetry Context from W3C trace HTTP headers. - -def validate_w3c_trace_context_version(version: str) -> bool: - """Validate W3C Trace Context version. + 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: - version: The version string to validate + headers: Dictionary of HTTP headers containing trace context. + Expected keys include ``traceparent`` and optionally ``tracestate``. Returns: - True if valid, False otherwise - """ - return version == W3C_TRACE_CONTEXT_VERSION + An OpenTelemetry Context containing the extracted trace information. + If no valid trace context is found, returns an empty context. + Example:: -def _is_valid_hex(hex_string: str) -> bool: - """Check if a string contains only valid hexadecimal characters. - - Args: - hex_string: The string to validate + .. code-block:: python - Returns: - True if all characters are valid hexadecimal (0-9, a-f, A-F), False otherwise + headers = { + "traceparent": "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01" + } + parent_context = extract_context_from_headers(headers) + with InferenceScope.start( + details, agent, tenant, parent_context=parent_context + ): + pass """ - return all(c in "0123456789abcdefABCDEF" for c in hex_string) + return extract(headers) -def validate_trace_id(trace_id_hex: str) -> bool: - """Validate W3C Trace Context trace_id format. +def get_traceparent(headers: dict[str, str]) -> str | None: + """Return the W3C ``traceparent`` value from a headers dictionary. Args: - trace_id_hex: The trace_id hex string to validate (should be 32 hex chars) + headers: Dictionary of HTTP headers, typically obtained from + :meth:`OpenTelemetryScope.inject_context_to_headers`. 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) + The traceparent string (e.g. + ``"00---"``), or ``None`` if the + key is not present. + Example:: -def validate_span_id(span_id_hex: str) -> bool: - """Validate W3C Trace Context span_id format. - - Args: - span_id_hex: The span_id hex string to validate (should be 16 hex chars) + .. code-block:: python - Returns: - True if valid (16 hex chars), False otherwise + # Extract traceparent from incoming HTTP request headers + traceparent = get_traceparent(request.headers) + turn_context.turn_state[A365_PARENT_TRACEPARENT_KEY] = traceparent """ - return len(span_id_hex) == W3C_SPAN_ID_LENGTH and _is_valid_hex(span_id_hex) - - -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 headers.get("traceparent") def safe_json_dumps(obj: Any, **kwargs: Any) -> str: 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 d69070a2..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,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_context_from_headers from ..scope_helpers.utils import ( get_execution_type_pair, @@ -31,8 +32,8 @@ logger = logging.getLogger(__name__) -A365_PARENT_SPAN_KEY = "A365ParentSpanId" -"""TurnState key for the parent span reference.""" +# TurnState key for the parent trace context (W3C traceparent string). +A365_PARENT_TRACEPARENT_KEY = "A365ParentTraceparent" def _derive_agent_details(context: TurnContext) -> AgentDetails | None: @@ -109,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 @@ -175,19 +176,22 @@ async def handler( await send_next() return - parent_id: str | None = turn_context.turn_state.get(A365_PARENT_SPAN_KEY) - if not parent_id: + traceparent: str | None = turn_context.turn_state.get(A365_PARENT_TRACEPARENT_KEY) + parent_context = None + 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( 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..20744252 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_context_from_headers, get_tracer_provider, ) from microsoft_agents_a365.observability.core.config import _telemetry_manager @@ -132,14 +133,20 @@ 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_context_from_headers({"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..f67b3785 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_context_from_headers, 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_context_from_headers({"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..64a519c2 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_context_from_headers, 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_context_from_headers({"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..eb17dda8 --- /dev/null +++ b/tests/observability/core/test_trace_context_propagation.py @@ -0,0 +1,280 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Tests for trace context propagation functionality.""" + +import os +import unittest +from urllib.parse import urlparse + +import pytest +from microsoft_agents_a365.observability.core import ( + AgentDetails, + ExecuteToolScope, + InferenceCallDetails, + InferenceOperationType, + InferenceScope, + InvokeAgentDetails, + InvokeAgentScope, + TenantDetails, + ToolCallDetails, + configure, + extract_context_from_headers, + 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_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", + providerName="openai", + ) + + scope = InferenceScope.start(details, self.agent_details, self.tenant_details) + + if scope is not None: + headers = scope.inject_context_to_headers() + + # 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_context_to_headers_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_context_to_headers() + 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_context_to_headers() + parent_scope.dispose() + + # Extract context from headers (simulating receiving via HTTP) + parent_context = extract_context_from_headers(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_context_to_headers() + scope.dispose() + + # Simulate receiving these headers and creating a child scope + parent_ctx = extract_context_from_headers(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.""" + parent_trace_id = "1234567890abcdef1234567890abcdef" + parent_span_id = "abcdefabcdef1234" + traceparent = f"00-{parent_trace_id}-{parent_span_id}-01" + + parent_context = extract_context_from_headers({"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_context_to_headers() + 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__": + import sys + from pathlib import Path + + 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..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, ) @@ -164,12 +164,12 @@ 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() - 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()) @@ -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