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.
Files changed (33) hide show
  1. microsoft_agents_a365/observability/core/__init__.py +61 -0
  2. microsoft_agents_a365/observability/core/agent_details.py +42 -0
  3. microsoft_agents_a365/observability/core/config.py +246 -0
  4. microsoft_agents_a365/observability/core/constants.py +107 -0
  5. microsoft_agents_a365/observability/core/execute_tool_scope.py +88 -0
  6. microsoft_agents_a365/observability/core/execution_type.py +13 -0
  7. microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +310 -0
  8. microsoft_agents_a365/observability/core/exporters/utils.py +72 -0
  9. microsoft_agents_a365/observability/core/inference_call_details.py +18 -0
  10. microsoft_agents_a365/observability/core/inference_operation_type.py +11 -0
  11. microsoft_agents_a365/observability/core/inference_scope.py +140 -0
  12. microsoft_agents_a365/observability/core/invoke_agent_details.py +17 -0
  13. microsoft_agents_a365/observability/core/invoke_agent_scope.py +166 -0
  14. microsoft_agents_a365/observability/core/middleware/__init__.py +7 -0
  15. microsoft_agents_a365/observability/core/middleware/baggage_builder.py +319 -0
  16. microsoft_agents_a365/observability/core/middleware/turn_context_baggage.py +193 -0
  17. microsoft_agents_a365/observability/core/models/__init__.py +2 -0
  18. microsoft_agents_a365/observability/core/models/agent_type.py +25 -0
  19. microsoft_agents_a365/observability/core/models/caller_details.py +25 -0
  20. microsoft_agents_a365/observability/core/opentelemetry_scope.py +250 -0
  21. microsoft_agents_a365/observability/core/request.py +19 -0
  22. microsoft_agents_a365/observability/core/source_metadata.py +15 -0
  23. microsoft_agents_a365/observability/core/tenant_details.py +11 -0
  24. microsoft_agents_a365/observability/core/tool_call_details.py +18 -0
  25. microsoft_agents_a365/observability/core/tool_type.py +13 -0
  26. microsoft_agents_a365/observability/core/trace_processor/__init__.py +13 -0
  27. microsoft_agents_a365/observability/core/trace_processor/span_processor.py +75 -0
  28. microsoft_agents_a365/observability/core/trace_processor/util.py +44 -0
  29. microsoft_agents_a365/observability/core/utils.py +151 -0
  30. microsoft_agents_a365_observability_core-0.1.0.dist-info/METADATA +78 -0
  31. microsoft_agents_a365_observability_core-0.1.0.dist-info/RECORD +33 -0
  32. microsoft_agents_a365_observability_core-0.1.0.dist-info/WHEEL +5 -0
  33. 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,11 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ # Tenant details class.
4
+ from dataclasses import dataclass
5
+
6
+
7
+ @dataclass
8
+ class TenantDetails:
9
+ """Represents the tenant id attached to the span."""
10
+
11
+ tenant_id: str
@@ -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,13 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """
4
+ Trace Processors
5
+ """
6
+
7
+ from .span_processor import SpanProcessor
8
+
9
+ # Export public API
10
+ __all__ = [
11
+ # Span processor
12
+ "SpanProcessor",
13
+ ]
@@ -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