Skip to content

Commit 6d2e789

Browse files
support service address and implement span links (#218)
* support service address and implement span links * fix rush issues --------- Co-authored-by: Nikhil Chitlur Navakiran (from Dev Box) <nikhilc@microsoft.com>
1 parent fbdc187 commit 6d2e789

File tree

12 files changed

+405
-89
lines changed

12 files changed

+405
-89
lines changed

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

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -77,25 +77,26 @@ def __init__(
7777
user_details: Optional human user details
7878
span_details: Optional span configuration (parent context, timing, kind)
7979
"""
80-
kind = SpanKind.INTERNAL
81-
parent_context = None
82-
start_time = None
83-
end_time = None
84-
if span_details is not None:
85-
if span_details.span_kind is not None:
86-
kind = span_details.span_kind
87-
parent_context = span_details.parent_context
88-
start_time = span_details.start_time
89-
end_time = span_details.end_time
80+
# spanKind defaults to INTERNAL; allow override via span_details
81+
resolved_span_details = (
82+
SpanDetails(
83+
span_kind=span_details.span_kind
84+
if span_details and span_details.span_kind
85+
else SpanKind.INTERNAL,
86+
parent_context=span_details.parent_context if span_details else None,
87+
start_time=span_details.start_time if span_details else None,
88+
end_time=span_details.end_time if span_details else None,
89+
span_links=span_details.span_links if span_details else None,
90+
)
91+
if span_details
92+
else SpanDetails(span_kind=SpanKind.INTERNAL)
93+
)
9094

9195
super().__init__(
92-
kind=kind,
9396
operation_name=EXECUTE_TOOL_OPERATION_NAME,
9497
activity_name=f"{EXECUTE_TOOL_OPERATION_NAME} {details.tool_name}",
9598
agent_details=agent_details,
96-
parent_context=parent_context,
97-
start_time=start_time,
98-
end_time=end_time,
99+
span_details=resolved_span_details,
99100
)
100101

101102
# Extract details

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

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
from typing import List
55

6+
from opentelemetry.trace import SpanKind
7+
68
from .agent_details import AgentDetails
79
from .constants import (
810
CHANNEL_LINK_KEY,
@@ -74,22 +76,24 @@ def __init__(
7476
user_details: Optional human user details
7577
span_details: Optional span configuration (parent context, timing)
7678
"""
77-
parent_context = None
78-
start_time = None
79-
end_time = None
80-
if span_details is not None:
81-
parent_context = span_details.parent_context
82-
start_time = span_details.start_time
83-
end_time = span_details.end_time
79+
# spanKind for InferenceScope is always CLIENT
80+
resolved_span_details = (
81+
SpanDetails(
82+
span_kind=SpanKind.CLIENT,
83+
parent_context=span_details.parent_context if span_details else None,
84+
start_time=span_details.start_time if span_details else None,
85+
end_time=span_details.end_time if span_details else None,
86+
span_links=span_details.span_links if span_details else None,
87+
)
88+
if span_details
89+
else SpanDetails(span_kind=SpanKind.CLIENT)
90+
)
8491

8592
super().__init__(
86-
kind="Client",
8793
operation_name=details.operationName.value,
8894
activity_name=f"{details.operationName.value} {details.model}",
8995
agent_details=agent_details,
90-
parent_context=parent_context,
91-
start_time=start_time,
92-
end_time=end_time,
96+
span_details=resolved_span_details,
9397
)
9498

9599
if request.content:

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

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,25 +93,26 @@ def __init__(
9393
if agent_details.agent_name:
9494
activity_name = f"{INVOKE_AGENT_OPERATION_NAME} {agent_details.agent_name}"
9595

96-
kind = SpanKind.CLIENT
97-
parent_context = None
98-
start_time = None
99-
end_time = None
100-
if span_details is not None:
101-
if span_details.span_kind is not None:
102-
kind = span_details.span_kind
103-
parent_context = span_details.parent_context
104-
start_time = span_details.start_time
105-
end_time = span_details.end_time
96+
# spanKind defaults to CLIENT; allow override via span_details
97+
resolved_span_details = (
98+
SpanDetails(
99+
span_kind=span_details.span_kind
100+
if span_details and span_details.span_kind
101+
else SpanKind.CLIENT,
102+
parent_context=span_details.parent_context if span_details else None,
103+
start_time=span_details.start_time if span_details else None,
104+
end_time=span_details.end_time if span_details else None,
105+
span_links=span_details.span_links if span_details else None,
106+
)
107+
if span_details
108+
else SpanDetails(span_kind=SpanKind.CLIENT)
109+
)
106110

107111
super().__init__(
108-
kind=kind,
109112
operation_name=INVOKE_AGENT_OPERATION_NAME,
110113
activity_name=activity_name,
111114
agent_details=agent_details,
112-
parent_context=parent_context,
113-
start_time=start_time,
114-
end_time=end_time,
115+
span_details=resolved_span_details,
115116
)
116117

117118
self.set_tag_maybe(SESSION_ID_KEY, request.session_id)

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
GEN_AI_CALLER_CLIENT_IP_KEY,
2121
GEN_AI_CONVERSATION_ID_KEY,
2222
GEN_AI_CONVERSATION_ITEM_LINK_KEY,
23+
SERVER_ADDRESS_KEY,
24+
SERVER_PORT_KEY,
2325
SERVICE_NAME_KEY,
2426
SESSION_DESCRIPTION_KEY,
2527
SESSION_ID_KEY,
@@ -167,6 +169,21 @@ def user_client_ip(self, value: str | None) -> "BaggageBuilder":
167169
self._set(GEN_AI_CALLER_CLIENT_IP_KEY, validate_and_normalize_ip(value))
168170
return self
169171

172+
def invoke_agent_server(self, address: str | None, port: int | None = None) -> "BaggageBuilder":
173+
"""Set the invoke agent server address and port baggage values.
174+
175+
Args:
176+
address: The server address (hostname) of the target agent service.
177+
port: Optional server port. Only recorded when different from 443.
178+
179+
Returns:
180+
Self for method chaining
181+
"""
182+
self._set(SERVER_ADDRESS_KEY, address)
183+
if port is not None and port != 443:
184+
self._set(SERVER_PORT_KEY, str(port))
185+
return self
186+
170187
def conversation_id(self, value: str | None) -> "BaggageBuilder":
171188
"""Set the conversation ID baggage value."""
172189
self._set(GEN_AI_CONVERSATION_ID_KEY, value)

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

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949

5050
if TYPE_CHECKING:
5151
from .agent_details import AgentDetails
52+
from .span_details import SpanDetails
5253

5354
# Create logger for this module - inherits from 'microsoft_agents_a365.observability.core'
5455
logger = logging.getLogger(__name__)
@@ -93,32 +94,38 @@ def _datetime_to_ns(dt: datetime | None) -> int | None:
9394

9495
def __init__(
9596
self,
96-
kind: "str | SpanKind",
9797
operation_name: str,
9898
activity_name: str,
9999
agent_details: "AgentDetails | None" = None,
100-
parent_context: Context | None = None,
101-
start_time: datetime | None = None,
102-
end_time: datetime | None = None,
100+
span_details: "SpanDetails | None" = None,
103101
):
104102
"""Initialize the OpenTelemetry scope.
105103
106104
Args:
107-
kind: The kind of activity. Accepts a string (e.g. ``"Client"``,
108-
``"Server"``, ``"Internal"``) or an ``opentelemetry.trace.SpanKind``
109-
enum value directly.
110105
operation_name: The name of the operation being traced
111106
activity_name: The name of the activity for display purposes
112107
agent_details: Optional agent details
113-
parent_context: Optional OpenTelemetry Context used to link this span to an
114-
upstream operation. Use ``extract_context_from_headers()`` to extract a
115-
Context from HTTP headers containing W3C traceparent.
116-
start_time: Optional explicit start time as a datetime object.
117-
Useful when recording an operation after it has already completed.
118-
end_time: Optional explicit end time as a datetime object.
119-
When provided, the span will use this timestamp when disposed
120-
instead of the current wall-clock time.
108+
span_details: Optional span configuration including parent context,
109+
start/end times, span kind, and span links. Subclasses may override
110+
``span_details.span_kind`` before calling this constructor;
111+
defaults to ``SpanKind.CLIENT``.
121112
"""
113+
parent_context = span_details.parent_context if span_details else None
114+
start_time = span_details.start_time if span_details else None
115+
end_time = span_details.end_time if span_details else None
116+
span_links = span_details.span_links if span_details else None
117+
kind = (
118+
span_details.span_kind if span_details and span_details.span_kind else SpanKind.CLIENT
119+
)
120+
if not isinstance(kind, SpanKind):
121+
logger.warning(
122+
"span_details.span_kind has invalid type %s (value: %r); "
123+
"falling back to SpanKind.CLIENT",
124+
type(kind).__name__,
125+
kind,
126+
)
127+
kind = SpanKind.CLIENT
128+
122129
self._span: Span | None = None
123130
self._custom_start_time: datetime | None = start_time
124131
self._custom_end_time: datetime | None = end_time
@@ -130,20 +137,7 @@ def __init__(
130137
if self._is_telemetry_enabled():
131138
tracer = self._get_tracer()
132139

133-
# Resolve activity_kind from either a SpanKind enum or a string
134-
if isinstance(kind, SpanKind):
135-
activity_kind = kind
136-
else:
137-
# Map string kind to SpanKind enum
138-
activity_kind = SpanKind.INTERNAL
139-
if kind.lower() == "client":
140-
activity_kind = SpanKind.CLIENT
141-
elif kind.lower() == "server":
142-
activity_kind = SpanKind.SERVER
143-
elif kind.lower() == "producer":
144-
activity_kind = SpanKind.PRODUCER
145-
elif kind.lower() == "consumer":
146-
activity_kind = SpanKind.CONSUMER
140+
activity_kind = kind
147141

148142
# Get context for parent relationship
149143
# If parent_context is provided, use it directly
@@ -158,6 +152,7 @@ def __init__(
158152
kind=activity_kind,
159153
context=span_context,
160154
start_time=otel_start_time,
155+
links=span_links,
161156
)
162157

163158
# Log span creation

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from datetime import datetime
66

77
from opentelemetry.context import Context
8-
from opentelemetry.trace import SpanKind
8+
from opentelemetry.trace import Link, SpanKind
99

1010

1111
@dataclass
@@ -23,3 +23,6 @@ class SpanDetails:
2323

2424
end_time: datetime | None = None
2525
"""Optional explicit end time as a datetime object."""
26+
27+
span_links: list[Link] | None = None
28+
"""Optional span links to associate with this span for causal relationships."""

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/spans_scopes/output_scope.py

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4+
from opentelemetry.trace import SpanKind
5+
46
from ..agent_details import AgentDetails
57
from ..constants import (
68
GEN_AI_CALLER_CLIENT_IP_KEY,
@@ -64,22 +66,24 @@ def __init__(
6466
user_details: Optional human user details
6567
span_details: Optional span configuration (parent context, timing)
6668
"""
67-
parent_context = None
68-
start_time = None
69-
end_time = None
70-
if span_details is not None:
71-
parent_context = span_details.parent_context
72-
start_time = span_details.start_time
73-
end_time = span_details.end_time
69+
# spanKind for OutputScope is always CLIENT
70+
resolved_span_details = (
71+
SpanDetails(
72+
span_kind=SpanKind.CLIENT,
73+
parent_context=span_details.parent_context if span_details else None,
74+
start_time=span_details.start_time if span_details else None,
75+
end_time=span_details.end_time if span_details else None,
76+
span_links=span_details.span_links if span_details else None,
77+
)
78+
if span_details
79+
else SpanDetails(span_kind=SpanKind.CLIENT)
80+
)
7481

7582
super().__init__(
76-
kind="Client",
7783
operation_name=OUTPUT_OPERATION_NAME,
7884
activity_name=(f"{OUTPUT_OPERATION_NAME} {agent_details.agent_id}"),
7985
agent_details=agent_details,
80-
parent_context=parent_context,
81-
start_time=start_time,
82-
end_time=end_time,
86+
span_details=resolved_span_details,
8387
)
8488

8589
self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, request.conversation_id)

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/util.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,24 @@
2424
# Channel dimensions
2525
consts.CHANNEL_NAME_KEY, # microsoft.channel.name
2626
consts.CHANNEL_LINK_KEY, # microsoft.channel.link
27+
# User / Caller attributes
28+
consts.USER_ID_KEY, # user.id
29+
consts.USER_NAME_KEY, # user.name
30+
consts.USER_EMAIL_KEY, # user.email
31+
# Service attributes
32+
consts.SERVICE_NAME_KEY, # service.name
2733
]
2834

2935
# Invoke Agent–specific attributes
3036
INVOKE_AGENT_ATTRIBUTES = [
31-
# Caller / Invoker attributes
32-
consts.USER_ID_KEY, # user.id
33-
consts.USER_NAME_KEY, # user.name
34-
consts.USER_EMAIL_KEY, # user.email
3537
# Caller Agent (A2A) attributes
3638
consts.GEN_AI_CALLER_AGENT_ID_KEY, # microsoft.a365.caller.agent.id
3739
consts.GEN_AI_CALLER_AGENT_NAME_KEY, # microsoft.a365.caller.agent.name
3840
consts.GEN_AI_CALLER_AGENT_USER_ID_KEY, # microsoft.a365.caller.agent.user.id
3941
consts.GEN_AI_CALLER_AGENT_EMAIL_KEY, # microsoft.a365.caller.agent.user.email
4042
consts.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, # microsoft.a365.caller.agent.blueprint.id
4143
consts.GEN_AI_CALLER_AGENT_PLATFORM_ID_KEY, # microsoft.a365.caller.agent.platform.id
44+
# Server address/port for invoke agent target
45+
consts.SERVER_ADDRESS_KEY, # server.address
46+
consts.SERVER_PORT_KEY, # server.port
4247
]

tests/observability/core/test_baggage_builder.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
GEN_AI_AGENT_EMAIL_KEY,
1313
GEN_AI_AGENT_ID_KEY,
1414
GEN_AI_CALLER_CLIENT_IP_KEY,
15+
SERVER_ADDRESS_KEY,
16+
SERVER_PORT_KEY,
1517
SERVICE_NAME_KEY,
1618
SESSION_DESCRIPTION_KEY,
1719
SESSION_ID_KEY,
@@ -329,6 +331,39 @@ def test_operation_source_method(self):
329331
current_baggage = baggage.get_all()
330332
self.assertIsNone(current_baggage.get(SERVICE_NAME_KEY))
331333

334+
def test_invoke_agent_server_sets_address_and_port(self):
335+
"""Test that invoke_agent_server sets both address and non-443 port."""
336+
address = "app.azurewebsites.net"
337+
port = 8080
338+
339+
with BaggageBuilder().invoke_agent_server(address, port).build():
340+
current_baggage = baggage.get_all()
341+
self.assertEqual(current_baggage.get(SERVER_ADDRESS_KEY), address)
342+
self.assertEqual(current_baggage.get(SERVER_PORT_KEY), str(port))
343+
344+
# After scope exit, baggage should be cleared
345+
current_baggage = baggage.get_all()
346+
self.assertIsNone(current_baggage.get(SERVER_ADDRESS_KEY))
347+
self.assertIsNone(current_baggage.get(SERVER_PORT_KEY))
348+
349+
def test_invoke_agent_server_omits_port_when_443(self):
350+
"""Test that invoke_agent_server omits port when it is the default 443."""
351+
address = "app.azurewebsites.net"
352+
353+
with BaggageBuilder().invoke_agent_server(address, 443).build():
354+
current_baggage = baggage.get_all()
355+
self.assertEqual(current_baggage.get(SERVER_ADDRESS_KEY), address)
356+
self.assertIsNone(current_baggage.get(SERVER_PORT_KEY))
357+
358+
def test_invoke_agent_server_sets_address_only_when_port_none(self):
359+
"""Test that invoke_agent_server sets only address when port is None."""
360+
address = "app.azurewebsites.net"
361+
362+
with BaggageBuilder().invoke_agent_server(address).build():
363+
current_baggage = baggage.get_all()
364+
self.assertEqual(current_baggage.get(SERVER_ADDRESS_KEY), address)
365+
self.assertIsNone(current_baggage.get(SERVER_PORT_KEY))
366+
332367

333368
if __name__ == "__main__":
334369
unittest.main()

0 commit comments

Comments
 (0)