Skip to content

Commit 3670119

Browse files
support service address and implement span links
1 parent 99249d6 commit 3670119

12 files changed

Lines changed: 378 additions & 89 deletions

File tree

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

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -77,25 +77,20 @@ 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 = SpanDetails(
82+
span_kind=span_details.span_kind if span_details and span_details.span_kind else SpanKind.INTERNAL,
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+
) if span_details else SpanDetails(span_kind=SpanKind.INTERNAL)
9088

9189
super().__init__(
92-
kind=kind,
9390
operation_name=EXECUTE_TOOL_OPERATION_NAME,
9491
activity_name=f"{EXECUTE_TOOL_OPERATION_NAME} {details.tool_name}",
9592
agent_details=agent_details,
96-
parent_context=parent_context,
97-
start_time=start_time,
98-
end_time=end_time,
93+
span_details=resolved_span_details,
9994
)
10095

10196
# Extract details

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

Lines changed: 11 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,20 @@ 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 = SpanDetails(
81+
span_kind=SpanKind.CLIENT,
82+
parent_context=span_details.parent_context if span_details else None,
83+
start_time=span_details.start_time if span_details else None,
84+
end_time=span_details.end_time if span_details else None,
85+
span_links=span_details.span_links if span_details else None,
86+
) if span_details else SpanDetails(span_kind=SpanKind.CLIENT)
8487

8588
super().__init__(
86-
kind="Client",
8789
operation_name=details.operationName.value,
8890
activity_name=f"{details.operationName.value} {details.model}",
8991
agent_details=agent_details,
90-
parent_context=parent_context,
91-
start_time=start_time,
92-
end_time=end_time,
92+
span_details=resolved_span_details,
9393
)
9494

9595
if request.content:

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

Lines changed: 9 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,25 +93,20 @@ 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 = SpanDetails(
98+
span_kind=span_details.span_kind if span_details and span_details.span_kind else SpanKind.CLIENT,
99+
parent_context=span_details.parent_context if span_details else None,
100+
start_time=span_details.start_time if span_details else None,
101+
end_time=span_details.end_time if span_details else None,
102+
span_links=span_details.span_links if span_details else None,
103+
) if span_details else SpanDetails(span_kind=SpanKind.CLIENT)
106104

107105
super().__init__(
108-
kind=kind,
109106
operation_name=INVOKE_AGENT_OPERATION_NAME,
110107
activity_name=activity_name,
111108
agent_details=agent_details,
112-
parent_context=parent_context,
113-
start_time=start_time,
114-
end_time=end_time,
109+
span_details=resolved_span_details,
115110
)
116111

117112
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: 19 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,23 @@ 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(
173+
self, address: str | None, port: int | None = None
174+
) -> "BaggageBuilder":
175+
"""Set the invoke agent server address and port baggage values.
176+
177+
Args:
178+
address: The server address (hostname) of the target agent service.
179+
port: Optional server port. Only recorded when different from 443.
180+
181+
Returns:
182+
Self for method chaining
183+
"""
184+
self._set(SERVER_ADDRESS_KEY, address)
185+
if port is not None and port != 443:
186+
self._set(SERVER_PORT_KEY, str(port))
187+
return self
188+
170189
def conversation_id(self, value: str | None) -> "BaggageBuilder":
171190
"""Set the conversation ID baggage value."""
172191
self._set(GEN_AI_CONVERSATION_ID_KEY, value)

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

Lines changed: 15 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,28 @@ 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 = span_details.span_kind if span_details and span_details.span_kind else SpanKind.CLIENT
118+
122119
self._span: Span | None = None
123120
self._custom_start_time: datetime | None = start_time
124121
self._custom_end_time: datetime | None = end_time
@@ -130,20 +127,8 @@ def __init__(
130127
if self._is_telemetry_enabled():
131128
tracer = self._get_tracer()
132129

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
130+
# Resolve activity_kind from SpanKind enum
131+
activity_kind = kind if isinstance(kind, SpanKind) else SpanKind.CLIENT
147132

148133
# Get context for parent relationship
149134
# If parent_context is provided, use it directly
@@ -158,6 +143,7 @@ def __init__(
158143
kind=activity_kind,
159144
context=span_context,
160145
start_time=otel_start_time,
146+
links=span_links,
161147
)
162148

163149
# 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: 11 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,20 @@ 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 = SpanDetails(
71+
span_kind=SpanKind.CLIENT,
72+
parent_context=span_details.parent_context if span_details else None,
73+
start_time=span_details.start_time if span_details else None,
74+
end_time=span_details.end_time if span_details else None,
75+
span_links=span_details.span_links if span_details else None,
76+
) if span_details else SpanDetails(span_kind=SpanKind.CLIENT)
7477

7578
super().__init__(
76-
kind="Client",
7779
operation_name=OUTPUT_OPERATION_NAME,
7880
activity_name=(f"{OUTPUT_OPERATION_NAME} {agent_details.agent_id}"),
7981
agent_details=agent_details,
80-
parent_context=parent_context,
81-
start_time=start_time,
82-
end_time=end_time,
82+
span_details=resolved_span_details,
8383
)
8484

8585
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)