microsoft-agents-a365-observability-core 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- microsoft_agents_a365/observability/core/__init__.py +61 -0
- microsoft_agents_a365/observability/core/agent_details.py +42 -0
- microsoft_agents_a365/observability/core/config.py +246 -0
- microsoft_agents_a365/observability/core/constants.py +107 -0
- microsoft_agents_a365/observability/core/execute_tool_scope.py +88 -0
- microsoft_agents_a365/observability/core/execution_type.py +13 -0
- microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +310 -0
- microsoft_agents_a365/observability/core/exporters/utils.py +72 -0
- microsoft_agents_a365/observability/core/inference_call_details.py +18 -0
- microsoft_agents_a365/observability/core/inference_operation_type.py +11 -0
- microsoft_agents_a365/observability/core/inference_scope.py +140 -0
- microsoft_agents_a365/observability/core/invoke_agent_details.py +17 -0
- microsoft_agents_a365/observability/core/invoke_agent_scope.py +166 -0
- microsoft_agents_a365/observability/core/middleware/__init__.py +7 -0
- microsoft_agents_a365/observability/core/middleware/baggage_builder.py +319 -0
- microsoft_agents_a365/observability/core/middleware/turn_context_baggage.py +193 -0
- microsoft_agents_a365/observability/core/models/__init__.py +2 -0
- microsoft_agents_a365/observability/core/models/agent_type.py +25 -0
- microsoft_agents_a365/observability/core/models/caller_details.py +25 -0
- microsoft_agents_a365/observability/core/opentelemetry_scope.py +250 -0
- microsoft_agents_a365/observability/core/request.py +19 -0
- microsoft_agents_a365/observability/core/source_metadata.py +15 -0
- microsoft_agents_a365/observability/core/tenant_details.py +11 -0
- microsoft_agents_a365/observability/core/tool_call_details.py +18 -0
- microsoft_agents_a365/observability/core/tool_type.py +13 -0
- microsoft_agents_a365/observability/core/trace_processor/__init__.py +13 -0
- microsoft_agents_a365/observability/core/trace_processor/span_processor.py +75 -0
- microsoft_agents_a365/observability/core/trace_processor/util.py +44 -0
- microsoft_agents_a365/observability/core/utils.py +151 -0
- microsoft_agents_a365_observability_core-0.1.0.dist-info/METADATA +78 -0
- microsoft_agents_a365_observability_core-0.1.0.dist-info/RECORD +33 -0
- microsoft_agents_a365_observability_core-0.1.0.dist-info/WHEEL +5 -0
- microsoft_agents_a365_observability_core-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class AgentType(Enum):
|
|
8
|
+
"""
|
|
9
|
+
Supported agent types for generative AI.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
ENTRA_EMBODIED = "EntraEmbodied"
|
|
13
|
+
"""Entra embodied agent."""
|
|
14
|
+
|
|
15
|
+
ENTRA_NON_EMBODIED = "EntraNonEmbodied"
|
|
16
|
+
"""Entra non-embodied agent."""
|
|
17
|
+
|
|
18
|
+
MICROSOFT_COPILOT = "MicrosoftCopilot"
|
|
19
|
+
"""Microsoft Copilot agent."""
|
|
20
|
+
|
|
21
|
+
DECLARATIVE_AGENT = "DeclarativeAgent"
|
|
22
|
+
"""Declarative agent."""
|
|
23
|
+
|
|
24
|
+
FOUNDRY = "Foundry"
|
|
25
|
+
"""Foundry agent."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CallerDetails:
|
|
10
|
+
"""Details about the caller that invoked an agent."""
|
|
11
|
+
|
|
12
|
+
caller_id: Optional[str] = None
|
|
13
|
+
"""The unique identifier for the caller."""
|
|
14
|
+
|
|
15
|
+
caller_upn: Optional[str] = None
|
|
16
|
+
"""The User Principal Name (UPN) of the caller."""
|
|
17
|
+
|
|
18
|
+
caller_name: Optional[str] = None
|
|
19
|
+
"""The human-readable name of the caller."""
|
|
20
|
+
|
|
21
|
+
caller_user_id: Optional[str] = None
|
|
22
|
+
"""The user ID of the caller."""
|
|
23
|
+
|
|
24
|
+
tenant_id: Optional[str] = None
|
|
25
|
+
"""The tenant ID of the caller."""
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
# Base class for OpenTelemetry tracing scopes.
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from threading import Lock
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from opentelemetry import baggage, context, trace
|
|
12
|
+
from opentelemetry.trace import Span, SpanKind, Status, StatusCode, Tracer, set_span_in_context
|
|
13
|
+
|
|
14
|
+
from .constants import (
|
|
15
|
+
ENABLE_A365_OBSERVABILITY,
|
|
16
|
+
ENABLE_OBSERVABILITY,
|
|
17
|
+
ERROR_TYPE_KEY,
|
|
18
|
+
GEN_AI_AGENT_AUID_KEY,
|
|
19
|
+
GEN_AI_AGENT_BLUEPRINT_ID_KEY,
|
|
20
|
+
GEN_AI_AGENT_DESCRIPTION_KEY,
|
|
21
|
+
GEN_AI_AGENT_ID_KEY,
|
|
22
|
+
GEN_AI_AGENT_NAME_KEY,
|
|
23
|
+
GEN_AI_AGENT_TYPE_KEY,
|
|
24
|
+
GEN_AI_AGENT_UPN_KEY,
|
|
25
|
+
GEN_AI_CONVERSATION_ID_KEY,
|
|
26
|
+
GEN_AI_EVENT_CONTENT,
|
|
27
|
+
GEN_AI_ICON_URI_KEY,
|
|
28
|
+
GEN_AI_OPERATION_NAME_KEY,
|
|
29
|
+
GEN_AI_SYSTEM_KEY,
|
|
30
|
+
GEN_AI_SYSTEM_VALUE,
|
|
31
|
+
SOURCE_NAME,
|
|
32
|
+
TENANT_ID_KEY,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from .agent_details import AgentDetails
|
|
37
|
+
from .tenant_details import TenantDetails
|
|
38
|
+
|
|
39
|
+
# Create logger for this module - inherits from 'microsoft_agents_a365.observability.core'
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OpenTelemetryScope:
|
|
44
|
+
"""Base class for OpenTelemetry tracing scopes in the SDK."""
|
|
45
|
+
|
|
46
|
+
_tracer: Tracer | None = None
|
|
47
|
+
_tracer_lock = Lock()
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def _get_tracer(cls) -> Tracer:
|
|
51
|
+
"""Get the tracer instance, creating it if necessary."""
|
|
52
|
+
if cls._tracer is None:
|
|
53
|
+
with cls._tracer_lock:
|
|
54
|
+
if cls._tracer is None:
|
|
55
|
+
cls._tracer = trace.get_tracer(SOURCE_NAME)
|
|
56
|
+
return cls._tracer
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def _is_telemetry_enabled(cls) -> bool:
|
|
60
|
+
"""Check if telemetry is enabled."""
|
|
61
|
+
# Check environment variable
|
|
62
|
+
env_value = os.getenv(ENABLE_OBSERVABILITY, "").lower()
|
|
63
|
+
enable_observability = os.getenv(ENABLE_A365_OBSERVABILITY, "").lower()
|
|
64
|
+
return (env_value or enable_observability) in ("true", "1", "yes", "on")
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
kind: str,
|
|
69
|
+
operation_name: str,
|
|
70
|
+
activity_name: str,
|
|
71
|
+
agent_details: "AgentDetails | None" = None,
|
|
72
|
+
tenant_details: "TenantDetails | None" = None,
|
|
73
|
+
):
|
|
74
|
+
"""Initialize the OpenTelemetry scope.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
kind: The kind of activity (Client, Server, Internal, etc.)
|
|
78
|
+
operation_name: The name of the operation being traced
|
|
79
|
+
activity_name: The name of the activity for display purposes
|
|
80
|
+
agent_details: Optional agent details
|
|
81
|
+
tenant_details: Optional tenant details
|
|
82
|
+
"""
|
|
83
|
+
self._span: Span | None = None
|
|
84
|
+
self._start_time = time.time()
|
|
85
|
+
self._has_ended = False
|
|
86
|
+
self._error_type: str | None = None
|
|
87
|
+
self._exception: Exception | None = None
|
|
88
|
+
self._context_token = None
|
|
89
|
+
|
|
90
|
+
if self._is_telemetry_enabled():
|
|
91
|
+
tracer = self._get_tracer()
|
|
92
|
+
|
|
93
|
+
# Map string kind to SpanKind enum
|
|
94
|
+
activity_kind = SpanKind.INTERNAL
|
|
95
|
+
if kind.lower() == "client":
|
|
96
|
+
activity_kind = SpanKind.CLIENT
|
|
97
|
+
elif kind.lower() == "server":
|
|
98
|
+
activity_kind = SpanKind.SERVER
|
|
99
|
+
elif kind.lower() == "producer":
|
|
100
|
+
activity_kind = SpanKind.PRODUCER
|
|
101
|
+
elif kind.lower() == "consumer":
|
|
102
|
+
activity_kind = SpanKind.CONSUMER
|
|
103
|
+
|
|
104
|
+
# Get current context for parent relationship
|
|
105
|
+
current_context = context.get_current()
|
|
106
|
+
|
|
107
|
+
self._span = tracer.start_span(
|
|
108
|
+
activity_name, kind=activity_kind, context=current_context
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Log span creation
|
|
112
|
+
if self._span:
|
|
113
|
+
span_id = f"{self._span.context.span_id:016x}" if self._span.context else "unknown"
|
|
114
|
+
logger.info(f"Span started: '{activity_name}' ({span_id})")
|
|
115
|
+
else:
|
|
116
|
+
logger.error(f"Failed to create span: '{activity_name}' - tracer returned None")
|
|
117
|
+
|
|
118
|
+
# Set common tags
|
|
119
|
+
if self._span:
|
|
120
|
+
self._span.set_attribute(GEN_AI_SYSTEM_KEY, GEN_AI_SYSTEM_VALUE)
|
|
121
|
+
self._span.set_attribute(GEN_AI_OPERATION_NAME_KEY, operation_name)
|
|
122
|
+
|
|
123
|
+
# Set agent details if provided
|
|
124
|
+
if agent_details:
|
|
125
|
+
self.set_tag_maybe(GEN_AI_AGENT_ID_KEY, agent_details.agent_id)
|
|
126
|
+
self.set_tag_maybe(GEN_AI_AGENT_NAME_KEY, agent_details.agent_name)
|
|
127
|
+
self.set_tag_maybe(
|
|
128
|
+
GEN_AI_AGENT_DESCRIPTION_KEY, agent_details.agent_description
|
|
129
|
+
)
|
|
130
|
+
self.set_tag_maybe(GEN_AI_AGENT_AUID_KEY, agent_details.agent_auid)
|
|
131
|
+
self.set_tag_maybe(GEN_AI_AGENT_UPN_KEY, agent_details.agent_upn)
|
|
132
|
+
self.set_tag_maybe(
|
|
133
|
+
GEN_AI_AGENT_BLUEPRINT_ID_KEY, agent_details.agent_blueprint_id
|
|
134
|
+
)
|
|
135
|
+
self.set_tag_maybe(
|
|
136
|
+
GEN_AI_AGENT_TYPE_KEY,
|
|
137
|
+
agent_details.agent_type.value if agent_details.agent_type else None,
|
|
138
|
+
)
|
|
139
|
+
self.set_tag_maybe(TENANT_ID_KEY, agent_details.tenant_id)
|
|
140
|
+
self.set_tag_maybe(GEN_AI_CONVERSATION_ID_KEY, agent_details.conversation_id)
|
|
141
|
+
self.set_tag_maybe(GEN_AI_ICON_URI_KEY, agent_details.icon_uri)
|
|
142
|
+
|
|
143
|
+
# Set tenant details if provided
|
|
144
|
+
if tenant_details:
|
|
145
|
+
self.set_tag_maybe(TENANT_ID_KEY, str(tenant_details.tenant_id))
|
|
146
|
+
|
|
147
|
+
def record_error(self, exception: Exception) -> None:
|
|
148
|
+
"""Record an error in the span.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
exception: The exception that occurred
|
|
152
|
+
"""
|
|
153
|
+
if self._span and self._is_telemetry_enabled():
|
|
154
|
+
self._error_type = type(exception).__name__
|
|
155
|
+
self._exception = exception
|
|
156
|
+
self._span.set_attribute(ERROR_TYPE_KEY, self._error_type)
|
|
157
|
+
self._span.record_exception(exception)
|
|
158
|
+
self._span.set_status(Status(StatusCode.ERROR, str(exception)))
|
|
159
|
+
|
|
160
|
+
def record_response(self, response: str) -> None:
|
|
161
|
+
"""Record an response in the span.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
response: The response content to record
|
|
165
|
+
"""
|
|
166
|
+
if self._span and self._is_telemetry_enabled():
|
|
167
|
+
self._span.set_attribute(GEN_AI_EVENT_CONTENT, response)
|
|
168
|
+
|
|
169
|
+
def record_cancellation(self) -> None:
|
|
170
|
+
"""Record task cancellation."""
|
|
171
|
+
if self._span and self._is_telemetry_enabled():
|
|
172
|
+
self._error_type = "TaskCanceledException"
|
|
173
|
+
self._span.set_attribute(ERROR_TYPE_KEY, self._error_type)
|
|
174
|
+
self._span.set_status(Status(StatusCode.ERROR, "Task was cancelled"))
|
|
175
|
+
|
|
176
|
+
def set_tag_maybe(self, name: str, value: Any) -> None:
|
|
177
|
+
"""Set a tag on the span if the value is not None.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
name: The name of the tag
|
|
181
|
+
value: The value to set (will be skipped if None)
|
|
182
|
+
"""
|
|
183
|
+
if value is not None and self._span and self._is_telemetry_enabled():
|
|
184
|
+
self._span.set_attribute(name, value)
|
|
185
|
+
|
|
186
|
+
def add_baggage(self, key: str, value: str) -> None:
|
|
187
|
+
"""Add baggage to the current context.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
key: The baggage key
|
|
191
|
+
value: The baggage value
|
|
192
|
+
"""
|
|
193
|
+
# Set baggage in the current context
|
|
194
|
+
if self._is_telemetry_enabled():
|
|
195
|
+
# Set baggage on the current context
|
|
196
|
+
# This will be inherited by child spans created within this context
|
|
197
|
+
baggage_context = baggage.set_baggage(key, value)
|
|
198
|
+
# The context needs to be made current for child spans to inherit the baggage
|
|
199
|
+
context.attach(baggage_context)
|
|
200
|
+
|
|
201
|
+
def record_attributes(self, attributes: dict[str, Any] | list[tuple[str, Any]]) -> None:
|
|
202
|
+
"""Record multiple attribute key/value pairs for telemetry tracking.
|
|
203
|
+
|
|
204
|
+
This method allows setting multiple custom attributes on the span at once.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
attributes: Dictionary or list of tuples containing attribute key-value pairs.
|
|
208
|
+
Keys that are None or empty will be skipped.
|
|
209
|
+
"""
|
|
210
|
+
if not self._is_telemetry_enabled() or self._span is None:
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
# Handle both dict and list of tuples
|
|
214
|
+
items = attributes.items() if isinstance(attributes, dict) else attributes
|
|
215
|
+
|
|
216
|
+
for key, value in items:
|
|
217
|
+
if key and key.strip():
|
|
218
|
+
self._span.set_attribute(key, value)
|
|
219
|
+
|
|
220
|
+
def _end(self) -> None:
|
|
221
|
+
"""End the span and record metrics."""
|
|
222
|
+
if self._span and self._is_telemetry_enabled() and not self._has_ended:
|
|
223
|
+
self._has_ended = True
|
|
224
|
+
span_id = f"{self._span.context.span_id:016x}" if self._span.context else "unknown"
|
|
225
|
+
logger.info(f"Span ended: '{self._span.name}' ({span_id})")
|
|
226
|
+
|
|
227
|
+
self._span.end()
|
|
228
|
+
|
|
229
|
+
def __enter__(self):
|
|
230
|
+
"""Enter the context manager and make span active."""
|
|
231
|
+
if self._span and self._is_telemetry_enabled():
|
|
232
|
+
# Make this span the active span in the current context
|
|
233
|
+
new_context = set_span_in_context(self._span)
|
|
234
|
+
self._context_token = context.attach(new_context)
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
238
|
+
"""Exit the context manager and restore previous context."""
|
|
239
|
+
try:
|
|
240
|
+
if exc_type is not None and exc_val is not None:
|
|
241
|
+
self.record_error(exc_val)
|
|
242
|
+
finally:
|
|
243
|
+
# Restore previous context
|
|
244
|
+
if self._context_token is not None:
|
|
245
|
+
context.detach(self._context_token)
|
|
246
|
+
self._end()
|
|
247
|
+
|
|
248
|
+
def dispose(self) -> None:
|
|
249
|
+
"""Dispose the scope and finalize telemetry data collection."""
|
|
250
|
+
self._end()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
# Request class.
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from .execution_type import ExecutionType
|
|
8
|
+
from .source_metadata import SourceMetadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Request:
|
|
13
|
+
"""Request details for agent execution."""
|
|
14
|
+
|
|
15
|
+
content: str
|
|
16
|
+
execution_type: ExecutionType
|
|
17
|
+
session_id: str | None = None
|
|
18
|
+
source_metadata: SourceMetadata | None = None
|
|
19
|
+
payload: str | None = None
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
# Source metadata class.
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class SourceMetadata:
|
|
10
|
+
"""Source metadata for agent execution context."""
|
|
11
|
+
|
|
12
|
+
id: str | None = None
|
|
13
|
+
name: str | None = None
|
|
14
|
+
icon_uri: str | None = None
|
|
15
|
+
description: str | None = None
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
# Data class for tool call details.
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import ParseResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ToolCallDetails:
|
|
11
|
+
"""Details of a tool call made by an agent in the system."""
|
|
12
|
+
|
|
13
|
+
tool_name: str
|
|
14
|
+
arguments: str | None = None
|
|
15
|
+
tool_call_id: str | None = None
|
|
16
|
+
description: str | None = None
|
|
17
|
+
tool_type: str | None = None
|
|
18
|
+
endpoint: ParseResult | None = None
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
# Tool type enum.
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolType(Enum):
|
|
9
|
+
"""Enumeration for different tool types for execute tool contexts."""
|
|
10
|
+
|
|
11
|
+
FUNCTION = "function"
|
|
12
|
+
EXTENSION = "extension"
|
|
13
|
+
DATASTORE = "datastore"
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Span processor for copying OpenTelemetry baggage entries onto spans.
|
|
4
|
+
|
|
5
|
+
This implementation assumes `opentelemetry.baggage.get_all` is available with the
|
|
6
|
+
signature `get_all(context: Context | None) -> Mapping[str, object]`.
|
|
7
|
+
|
|
8
|
+
For every new span:
|
|
9
|
+
* Retrieve the current (or parent) context
|
|
10
|
+
* Obtain all baggage entries via `baggage.get_all`
|
|
11
|
+
* For each (key, value) pair with a truthy value not already present as a span
|
|
12
|
+
attribute, add it via `span.set_attribute`
|
|
13
|
+
* Never overwrites existing attributes
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from opentelemetry import baggage, context
|
|
17
|
+
from opentelemetry.sdk.trace import SpanProcessor as BaseSpanProcessor
|
|
18
|
+
|
|
19
|
+
from ..constants import GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME
|
|
20
|
+
from .util import COMMON_ATTRIBUTES, INVOKE_AGENT_ATTRIBUTES
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SpanProcessor(BaseSpanProcessor):
|
|
24
|
+
"""Span processor that propagates every baggage key/value to span attributes."""
|
|
25
|
+
|
|
26
|
+
def __init__(self):
|
|
27
|
+
super().__init__()
|
|
28
|
+
|
|
29
|
+
def on_start(self, span, parent_context=None):
|
|
30
|
+
ctx = parent_context or context.get_current()
|
|
31
|
+
if ctx is None:
|
|
32
|
+
return super().on_start(span, parent_context)
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
existing = getattr(span, "attributes", {}) or {}
|
|
36
|
+
except Exception:
|
|
37
|
+
existing = {}
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
baggage_map = baggage.get_all(ctx) or {}
|
|
41
|
+
except Exception:
|
|
42
|
+
baggage_map = {}
|
|
43
|
+
|
|
44
|
+
operation_name = existing.get(GEN_AI_OPERATION_NAME_KEY)
|
|
45
|
+
is_invoke_agent = False
|
|
46
|
+
if operation_name == INVOKE_AGENT_OPERATION_NAME:
|
|
47
|
+
is_invoke_agent = True
|
|
48
|
+
elif isinstance(getattr(span, "name", None), str) and span.name.startswith(
|
|
49
|
+
INVOKE_AGENT_OPERATION_NAME
|
|
50
|
+
):
|
|
51
|
+
is_invoke_agent = True
|
|
52
|
+
|
|
53
|
+
# Build target key set (avoid duplicates).
|
|
54
|
+
target_keys = list(COMMON_ATTRIBUTES)
|
|
55
|
+
if is_invoke_agent:
|
|
56
|
+
# Add invoke-agent-only attributes
|
|
57
|
+
for k in INVOKE_AGENT_ATTRIBUTES:
|
|
58
|
+
if k not in target_keys:
|
|
59
|
+
target_keys.append(k)
|
|
60
|
+
|
|
61
|
+
for key in target_keys:
|
|
62
|
+
if key in existing:
|
|
63
|
+
continue
|
|
64
|
+
value = baggage_map.get(key)
|
|
65
|
+
if not value:
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
span.set_attribute(key, value)
|
|
69
|
+
except Exception:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
return super().on_start(span, parent_context)
|
|
73
|
+
|
|
74
|
+
def on_end(self, span):
|
|
75
|
+
super().on_end(span)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
from .. import constants as consts
|
|
4
|
+
|
|
5
|
+
# Generic / common tracing attributes
|
|
6
|
+
COMMON_ATTRIBUTES = [
|
|
7
|
+
consts.TENANT_ID_KEY, # tenant.id
|
|
8
|
+
consts.CUSTOM_PARENT_SPAN_ID_KEY, # custom.parent.span.id
|
|
9
|
+
consts.CUSTOM_SPAN_NAME_KEY, # custom.span.name
|
|
10
|
+
consts.CORRELATION_ID_KEY, # correlation.id
|
|
11
|
+
consts.GEN_AI_CONVERSATION_ID_KEY, # conversation.id
|
|
12
|
+
consts.GEN_AI_CONVERSATION_ITEM_LINK_KEY, # conversation.itemLink
|
|
13
|
+
consts.GEN_AI_OPERATION_NAME_KEY, # gen_ai.operation.name
|
|
14
|
+
consts.GEN_AI_AGENT_ID_KEY, # gen_ai.agent.id
|
|
15
|
+
consts.GEN_AI_AGENT_NAME_KEY, # gen_ai.agent.name
|
|
16
|
+
consts.GEN_AI_AGENT_DESCRIPTION_KEY, # gen_ai.agent.description
|
|
17
|
+
consts.GEN_AI_AGENT_USER_ID_KEY, # gen_ai.agent.userid
|
|
18
|
+
consts.GEN_AI_AGENT_UPN_KEY, # gen_ai.agent.upn
|
|
19
|
+
consts.GEN_AI_AGENT_BLUEPRINT_ID_KEY, # gen_ai.agent.applicationid
|
|
20
|
+
consts.GEN_AI_AGENT_AUID_KEY,
|
|
21
|
+
consts.GEN_AI_AGENT_TYPE_KEY,
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Invoke Agent–specific attributes
|
|
25
|
+
INVOKE_AGENT_ATTRIBUTES = [
|
|
26
|
+
# Caller / Invoker attributes
|
|
27
|
+
consts.GEN_AI_CALLER_ID_KEY, # gen_ai.caller.id
|
|
28
|
+
consts.GEN_AI_CALLER_NAME_KEY, # gen_ai.caller.name
|
|
29
|
+
consts.GEN_AI_CALLER_UPN_KEY, # gen_ai.caller.upn
|
|
30
|
+
consts.GEN_AI_CALLER_USER_ID_KEY, # gen_ai.caller.userid
|
|
31
|
+
consts.GEN_AI_CALLER_TENANT_ID_KEY, # gen_ai.caller.tenantid
|
|
32
|
+
# Caller Agent (A2A) attributes
|
|
33
|
+
consts.GEN_AI_CALLER_AGENT_ID_KEY, # gen_ai.caller.agent.id
|
|
34
|
+
consts.GEN_AI_CALLER_AGENT_NAME_KEY, # gen_ai.caller.agent.name
|
|
35
|
+
consts.GEN_AI_CALLER_AGENT_USER_ID_KEY, # gen_ai.caller.agent.userid
|
|
36
|
+
consts.GEN_AI_CALLER_AGENT_UPN_KEY, # gen_ai.caller.agent.upn
|
|
37
|
+
consts.GEN_AI_CALLER_AGENT_TENANT_ID_KEY, # gen_ai.caller.agent.tenantid
|
|
38
|
+
consts.GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, # gen_ai.caller.agent.applicationid
|
|
39
|
+
# Execution context
|
|
40
|
+
consts.GEN_AI_EXECUTION_TYPE_KEY, # gen_ai.execution.type
|
|
41
|
+
consts.GEN_AI_EXECUTION_SOURCE_ID_KEY, # gen_ai.execution.sourceMetadata.id
|
|
42
|
+
consts.GEN_AI_EXECUTION_SOURCE_NAME_KEY, # gen_ai.execution.sourceMetadata.name
|
|
43
|
+
consts.GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, # gen_ai.execution.sourceMetadata.description
|
|
44
|
+
]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import datetime
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import traceback
|
|
7
|
+
from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from threading import RLock
|
|
10
|
+
from typing import Any, Generic, TypeVar, cast
|
|
11
|
+
|
|
12
|
+
from opentelemetry.semconv.trace import SpanAttributes as OTELSpanAttributes
|
|
13
|
+
from opentelemetry.trace import Span
|
|
14
|
+
from opentelemetry.util.types import AttributeValue
|
|
15
|
+
from wrapt import ObjectProxy
|
|
16
|
+
|
|
17
|
+
from microsoft_agents_a365.observability.core.constants import ERROR_TYPE_KEY
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
logger.addHandler(logging.NullHandler())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def safe_json_dumps(obj: Any, **kwargs: Any) -> str:
|
|
24
|
+
return json.dumps(obj, default=str, ensure_ascii=False, **kwargs)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def as_utc_nano(dt: datetime.datetime) -> int:
|
|
28
|
+
return int(dt.astimezone(datetime.UTC).timestamp() * 1_000_000_000)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
KeyType = TypeVar("KeyType")
|
|
32
|
+
ValueType = TypeVar("ValueType")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_first_value(
|
|
36
|
+
mapping: Mapping[KeyType, ValueType], keys: Iterable[KeyType]
|
|
37
|
+
) -> ValueType | None:
|
|
38
|
+
"""
|
|
39
|
+
Returns the first non-null value corresponding to an input key, or None if
|
|
40
|
+
no non-null value is found.
|
|
41
|
+
"""
|
|
42
|
+
if not hasattr(mapping, "get"):
|
|
43
|
+
return None
|
|
44
|
+
return next(
|
|
45
|
+
(value for key in keys if (value := mapping.get(key)) is not None),
|
|
46
|
+
None,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def stop_on_exception(
|
|
51
|
+
wrapped: Callable[..., Iterator[tuple[str, Any]]],
|
|
52
|
+
) -> Callable[..., Iterator[tuple[str, Any]]]:
|
|
53
|
+
def wrapper(*args: Any, **kwargs: Any) -> Iterator[tuple[str, Any]]:
|
|
54
|
+
try:
|
|
55
|
+
yield from wrapped(*args, **kwargs)
|
|
56
|
+
except Exception:
|
|
57
|
+
logger.exception("Failed to get attribute.")
|
|
58
|
+
|
|
59
|
+
return wrapper
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def record_exception(span: Span, error: BaseException) -> None:
|
|
63
|
+
if isinstance(error, Exception):
|
|
64
|
+
span.record_exception(error)
|
|
65
|
+
return
|
|
66
|
+
exception_type = error.__class__.__name__
|
|
67
|
+
exception_message = str(error)
|
|
68
|
+
if not exception_message:
|
|
69
|
+
exception_message = repr(error)
|
|
70
|
+
attributes: dict[str, AttributeValue] = {
|
|
71
|
+
ERROR_TYPE_KEY: exception_type,
|
|
72
|
+
OTELSpanAttributes.EXCEPTION_MESSAGE: exception_message,
|
|
73
|
+
}
|
|
74
|
+
try:
|
|
75
|
+
attributes[OTELSpanAttributes.EXCEPTION_STACKTRACE] = traceback.format_exc()
|
|
76
|
+
except Exception:
|
|
77
|
+
logger.exception("Failed to record exception stacktrace.")
|
|
78
|
+
span.add_event(name="exception", attributes=attributes)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@stop_on_exception
|
|
82
|
+
def flatten(key_values: Iterable[tuple[str, Any]]) -> Iterator[tuple[str, AttributeValue]]:
|
|
83
|
+
for key, value in key_values:
|
|
84
|
+
if value is None:
|
|
85
|
+
continue
|
|
86
|
+
if isinstance(value, Mapping):
|
|
87
|
+
for sub_key, sub_value in flatten(value.items()):
|
|
88
|
+
yield f"{key}.{sub_key}", sub_value
|
|
89
|
+
elif isinstance(value, list) and any(isinstance(item, Mapping) for item in value):
|
|
90
|
+
for index, sub_mapping in enumerate(value):
|
|
91
|
+
for sub_key, sub_value in flatten(sub_mapping.items()):
|
|
92
|
+
yield f"{key}.{index}.{sub_key}", sub_value
|
|
93
|
+
else:
|
|
94
|
+
if isinstance(value, Enum):
|
|
95
|
+
value = value.value
|
|
96
|
+
yield key, value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
K = TypeVar("K", bound=Hashable)
|
|
100
|
+
V = TypeVar("V")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class DictWithLock(ObjectProxy, Generic[K, V]): # type: ignore
|
|
104
|
+
"""
|
|
105
|
+
A wrapped dictionary with lock
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, wrapped: dict[str, V] | None = None) -> None:
|
|
109
|
+
super().__init__(wrapped or {})
|
|
110
|
+
self._self_lock = RLock()
|
|
111
|
+
|
|
112
|
+
def get(self, key: K) -> V | None:
|
|
113
|
+
with self._self_lock:
|
|
114
|
+
return cast(V | None, self.__wrapped__.get(key))
|
|
115
|
+
|
|
116
|
+
def pop(self, key: K, *args: Any) -> V | None:
|
|
117
|
+
with self._self_lock:
|
|
118
|
+
return cast(V | None, self.__wrapped__.pop(key, *args))
|
|
119
|
+
|
|
120
|
+
def __getitem__(self, key: K) -> V:
|
|
121
|
+
with self._self_lock:
|
|
122
|
+
return cast(V, super().__getitem__(key))
|
|
123
|
+
|
|
124
|
+
def __setitem__(self, key: K, value: V) -> None:
|
|
125
|
+
with self._self_lock:
|
|
126
|
+
super().__setitem__(key, value)
|
|
127
|
+
|
|
128
|
+
def __delitem__(self, key: K) -> None:
|
|
129
|
+
with self._self_lock:
|
|
130
|
+
super().__delitem__(key)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def extract_model_name(span_name: str) -> str | None:
|
|
134
|
+
"""
|
|
135
|
+
Extract model name from span names like:
|
|
136
|
+
- 'chat.completions gpt-4o-mini' -> 'gpt-4o-mini'
|
|
137
|
+
- 'chat.completions gpt-3.5-turbo' -> 'gpt-3.5-turbo'
|
|
138
|
+
- 'chat.completions' -> None
|
|
139
|
+
"""
|
|
140
|
+
parts = span_name.split(" ")
|
|
141
|
+
|
|
142
|
+
if len(parts) == 2:
|
|
143
|
+
return parts[1]
|
|
144
|
+
# If we have more than 2 parts, the model name starts from the 3rd part
|
|
145
|
+
# Format: "chat.completions model-name" or "chat.completions model-name-with-dashes"
|
|
146
|
+
elif len(parts) >= 3:
|
|
147
|
+
# Join everything after "chat.completions" to handle model names with spaces/dashes
|
|
148
|
+
model_name = " ".join(parts[2:])
|
|
149
|
+
return model_name.strip()
|
|
150
|
+
|
|
151
|
+
return None
|