-
Notifications
You must be signed in to change notification settings - Fork 10
Add OutputScope for output message tracing with parent span linking #164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
765af45
482cb38
53bd58d
9ddca17
2274828
f1e7c5d
f902aad
b965b0f
fb08009
d36e3e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. | ||
|
|
||
| from dataclasses import dataclass | ||
|
|
||
|
|
||
| @dataclass | ||
| class Response: | ||
| """Response details from agent execution.""" | ||
|
|
||
| messages: list[str] | ||
| """The list of response messages from the agent. | ||
| Each message represents a text response generated by the agent during execution. | ||
| Messages are serialized to JSON format for OpenTelemetry span attributes. | ||
| An empty list is valid and represents no response messages. | ||
| """ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Why is the docstring after the object it document? |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,14 @@ | |
| from typing import TYPE_CHECKING, Any | ||
|
|
||
| from opentelemetry import baggage, context, trace | ||
| from opentelemetry.trace import Span, SpanKind, Status, StatusCode, Tracer, set_span_in_context | ||
| from opentelemetry.trace import ( | ||
| Span, | ||
| SpanKind, | ||
| Status, | ||
| StatusCode, | ||
| Tracer, | ||
| set_span_in_context, | ||
| ) | ||
|
|
||
| from .constants import ( | ||
| ENABLE_A365_OBSERVABILITY, | ||
|
|
@@ -32,6 +39,7 @@ | |
| SOURCE_NAME, | ||
| TENANT_ID_KEY, | ||
| ) | ||
| from .utils import parse_parent_id_to_context | ||
|
|
||
| if TYPE_CHECKING: | ||
| from .agent_details import AgentDetails | ||
|
|
@@ -71,6 +79,7 @@ def __init__( | |
| activity_name: str, | ||
| agent_details: "AgentDetails | None" = None, | ||
| tenant_details: "TenantDetails | None" = None, | ||
| parent_id: str | None = None, | ||
| ): | ||
| """Initialize the OpenTelemetry scope. | ||
|
|
||
|
|
@@ -80,6 +89,8 @@ 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 | ||
| """ | ||
| self._span: Span | None = None | ||
| self._start_time = time.time() | ||
|
|
@@ -102,12 +113,13 @@ def __init__( | |
| elif kind.lower() == "consumer": | ||
| activity_kind = SpanKind.CONSUMER | ||
|
|
||
| # Get current context for parent relationship | ||
| current_context = context.get_current() | ||
| # Get context for parent relationship | ||
| # If parent_id is provided, parse it and use it as the parent context | ||
| # 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() | ||
|
|
||
| self._span = tracer.start_span( | ||
| activity_name, kind=activity_kind, context=current_context | ||
| ) | ||
| self._span = tracer.start_span(activity_name, kind=activity_kind, context=span_context) | ||
|
Comment on lines
+116
to
+122
|
||
|
|
||
| # Log span creation | ||
| if self._span: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT License. |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,78 @@ | ||||||||
| # Copyright (c) Microsoft Corporation. | ||||||||
| # Licensed under the MIT License. | ||||||||
|
|
||||||||
| from ..agent_details import AgentDetails | ||||||||
| from ..constants import GEN_AI_OUTPUT_MESSAGES_KEY | ||||||||
| from ..models.response import Response | ||||||||
| from ..opentelemetry_scope import OpenTelemetryScope | ||||||||
| from ..tenant_details import TenantDetails | ||||||||
| from ..utils import safe_json_dumps | ||||||||
|
|
||||||||
| OUTPUT_OPERATION_NAME = "output_messages" | ||||||||
|
|
||||||||
|
|
||||||||
|
Comment on lines
+12
to
+13
|
||||||||
| __all__ = ["OutputScope"] |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
start() passes parent_id positionally into OutputScope(...). Using a keyword argument (parent_id=...) would make this resilient to future signature changes and clearer at the call site.
| return OutputScope(agent_details, tenant_details, response, parent_id) | |
| return OutputScope(agent_details, tenant_details, response, parent_id=parent_id) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -13,11 +13,12 @@ | |||||||||||||||||||||||||||||||||||
| from threading import RLock | ||||||||||||||||||||||||||||||||||||
| from typing import Any, Generic, TypeVar, cast | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| from opentelemetry import context | ||||||||||||||||||||||||||||||||||||
| from opentelemetry.semconv.attributes.exception_attributes import ( | ||||||||||||||||||||||||||||||||||||
| EXCEPTION_MESSAGE, | ||||||||||||||||||||||||||||||||||||
| EXCEPTION_STACKTRACE, | ||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||
| from opentelemetry.trace import Span | ||||||||||||||||||||||||||||||||||||
| from opentelemetry.trace import NonRecordingSpan, Span, SpanContext, TraceFlags, set_span_in_context | ||||||||||||||||||||||||||||||||||||
| from opentelemetry.util.types import AttributeValue | ||||||||||||||||||||||||||||||||||||
| from wrapt import ObjectProxy | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
@@ -27,6 +28,128 @@ | |||||||||||||||||||||||||||||||||||
| 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 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. | ||||||||||||||||||||||||||||||||||||
| Args: | ||||||||||||||||||||||||||||||||||||
| trace_id_hex: The trace_id hex string to validate (should be 32 hex chars) | ||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
Comment on lines
+68
to
+72
|
||||||||||||||||||||||||||||||||||||
| True if valid (32 hex chars), False otherwise | |
| """ | |
| return len(trace_id_hex) == W3C_TRACE_ID_LENGTH and _is_valid_hex(trace_id_hex) | |
| True if valid (32 hex chars) and not all zeros, False otherwise | |
| """ | |
| if len(trace_id_hex) != W3C_TRACE_ID_LENGTH or not _is_valid_hex(trace_id_hex): | |
| return False | |
| # W3C Trace Context specification forbids an all-zero trace-id | |
| if trace_id_hex == "0" * W3C_TRACE_ID_LENGTH: | |
| logger.warning( | |
| "Received invalid all-zero trace_id per W3C Trace Context specification; " | |
| "ignoring parent trace context." | |
| ) | |
| return False | |
| return True |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similarly, W3C traceparent requires span-id to be 16 hex chars and not all zeros. validate_span_id() should reject '0000000000000000' so parse_parent_id_to_context() doesn’t create an invalid remote parent context.
| True if valid (16 hex chars), False otherwise | |
| """ | |
| return len(span_id_hex) == W3C_SPAN_ID_LENGTH and _is_valid_hex(span_id_hex) | |
| True if valid (16 hex chars and not all zeros), False otherwise | |
| """ | |
| return ( | |
| len(span_id_hex) == W3C_SPAN_ID_LENGTH | |
| and _is_valid_hex(span_id_hex) | |
| and any(c != "0" for c in span_id_hex) | |
| ) |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
trace_flags_hex is used to build TraceFlags(int(trace_flags_hex, 16)), but its format isn’t validated (length/hex chars). It would be better to validate it is exactly 2 hex chars (and within 0-255) to avoid relying on the generic exception handler for common bad inputs and to emit a targeted warning.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Responseis introduced as a new model type, but it isn’t re-exported frommicrosoft_agents_a365.observability.core(orcore.models) like other public data classes (e.g.,Request,InferenceCallDetails). If this is intended for SDK consumers, consider adding it to the relevant__init__.pyexports/__all__sofrom microsoft_agents_a365.observability.core import Responseworks.