Skip to content

Commit eaa2136

Browse files
CopilotnikhilNavanikhilc-microsoft
authored
Add OutputScope for output message tracing with parent span linking (#164)
* Initial plan * Add OutputScope for output message tracing - Add Response model class for agent execution response details - Add spans_scopes directory with OutputScope implementation - Add unit tests for OutputScope functionality Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> * Improve Response.messages field documentation Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> * Add parent_id parameter to OutputScope Similar to the .NET ExecuteToolScope, the OutputScope now accepts an optional parent_id parameter to link spans to upstream operations. Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> * Add parent_id to base OpenTelemetryScope class Similar to the .NET implementation, the base OpenTelemetryScope class now accepts an optional parent_id parameter that sets the CUSTOM_PARENT_SPAN_ID_KEY attribute on the span. This allows all scope subclasses to support parent span linking consistently. Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> * Make record_output_messages append messages instead of replacing Changed the behavior of record_output_messages to append new messages to the accumulated list rather than replacing all messages. This allows collecting output messages over multiple calls during the scope's lifetime. Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> * Fix parent_id to set on span context instead of span attribute Changed the parent_id implementation to properly link spans by parsing the W3C Trace Context format parent ID and using it to create a proper span context. This aligns with the .NET implementation which calls `activity?.SetParentId(parentId!)`. Added validation for W3C Trace Context version and trace_id/span_id lengths. Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> * Move parse_parent_id_to_context to utils.py Refactored W3C Trace Context parsing: - Added W3C Trace Context validation constants - Added validate_w3c_trace_context_version() helper - Added validate_trace_id() helper with hex validation - Added validate_span_id() helper with hex validation - Moved parse_parent_id_to_context() from opentelemetry_scope.py Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> * Clean up test_output_scope.py - remove redundant tests and aggregate similar ones Reduced from 10 tests to 4 focused tests: - test_output_scope_creates_span_with_messages (merged span name and messages tests) - test_record_output_messages_appends (merged multiple append tests) - test_output_scope_with_parent_id (parent linking) - test_output_scope_dispose (manual dispose) All tests use real spans via InMemorySpanExporter (no mocks). Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> * address PR comments --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> Co-authored-by: Nikhil Navakiran <nikhil.navakiran@gmail.com> Co-authored-by: Nikhil Chitlur Navakiran (from Dev Box) <nikhilc@microsoft.com>
1 parent 358a1dd commit eaa2136

File tree

6 files changed

+382
-7
lines changed

6 files changed

+382
-7
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from dataclasses import dataclass
5+
6+
7+
@dataclass
8+
class Response:
9+
"""Response details from agent execution."""
10+
11+
"""The list of response messages from the agent."""
12+
messages: list[str]

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/opentelemetry_scope.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@
1010
from typing import TYPE_CHECKING, Any
1111

1212
from opentelemetry import baggage, context, trace
13-
from opentelemetry.trace import Span, SpanKind, Status, StatusCode, Tracer, set_span_in_context
13+
from opentelemetry.trace import (
14+
Span,
15+
SpanKind,
16+
Status,
17+
StatusCode,
18+
Tracer,
19+
set_span_in_context,
20+
)
1421

1522
from .constants import (
1623
ENABLE_A365_OBSERVABILITY,
@@ -32,6 +39,7 @@
3239
SOURCE_NAME,
3340
TENANT_ID_KEY,
3441
)
42+
from .utils import parse_parent_id_to_context
3543

3644
if TYPE_CHECKING:
3745
from .agent_details import AgentDetails
@@ -71,6 +79,7 @@ def __init__(
7179
activity_name: str,
7280
agent_details: "AgentDetails | None" = None,
7381
tenant_details: "TenantDetails | None" = None,
82+
parent_id: str | None = None,
7483
):
7584
"""Initialize the OpenTelemetry scope.
7685
@@ -80,6 +89,8 @@ def __init__(
8089
activity_name: The name of the activity for display purposes
8190
agent_details: Optional agent details
8291
tenant_details: Optional tenant details
92+
parent_id: Optional parent Activity ID used to link this span to an upstream
93+
operation
8394
"""
8495
self._span: Span | None = None
8596
self._start_time = time.time()
@@ -102,12 +113,13 @@ def __init__(
102113
elif kind.lower() == "consumer":
103114
activity_kind = SpanKind.CONSUMER
104115

105-
# Get current context for parent relationship
106-
current_context = context.get_current()
116+
# Get context for parent relationship
117+
# If parent_id is provided, parse it and use it as the parent context
118+
# Otherwise, use the current context
119+
parent_context = parse_parent_id_to_context(parent_id)
120+
span_context = parent_context if parent_context else context.get_current()
107121

108-
self._span = tracer.start_span(
109-
activity_name, kind=activity_kind, context=current_context
110-
)
122+
self._span = tracer.start_span(activity_name, kind=activity_kind, context=span_context)
111123

112124
# Log span creation
113125
if self._span:
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
from ..agent_details import AgentDetails
5+
from ..constants import GEN_AI_OUTPUT_MESSAGES_KEY
6+
from ..models.response import Response
7+
from ..opentelemetry_scope import OpenTelemetryScope
8+
from ..tenant_details import TenantDetails
9+
from ..utils import safe_json_dumps
10+
11+
OUTPUT_OPERATION_NAME = "output_messages"
12+
13+
14+
class OutputScope(OpenTelemetryScope):
15+
"""Provides OpenTelemetry tracing scope for output messages."""
16+
17+
@staticmethod
18+
def start(
19+
agent_details: AgentDetails,
20+
tenant_details: TenantDetails,
21+
response: Response,
22+
parent_id: str | None = None,
23+
) -> "OutputScope":
24+
"""Creates and starts a new scope for output tracing.
25+
26+
Args:
27+
agent_details: The details of the agent
28+
tenant_details: The details of the tenant
29+
response: The response details from the agent
30+
parent_id: Optional parent Activity ID used to link this span to an upstream
31+
operation
32+
33+
Returns:
34+
A new OutputScope instance
35+
"""
36+
return OutputScope(agent_details, tenant_details, response, parent_id)
37+
38+
def __init__(
39+
self,
40+
agent_details: AgentDetails,
41+
tenant_details: TenantDetails,
42+
response: Response,
43+
parent_id: str | None = None,
44+
):
45+
"""Initialize the output scope.
46+
47+
Args:
48+
agent_details: The details of the agent
49+
tenant_details: The details of the tenant
50+
response: The response details from the agent
51+
parent_id: Optional parent Activity ID used to link this span to an upstream
52+
operation
53+
"""
54+
super().__init__(
55+
kind="Client",
56+
operation_name=OUTPUT_OPERATION_NAME,
57+
activity_name=(f"{OUTPUT_OPERATION_NAME} {agent_details.agent_id}"),
58+
agent_details=agent_details,
59+
tenant_details=tenant_details,
60+
parent_id=parent_id,
61+
)
62+
63+
# Initialize accumulated messages list
64+
self._output_messages: list[str] = list(response.messages)
65+
66+
# Set response messages
67+
self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(self._output_messages))
68+
69+
def record_output_messages(self, messages: list[str]) -> None:
70+
"""Records the output messages for telemetry tracking.
71+
72+
Appends the provided messages to the accumulated output messages list.
73+
74+
Args:
75+
messages: List of output messages to append
76+
"""
77+
self._output_messages.extend(messages)
78+
self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(self._output_messages))

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@
1313
from threading import RLock
1414
from typing import Any, Generic, TypeVar, cast
1515

16+
from opentelemetry import context
1617
from opentelemetry.semconv.attributes.exception_attributes import (
1718
EXCEPTION_MESSAGE,
1819
EXCEPTION_STACKTRACE,
1920
)
20-
from opentelemetry.trace import Span
21+
from opentelemetry.trace import NonRecordingSpan, Span, SpanContext, TraceFlags, set_span_in_context
2122
from opentelemetry.util.types import AttributeValue
2223
from wrapt import ObjectProxy
2324

@@ -27,6 +28,128 @@
2728
logger.addHandler(logging.NullHandler())
2829

2930

31+
# W3C Trace Context constants
32+
W3C_TRACE_CONTEXT_VERSION = "00"
33+
W3C_TRACE_ID_LENGTH = 32 # 32 hex chars = 128 bits
34+
W3C_SPAN_ID_LENGTH = 16 # 16 hex chars = 64 bits
35+
36+
37+
def validate_w3c_trace_context_version(version: str) -> bool:
38+
"""Validate W3C Trace Context version.
39+
40+
Args:
41+
version: The version string to validate
42+
43+
Returns:
44+
True if valid, False otherwise
45+
"""
46+
return version == W3C_TRACE_CONTEXT_VERSION
47+
48+
49+
def _is_valid_hex(hex_string: str) -> bool:
50+
"""Check if a string contains only valid hexadecimal characters.
51+
52+
Args:
53+
hex_string: The string to validate
54+
55+
Returns:
56+
True if all characters are valid hexadecimal (0-9, a-f, A-F), False otherwise
57+
"""
58+
return all(c in "0123456789abcdefABCDEF" for c in hex_string)
59+
60+
61+
def validate_trace_id(trace_id_hex: str) -> bool:
62+
"""Validate W3C Trace Context trace_id format.
63+
64+
Args:
65+
trace_id_hex: The trace_id hex string to validate (should be 32 hex chars)
66+
67+
Returns:
68+
True if valid (32 hex chars), False otherwise
69+
"""
70+
return len(trace_id_hex) == W3C_TRACE_ID_LENGTH and _is_valid_hex(trace_id_hex)
71+
72+
73+
def validate_span_id(span_id_hex: str) -> bool:
74+
"""Validate W3C Trace Context span_id format.
75+
76+
Args:
77+
span_id_hex: The span_id hex string to validate (should be 16 hex chars)
78+
79+
Returns:
80+
True if valid (16 hex chars), False otherwise
81+
"""
82+
return len(span_id_hex) == W3C_SPAN_ID_LENGTH and _is_valid_hex(span_id_hex)
83+
84+
85+
def parse_parent_id_to_context(parent_id: str | None) -> context.Context | None:
86+
"""Parse a W3C trace context parent ID and return a context with the parent span.
87+
88+
The parent_id format is expected to be W3C Trace Context format:
89+
"00-{trace_id}-{span_id}-{trace_flags}"
90+
Example: "00-1234567890abcdef1234567890abcdef-abcdefabcdef1234-01"
91+
92+
Args:
93+
parent_id: The W3C Trace Context format parent ID string
94+
95+
Returns:
96+
A context containing the parent span, or None if parent_id is invalid
97+
"""
98+
if not parent_id:
99+
return None
100+
101+
try:
102+
# W3C Trace Context format: "00-{trace_id}-{span_id}-{trace_flags}"
103+
parts = parent_id.split("-")
104+
if len(parts) != 4:
105+
logger.warning(f"Invalid parent_id format (expected 4 parts): {parent_id}")
106+
return None
107+
108+
version, trace_id_hex, span_id_hex, trace_flags_hex = parts
109+
110+
# Validate W3C Trace Context version
111+
if not validate_w3c_trace_context_version(version):
112+
logger.warning(f"Unsupported W3C Trace Context version: {version}")
113+
return None
114+
115+
# Validate trace_id (must be 32 hex chars)
116+
if not validate_trace_id(trace_id_hex):
117+
logger.warning(
118+
f"Invalid trace_id (expected {W3C_TRACE_ID_LENGTH} hex chars): '{trace_id_hex}'"
119+
)
120+
return None
121+
122+
# Validate span_id (must be 16 hex chars)
123+
if not validate_span_id(span_id_hex):
124+
logger.warning(
125+
f"Invalid span_id (expected {W3C_SPAN_ID_LENGTH} hex chars): '{span_id_hex}'"
126+
)
127+
return None
128+
129+
# Parse the hex values
130+
trace_id = int(trace_id_hex, 16)
131+
span_id = int(span_id_hex, 16)
132+
trace_flags = TraceFlags(int(trace_flags_hex, 16))
133+
134+
# Create a SpanContext from the parsed values
135+
parent_span_context = SpanContext(
136+
trace_id=trace_id,
137+
span_id=span_id,
138+
is_remote=True,
139+
trace_flags=trace_flags,
140+
)
141+
142+
# Create a NonRecordingSpan with the parent context
143+
parent_span = NonRecordingSpan(parent_span_context)
144+
145+
# Create a context with the parent span
146+
return set_span_in_context(parent_span)
147+
148+
except (ValueError, IndexError) as e:
149+
logger.warning(f"Failed to parse parent_id '{parent_id}': {e}")
150+
return None
151+
152+
30153
def safe_json_dumps(obj: Any, **kwargs: Any) -> str:
31154
return json.dumps(obj, default=str, ensure_ascii=False, **kwargs)
32155

0 commit comments

Comments
 (0)