Skip to content

Commit 51801b3

Browse files
CopilotnikhilNavanikhilc-microsoft
authored
feat: Support optional spanKind for InvokeAgentScope and ExecuteToolScope (#195)
* Initial plan * feat: add optional span_kind parameter to InvokeAgentScope and ExecuteToolScope Co-authored-by: nikhilNava <211831449+nikhilNava@users.noreply.github.com> * fix test and lint errors --------- 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 d7d5e58 commit 51801b3

5 files changed

Lines changed: 114 additions & 15 deletions

File tree

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

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

44
from datetime import datetime
55

6+
from opentelemetry.trace import SpanKind
7+
68
from .agent_details import AgentDetails
79
from .constants import (
810
CHANNEL_LINK_KEY,
@@ -34,6 +36,7 @@ def start(
3436
parent_id: str | None = None,
3537
start_time: datetime | None = None,
3638
end_time: datetime | None = None,
39+
span_kind: SpanKind | None = None,
3740
) -> "ExecuteToolScope":
3841
"""Creates and starts a new scope for tool execution tracing.
3942
@@ -49,12 +52,21 @@ def start(
4952
end_time: Optional explicit end time as a datetime object. When provided,
5053
the span will use this timestamp when disposed instead of the
5154
current wall-clock time.
55+
span_kind: Optional span kind override. Defaults to ``SpanKind.INTERNAL``.
56+
Use ``SpanKind.CLIENT`` when the tool calls an external service.
5257
5358
Returns:
5459
A new ExecuteToolScope instance
5560
"""
5661
return ExecuteToolScope(
57-
details, agent_details, tenant_details, request, parent_id, start_time, end_time
62+
details,
63+
agent_details,
64+
tenant_details,
65+
request,
66+
parent_id,
67+
start_time,
68+
end_time,
69+
span_kind,
5870
)
5971

6072
def __init__(
@@ -66,6 +78,7 @@ def __init__(
6678
parent_id: str | None = None,
6779
start_time: datetime | None = None,
6880
end_time: datetime | None = None,
81+
span_kind: SpanKind | None = None,
6982
):
7083
"""Initialize the tool execution scope.
7184
@@ -81,9 +94,11 @@ def __init__(
8194
end_time: Optional explicit end time as a datetime object. When provided,
8295
the span will use this timestamp when disposed instead of the
8396
current wall-clock time.
97+
span_kind: Optional span kind override. Defaults to ``SpanKind.INTERNAL``.
98+
Use ``SpanKind.CLIENT`` when the tool calls an external service.
8499
"""
85100
super().__init__(
86-
kind="Internal",
101+
kind=span_kind if span_kind is not None else SpanKind.INTERNAL,
87102
operation_name=EXECUTE_TOOL_OPERATION_NAME,
88103
activity_name=f"{EXECUTE_TOOL_OPERATION_NAME} {details.tool_name}",
89104
agent_details=agent_details,

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import logging
77
from datetime import datetime
88

9+
from opentelemetry.trace import SpanKind
10+
911
from .agent_details import AgentDetails
1012
from .constants import (
1113
CHANNEL_LINK_KEY,
@@ -50,6 +52,7 @@ def start(
5052
caller_details: CallerDetails | None = None,
5153
start_time: datetime | None = None,
5254
end_time: datetime | None = None,
55+
span_kind: SpanKind | None = None,
5356
) -> "InvokeAgentScope":
5457
"""Create and start a new scope for agent invocation tracing.
5558
@@ -62,6 +65,8 @@ def start(
6265
caller_details: Optional details of the non-agentic caller
6366
start_time: Optional explicit start time as a datetime object.
6467
end_time: Optional explicit end time as a datetime object.
68+
span_kind: Optional span kind override. Defaults to ``SpanKind.CLIENT``.
69+
Use ``SpanKind.SERVER`` when the agent is receiving an inbound request.
6570
6671
Returns:
6772
A new InvokeAgentScope instance
@@ -74,6 +79,7 @@ def start(
7479
caller_details,
7580
start_time,
7681
end_time,
82+
span_kind,
7783
)
7884

7985
def __init__(
@@ -85,6 +91,7 @@ def __init__(
8591
caller_details: CallerDetails | None = None,
8692
start_time: datetime | None = None,
8793
end_time: datetime | None = None,
94+
span_kind: SpanKind | None = None,
8895
):
8996
"""Initialize the agent invocation scope.
9097
@@ -96,6 +103,8 @@ def __init__(
96103
caller_details: Optional details of the non-agentic caller
97104
start_time: Optional explicit start time as a datetime object.
98105
end_time: Optional explicit end time as a datetime object.
106+
span_kind: Optional span kind override. Defaults to ``SpanKind.CLIENT``.
107+
Use ``SpanKind.SERVER`` when the agent is receiving an inbound request.
99108
"""
100109
activity_name = INVOKE_AGENT_OPERATION_NAME
101110
if invoke_agent_details.details.agent_name:
@@ -104,7 +113,7 @@ def __init__(
104113
)
105114

106115
super().__init__(
107-
kind="Client",
116+
kind=span_kind if span_kind is not None else SpanKind.CLIENT,
108117
operation_name=INVOKE_AGENT_OPERATION_NAME,
109118
activity_name=activity_name,
110119
agent_details=invoke_agent_details.details,

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

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ def _datetime_to_ns(dt: datetime | None) -> int | None:
9292

9393
def __init__(
9494
self,
95-
kind: str,
95+
kind: "str | SpanKind",
9696
operation_name: str,
9797
activity_name: str,
9898
agent_details: "AgentDetails | None" = None,
@@ -104,7 +104,9 @@ def __init__(
104104
"""Initialize the OpenTelemetry scope.
105105
106106
Args:
107-
kind: The kind of activity (Client, Server, Internal, etc.)
107+
kind: The kind of activity. Accepts a string (e.g. ``"Client"``,
108+
``"Server"``, ``"Internal"``) or an ``opentelemetry.trace.SpanKind``
109+
enum value directly.
108110
operation_name: The name of the operation being traced
109111
activity_name: The name of the activity for display purposes
110112
agent_details: Optional agent details
@@ -128,16 +130,20 @@ def __init__(
128130
if self._is_telemetry_enabled():
129131
tracer = self._get_tracer()
130132

131-
# Map string kind to SpanKind enum
132-
activity_kind = SpanKind.INTERNAL
133-
if kind.lower() == "client":
134-
activity_kind = SpanKind.CLIENT
135-
elif kind.lower() == "server":
136-
activity_kind = SpanKind.SERVER
137-
elif kind.lower() == "producer":
138-
activity_kind = SpanKind.PRODUCER
139-
elif kind.lower() == "consumer":
140-
activity_kind = SpanKind.CONSUMER
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
141147

142148
# Get context for parent relationship
143149
# If parent_id is provided, parse it and use it as the parent context

tests/observability/core/test_execute_tool_scope.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope
2727
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
2828
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
29+
from opentelemetry.trace import SpanKind
2930

3031

3132
class TestExecuteToolScope(unittest.TestCase):
@@ -157,6 +158,26 @@ def test_execute_tool_scope_with_parent_id(self):
157158
span_parent_id = f"{span.parent.span_id:016x}"
158159
self.assertEqual(span_parent_id, parent_span_id)
159160

161+
def test_span_kind_defaults_to_internal(self):
162+
"""Test that ExecuteToolScope defaults to SpanKind.INTERNAL."""
163+
scope = ExecuteToolScope.start(self.tool_details, self.agent_details, self.tenant_details)
164+
scope.dispose()
165+
166+
finished_spans = self.span_exporter.get_finished_spans()
167+
self.assertTrue(finished_spans, "Expected at least one span to be created")
168+
self.assertEqual(finished_spans[-1].kind, SpanKind.INTERNAL)
169+
170+
def test_span_kind_override_to_client(self):
171+
"""Test that ExecuteToolScope accepts SpanKind.CLIENT override."""
172+
scope = ExecuteToolScope.start(
173+
self.tool_details, self.agent_details, self.tenant_details, span_kind=SpanKind.CLIENT
174+
)
175+
scope.dispose()
176+
177+
finished_spans = self.span_exporter.get_finished_spans()
178+
self.assertTrue(finished_spans, "Expected at least one span to be created")
179+
self.assertEqual(finished_spans[-1].kind, SpanKind.CLIENT)
180+
160181

161182
if __name__ == "__main__":
162183
# Run pytest only on the current file

tests/observability/core/test_invoke_agent_scope.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from microsoft_agents_a365.observability.core.opentelemetry_scope import OpenTelemetryScope
3131
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
3232
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
33+
from opentelemetry.trace import SpanKind
3334

3435

3536
class TestInvokeAgentScope(unittest.TestCase):
@@ -199,6 +200,53 @@ def test_request_attributes_set_on_span(self):
199200
input_messages,
200201
)
201202

203+
def test_invoke_agent_scope_span_kind(self):
204+
"""Test that InvokeAgentScope creates spans with the correct SpanKind."""
205+
# Create scope
206+
scope = InvokeAgentScope.start(
207+
invoke_agent_details=self.invoke_details,
208+
tenant_details=self.tenant_details,
209+
request=self.test_request,
210+
)
211+
212+
if scope is not None:
213+
scope.dispose()
214+
215+
# Check that span was created with correct SpanKind
216+
finished_spans = self.span_exporter.get_finished_spans()
217+
self.assertTrue(finished_spans, "Expected at least one span to be created")
218+
219+
# Get the span and verify its kind
220+
span = finished_spans[-1]
221+
self.assertEqual(
222+
span.kind,
223+
SpanKind.CLIENT,
224+
"InvokeAgentScope defaults to CLIENT spans (can be overridden with span_kind parameter)",
225+
)
226+
227+
# Test SERVER span kind override
228+
scope_server = InvokeAgentScope.start(
229+
invoke_agent_details=self.invoke_details,
230+
tenant_details=self.tenant_details,
231+
request=self.test_request,
232+
span_kind=SpanKind.SERVER,
233+
)
234+
235+
if scope_server is not None:
236+
scope_server.dispose()
237+
238+
# Check that SERVER span was created
239+
finished_spans = self.span_exporter.get_finished_spans()
240+
self.assertTrue(len(finished_spans) >= 2, "Expected at least two spans to be created")
241+
242+
# Get the most recent span and verify it's SERVER
243+
server_span = finished_spans[-1]
244+
self.assertEqual(
245+
server_span.kind,
246+
SpanKind.SERVER,
247+
"InvokeAgentScope should create SERVER spans when span_kind parameter is set",
248+
)
249+
202250

203251
if __name__ == "__main__":
204252
# Run pytest only on the current file

0 commit comments

Comments
 (0)