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,310 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
# pip install opentelemetry-sdk opentelemetry-api requests
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
from microsoft_agents_a365.runtime.power_platform_api_discovery import PowerPlatformApiDiscovery
|
|
16
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
17
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
18
|
+
from opentelemetry.trace import StatusCode
|
|
19
|
+
|
|
20
|
+
from .utils import (
|
|
21
|
+
hex_span_id,
|
|
22
|
+
hex_trace_id,
|
|
23
|
+
kind_name,
|
|
24
|
+
partition_by_identity,
|
|
25
|
+
status_name,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# ---- Exporter ---------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
# Hardcoded constants - not configurable
|
|
31
|
+
DEFAULT_HTTP_TIMEOUT_SECONDS = 30.0
|
|
32
|
+
DEFAULT_MAX_RETRIES = 3
|
|
33
|
+
|
|
34
|
+
# Create logger for this module - inherits from 'microsoft_agents_a365.observability.core'
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Agent365Exporter(SpanExporter):
|
|
39
|
+
"""
|
|
40
|
+
Agent 365 span exporter for Agent 365:
|
|
41
|
+
* Partitions spans by (tenantId, agentId)
|
|
42
|
+
* Builds OTLP-like JSON: resourceSpans -> scopeSpans -> spans
|
|
43
|
+
* POSTs per group to https://{endpoint}/maven/agent365/agents/{agentId}/traces?api-version=1
|
|
44
|
+
* Adds Bearer token via token_resolver(agentId, tenantId)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
token_resolver: Callable[[str, str], str | None],
|
|
50
|
+
cluster_category: str = "prod",
|
|
51
|
+
use_s2s_endpoint: bool = False,
|
|
52
|
+
):
|
|
53
|
+
if token_resolver is None:
|
|
54
|
+
raise ValueError("token_resolver must be provided.")
|
|
55
|
+
self._session = requests.Session()
|
|
56
|
+
self._closed = False
|
|
57
|
+
self._lock = threading.Lock()
|
|
58
|
+
self._token_resolver = token_resolver
|
|
59
|
+
self._cluster_category = cluster_category
|
|
60
|
+
self._use_s2s_endpoint = use_s2s_endpoint
|
|
61
|
+
|
|
62
|
+
# ------------- SpanExporter API -----------------
|
|
63
|
+
|
|
64
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
65
|
+
if self._closed:
|
|
66
|
+
return SpanExportResult.FAILURE
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
groups = partition_by_identity(spans)
|
|
70
|
+
if not groups:
|
|
71
|
+
# No spans with identity; treat as success
|
|
72
|
+
logger.info("No spans with tenant/agent identity found; nothing exported.")
|
|
73
|
+
return SpanExportResult.SUCCESS
|
|
74
|
+
|
|
75
|
+
# Debug: Log number of groups and total span count
|
|
76
|
+
total_spans = sum(len(activities) for activities in groups.values())
|
|
77
|
+
logger.info(
|
|
78
|
+
f"Found {len(groups)} identity groups with {total_spans} total spans to export"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
any_failure = False
|
|
82
|
+
for (tenant_id, agent_id), activities in groups.items():
|
|
83
|
+
payload = self._build_export_request(activities)
|
|
84
|
+
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
|
85
|
+
|
|
86
|
+
# Resolve endpoint + token
|
|
87
|
+
discovery = PowerPlatformApiDiscovery(self._cluster_category)
|
|
88
|
+
endpoint = discovery.get_tenant_island_cluster_endpoint(tenant_id)
|
|
89
|
+
endpoint_path = (
|
|
90
|
+
f"/maven/agent365/service/agents/{agent_id}/traces"
|
|
91
|
+
if self._use_s2s_endpoint
|
|
92
|
+
else f"/maven/agent365/agents/{agent_id}/traces"
|
|
93
|
+
)
|
|
94
|
+
url = f"https://{endpoint}{endpoint_path}?api-version=1"
|
|
95
|
+
|
|
96
|
+
# Debug: Log endpoint being used
|
|
97
|
+
logger.info(
|
|
98
|
+
f"Exporting {len(activities)} spans to endpoint: {url} "
|
|
99
|
+
f"(tenant: {tenant_id}, agent: {agent_id})"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
headers = {"content-type": "application/json"}
|
|
103
|
+
try:
|
|
104
|
+
token = self._token_resolver(agent_id, tenant_id)
|
|
105
|
+
if token:
|
|
106
|
+
headers["authorization"] = f"Bearer {token}"
|
|
107
|
+
logger.info(f"Token resolved successfully for agent {agent_id}")
|
|
108
|
+
else:
|
|
109
|
+
logger.info(f"No token returned for agent {agent_id}")
|
|
110
|
+
except Exception as e:
|
|
111
|
+
# If token resolution fails, treat as failure for this group
|
|
112
|
+
logger.error(
|
|
113
|
+
f"Token resolution failed for agent {agent_id}, tenant {tenant_id}: {e}"
|
|
114
|
+
)
|
|
115
|
+
any_failure = True
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
# Basic retry loop
|
|
119
|
+
ok = self._post_with_retries(url, body, headers)
|
|
120
|
+
if not ok:
|
|
121
|
+
any_failure = True
|
|
122
|
+
|
|
123
|
+
return SpanExportResult.FAILURE if any_failure else SpanExportResult.SUCCESS
|
|
124
|
+
|
|
125
|
+
except Exception as e:
|
|
126
|
+
# Exporters should not raise; signal failure.
|
|
127
|
+
logger.error(f"Export failed with exception: {e}")
|
|
128
|
+
return SpanExportResult.FAILURE
|
|
129
|
+
|
|
130
|
+
def shutdown(self) -> None:
|
|
131
|
+
with self._lock:
|
|
132
|
+
if self._closed:
|
|
133
|
+
return
|
|
134
|
+
self._closed = True
|
|
135
|
+
try:
|
|
136
|
+
self._session.close()
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
141
|
+
return True
|
|
142
|
+
|
|
143
|
+
# ------------- HTTP helper ----------------------
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _truncate_text(text: str, max_length: int) -> str:
|
|
147
|
+
"""Truncate text to a maximum length, adding '...' if truncated."""
|
|
148
|
+
if len(text) > max_length:
|
|
149
|
+
return text[:max_length] + "..."
|
|
150
|
+
return text
|
|
151
|
+
|
|
152
|
+
def _post_with_retries(self, url: str, body: str, headers: dict[str, str]) -> bool:
|
|
153
|
+
for attempt in range(DEFAULT_MAX_RETRIES + 1):
|
|
154
|
+
try:
|
|
155
|
+
resp = self._session.post(
|
|
156
|
+
url,
|
|
157
|
+
data=body.encode("utf-8"),
|
|
158
|
+
headers=headers,
|
|
159
|
+
timeout=DEFAULT_HTTP_TIMEOUT_SECONDS,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Extract correlation ID from response headers for logging
|
|
163
|
+
correlation_id = (
|
|
164
|
+
resp.headers.get("x-ms-correlation-id")
|
|
165
|
+
or resp.headers.get("request-id")
|
|
166
|
+
or "N/A"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# 2xx => success
|
|
170
|
+
if 200 <= resp.status_code < 300:
|
|
171
|
+
logger.info(
|
|
172
|
+
f"HTTP {resp.status_code} success on attempt {attempt + 1}. "
|
|
173
|
+
f"Correlation ID: {correlation_id}. "
|
|
174
|
+
f"Response: {self._truncate_text(resp.text, 200)}"
|
|
175
|
+
)
|
|
176
|
+
return True
|
|
177
|
+
|
|
178
|
+
# Log non-success responses
|
|
179
|
+
response_text = self._truncate_text(resp.text, 500)
|
|
180
|
+
|
|
181
|
+
# Retry transient
|
|
182
|
+
if resp.status_code in (408, 429) or 500 <= resp.status_code < 600:
|
|
183
|
+
if attempt < DEFAULT_MAX_RETRIES:
|
|
184
|
+
time.sleep(0.2 * (attempt + 1))
|
|
185
|
+
continue
|
|
186
|
+
# Final attempt failed
|
|
187
|
+
logger.error(
|
|
188
|
+
f"HTTP {resp.status_code} final failure after {DEFAULT_MAX_RETRIES + 1} attempts. "
|
|
189
|
+
f"Correlation ID: {correlation_id}. "
|
|
190
|
+
f"Response: {response_text}"
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
# Non-retryable error
|
|
194
|
+
logger.error(
|
|
195
|
+
f"HTTP {resp.status_code} non-retryable error. "
|
|
196
|
+
f"Correlation ID: {correlation_id}. "
|
|
197
|
+
f"Response: {response_text}"
|
|
198
|
+
)
|
|
199
|
+
return False
|
|
200
|
+
|
|
201
|
+
except requests.RequestException as e:
|
|
202
|
+
if attempt < DEFAULT_MAX_RETRIES:
|
|
203
|
+
time.sleep(0.2 * (attempt + 1))
|
|
204
|
+
continue
|
|
205
|
+
# Final attempt failed
|
|
206
|
+
logger.error(
|
|
207
|
+
f"Request failed after {DEFAULT_MAX_RETRIES + 1} attempts with exception: {e}"
|
|
208
|
+
)
|
|
209
|
+
return False
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
# ------------- Payload mapping ------------------
|
|
213
|
+
|
|
214
|
+
def _build_export_request(self, spans: Sequence[ReadableSpan]) -> dict[str, Any]:
|
|
215
|
+
# Group by instrumentation scope (name, version)
|
|
216
|
+
scope_map: dict[tuple[str, str | None], list[dict[str, Any]]] = {}
|
|
217
|
+
|
|
218
|
+
for sp in spans:
|
|
219
|
+
scope = sp.instrumentation_scope
|
|
220
|
+
scope_key = (scope.name, scope.version)
|
|
221
|
+
scope_map.setdefault(scope_key, []).append(self._map_span(sp))
|
|
222
|
+
|
|
223
|
+
scope_spans: list[dict[str, Any]] = []
|
|
224
|
+
for (name, version), mapped_spans in scope_map.items():
|
|
225
|
+
scope_spans.append(
|
|
226
|
+
{
|
|
227
|
+
"scope": {
|
|
228
|
+
"name": name,
|
|
229
|
+
"version": version,
|
|
230
|
+
},
|
|
231
|
+
"spans": mapped_spans,
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Resource attributes (from the first span – all spans in a batch usually share resource)
|
|
236
|
+
# If you need to merge across spans, adapt accordingly.
|
|
237
|
+
resource_attrs = {}
|
|
238
|
+
if spans:
|
|
239
|
+
resource_attrs = dict(getattr(spans[0].resource, "attributes", {}) or {})
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
"resourceSpans": [
|
|
243
|
+
{
|
|
244
|
+
"resource": {"attributes": resource_attrs or None},
|
|
245
|
+
"scopeSpans": scope_spans,
|
|
246
|
+
}
|
|
247
|
+
]
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
def _map_span(self, sp: ReadableSpan) -> dict[str, Any]:
|
|
251
|
+
ctx = sp.context
|
|
252
|
+
|
|
253
|
+
parent_span_id = None
|
|
254
|
+
if sp.parent is not None and sp.parent.span_id != 0:
|
|
255
|
+
parent_span_id = hex_span_id(sp.parent.span_id)
|
|
256
|
+
|
|
257
|
+
# attributes
|
|
258
|
+
attrs = dict(sp.attributes or {})
|
|
259
|
+
# events
|
|
260
|
+
events = []
|
|
261
|
+
for ev in sp.events:
|
|
262
|
+
ev_attrs = dict(ev.attributes or {}) if ev.attributes else None
|
|
263
|
+
events.append(
|
|
264
|
+
{
|
|
265
|
+
"timeUnixNano": ev.timestamp, # already ns
|
|
266
|
+
"name": ev.name,
|
|
267
|
+
"attributes": ev_attrs,
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
if not events:
|
|
271
|
+
events = None
|
|
272
|
+
|
|
273
|
+
# links
|
|
274
|
+
links = []
|
|
275
|
+
for ln in sp.links or []:
|
|
276
|
+
ln_attrs = dict(ln.attributes or {}) if ln.attributes else None
|
|
277
|
+
links.append(
|
|
278
|
+
{
|
|
279
|
+
"traceId": hex_trace_id(ln.context.trace_id),
|
|
280
|
+
"spanId": hex_span_id(ln.context.span_id),
|
|
281
|
+
"attributes": ln_attrs,
|
|
282
|
+
}
|
|
283
|
+
)
|
|
284
|
+
if not links:
|
|
285
|
+
links = None
|
|
286
|
+
|
|
287
|
+
# status
|
|
288
|
+
status_code = sp.status.status_code if sp.status else StatusCode.UNSET
|
|
289
|
+
status = {
|
|
290
|
+
"code": status_name(status_code),
|
|
291
|
+
"message": getattr(sp.status, "description", "") or "",
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
# times are ns in ReadableSpan
|
|
295
|
+
start_ns = sp.start_time
|
|
296
|
+
end_ns = sp.end_time
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
"traceId": hex_trace_id(ctx.trace_id),
|
|
300
|
+
"spanId": hex_span_id(ctx.span_id),
|
|
301
|
+
"parentSpanId": parent_span_id,
|
|
302
|
+
"name": sp.name,
|
|
303
|
+
"kind": kind_name(sp.kind),
|
|
304
|
+
"startTimeUnixNano": start_ns,
|
|
305
|
+
"endTimeUnixNano": end_ns,
|
|
306
|
+
"attributes": attrs or None,
|
|
307
|
+
"events": events,
|
|
308
|
+
"links": links,
|
|
309
|
+
"status": status,
|
|
310
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
8
|
+
from opentelemetry.trace import SpanKind, StatusCode
|
|
9
|
+
|
|
10
|
+
from ..constants import (
|
|
11
|
+
ENABLE_A365_OBSERVABILITY_EXPORTER,
|
|
12
|
+
GEN_AI_AGENT_ID_KEY,
|
|
13
|
+
TENANT_ID_KEY,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def hex_trace_id(value: int) -> str:
|
|
18
|
+
# 128-bit -> 32 hex chars
|
|
19
|
+
return f"{value:032x}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def hex_span_id(value: int) -> str:
|
|
23
|
+
# 64-bit -> 16 hex chars
|
|
24
|
+
return f"{value:016x}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def as_str(v: Any) -> str | None:
|
|
28
|
+
if v is None:
|
|
29
|
+
return None
|
|
30
|
+
s = str(v)
|
|
31
|
+
return s if s.strip() else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def kind_name(kind: SpanKind) -> str:
|
|
35
|
+
# Return span kind name (enum name or numeric)
|
|
36
|
+
try:
|
|
37
|
+
return kind.name # Enum in otel 1.27+
|
|
38
|
+
except Exception:
|
|
39
|
+
return str(kind)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def status_name(code: StatusCode) -> str:
|
|
43
|
+
try:
|
|
44
|
+
return code.name
|
|
45
|
+
except Exception:
|
|
46
|
+
return str(code)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def partition_by_identity(
|
|
50
|
+
spans: Sequence[ReadableSpan],
|
|
51
|
+
) -> dict[tuple[str, str], list[ReadableSpan]]:
|
|
52
|
+
"""
|
|
53
|
+
Extract (tenantId, agentId). Prefer attributes; if you also stamp baggage
|
|
54
|
+
into attributes via a processor, they'll be here already.
|
|
55
|
+
"""
|
|
56
|
+
groups: dict[tuple[str, str], list[ReadableSpan]] = {}
|
|
57
|
+
for sp in spans:
|
|
58
|
+
attrs = sp.attributes or {}
|
|
59
|
+
tenant = as_str(attrs.get(TENANT_ID_KEY))
|
|
60
|
+
agent = as_str(attrs.get(GEN_AI_AGENT_ID_KEY))
|
|
61
|
+
if not tenant or not agent:
|
|
62
|
+
continue
|
|
63
|
+
key = (tenant, agent)
|
|
64
|
+
groups.setdefault(key, []).append(sp)
|
|
65
|
+
return groups
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_agent365_exporter_enabled() -> bool:
|
|
69
|
+
"""Check if Agent 365 exporter is enabled."""
|
|
70
|
+
# Check environment variable
|
|
71
|
+
enable_exporter = os.getenv(ENABLE_A365_OBSERVABILITY_EXPORTER, "").lower()
|
|
72
|
+
return (enable_exporter) in ("true", "1", "yes", "on")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .inference_operation_type import InferenceOperationType
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class InferenceCallDetails:
|
|
10
|
+
"""Details of an inference call for generative AI operations."""
|
|
11
|
+
|
|
12
|
+
operationName: InferenceOperationType
|
|
13
|
+
model: str
|
|
14
|
+
providerName: str
|
|
15
|
+
inputTokens: int | None = None
|
|
16
|
+
outputTokens: int | None = None
|
|
17
|
+
finishReasons: list[str] | None = None
|
|
18
|
+
responseId: str | None = None
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InferenceOperationType(Enum):
|
|
7
|
+
"""Supported inference operation types for generative AI."""
|
|
8
|
+
|
|
9
|
+
CHAT = "Chat"
|
|
10
|
+
TEXT_COMPLETION = "TextCompletion"
|
|
11
|
+
GENERATE_CONTENT = "GenerateContent"
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Copyright (c) Microsoft Corporation.
|
|
2
|
+
# Licensed under the MIT License.
|
|
3
|
+
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from .agent_details import AgentDetails
|
|
7
|
+
from .constants import (
|
|
8
|
+
GEN_AI_INPUT_MESSAGES_KEY,
|
|
9
|
+
GEN_AI_OPERATION_NAME_KEY,
|
|
10
|
+
GEN_AI_OUTPUT_MESSAGES_KEY,
|
|
11
|
+
GEN_AI_PROVIDER_NAME_KEY,
|
|
12
|
+
GEN_AI_REQUEST_MODEL_KEY,
|
|
13
|
+
GEN_AI_RESPONSE_FINISH_REASONS_KEY,
|
|
14
|
+
GEN_AI_RESPONSE_ID_KEY,
|
|
15
|
+
GEN_AI_THOUGHT_PROCESS_KEY,
|
|
16
|
+
GEN_AI_USAGE_INPUT_TOKENS_KEY,
|
|
17
|
+
GEN_AI_USAGE_OUTPUT_TOKENS_KEY,
|
|
18
|
+
)
|
|
19
|
+
from .inference_call_details import InferenceCallDetails
|
|
20
|
+
from .opentelemetry_scope import OpenTelemetryScope
|
|
21
|
+
from .request import Request
|
|
22
|
+
from .tenant_details import TenantDetails
|
|
23
|
+
from .utils import safe_json_dumps
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InferenceScope(OpenTelemetryScope):
|
|
27
|
+
"""Provides OpenTelemetry tracing scope for generative AI inference operations."""
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def start(
|
|
31
|
+
details: InferenceCallDetails,
|
|
32
|
+
agent_details: AgentDetails,
|
|
33
|
+
tenant_details: TenantDetails,
|
|
34
|
+
request: Request | None = None,
|
|
35
|
+
) -> "InferenceScope":
|
|
36
|
+
"""Creates and starts a new scope for inference tracing.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
details: The details of the inference call
|
|
40
|
+
agent_details: The details of the agent making the call
|
|
41
|
+
tenant_details: The details of the tenant
|
|
42
|
+
request: Optional request details for additional context
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
A new InferenceScope instance
|
|
46
|
+
"""
|
|
47
|
+
return InferenceScope(details, agent_details, tenant_details, request)
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
details: InferenceCallDetails,
|
|
52
|
+
agent_details: AgentDetails,
|
|
53
|
+
tenant_details: TenantDetails,
|
|
54
|
+
request: Request | None = None,
|
|
55
|
+
):
|
|
56
|
+
"""Initialize the inference scope.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
details: The details of the inference call
|
|
60
|
+
agent_details: The details of the agent making the call
|
|
61
|
+
tenant_details: The details of the tenant
|
|
62
|
+
request: Optional request details for additional context
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
super().__init__(
|
|
66
|
+
kind="Client",
|
|
67
|
+
operation_name=details.operationName.value,
|
|
68
|
+
activity_name=f"{details.operationName.value} {details.model}",
|
|
69
|
+
agent_details=agent_details,
|
|
70
|
+
tenant_details=tenant_details,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
if request:
|
|
74
|
+
self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, request.content)
|
|
75
|
+
|
|
76
|
+
self.set_tag_maybe(GEN_AI_OPERATION_NAME_KEY, details.operationName.value)
|
|
77
|
+
self.set_tag_maybe(GEN_AI_REQUEST_MODEL_KEY, details.model)
|
|
78
|
+
self.set_tag_maybe(GEN_AI_PROVIDER_NAME_KEY, details.providerName)
|
|
79
|
+
self.set_tag_maybe(
|
|
80
|
+
GEN_AI_USAGE_INPUT_TOKENS_KEY,
|
|
81
|
+
str(details.inputTokens) if details.inputTokens is not None else None,
|
|
82
|
+
)
|
|
83
|
+
self.set_tag_maybe(
|
|
84
|
+
GEN_AI_USAGE_OUTPUT_TOKENS_KEY,
|
|
85
|
+
str(details.outputTokens) if details.outputTokens is not None else None,
|
|
86
|
+
)
|
|
87
|
+
self.set_tag_maybe(
|
|
88
|
+
GEN_AI_RESPONSE_FINISH_REASONS_KEY,
|
|
89
|
+
safe_json_dumps(details.finishReasons) if details.finishReasons else None,
|
|
90
|
+
)
|
|
91
|
+
self.set_tag_maybe(GEN_AI_RESPONSE_ID_KEY, details.responseId)
|
|
92
|
+
|
|
93
|
+
def record_input_messages(self, messages: List[str]) -> None:
|
|
94
|
+
"""Records the input messages for telemetry tracking.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
messages: List of input messages
|
|
98
|
+
"""
|
|
99
|
+
self.set_tag_maybe(GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps(messages))
|
|
100
|
+
|
|
101
|
+
def record_output_messages(self, messages: List[str]) -> None:
|
|
102
|
+
"""Records the output messages for telemetry tracking.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
messages: List of output messages
|
|
106
|
+
"""
|
|
107
|
+
self.set_tag_maybe(GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(messages))
|
|
108
|
+
|
|
109
|
+
def record_input_tokens(self, input_tokens: int) -> None:
|
|
110
|
+
"""Records the number of input tokens for telemetry tracking.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
input_tokens: Number of input tokens
|
|
114
|
+
"""
|
|
115
|
+
self.set_tag_maybe(GEN_AI_USAGE_INPUT_TOKENS_KEY, str(input_tokens))
|
|
116
|
+
|
|
117
|
+
def record_output_tokens(self, output_tokens: int) -> None:
|
|
118
|
+
"""Records the number of output tokens for telemetry tracking.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
output_tokens: Number of output tokens
|
|
122
|
+
"""
|
|
123
|
+
self.set_tag_maybe(GEN_AI_USAGE_OUTPUT_TOKENS_KEY, str(output_tokens))
|
|
124
|
+
|
|
125
|
+
def record_finish_reasons(self, finish_reasons: List[str]) -> None:
|
|
126
|
+
"""Records the finish reasons for telemetry tracking.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
finish_reasons: List of finish reasons
|
|
130
|
+
"""
|
|
131
|
+
if finish_reasons:
|
|
132
|
+
self.set_tag_maybe(GEN_AI_RESPONSE_FINISH_REASONS_KEY, safe_json_dumps(finish_reasons))
|
|
133
|
+
|
|
134
|
+
def record_thought_process(self, thought_process: str) -> None:
|
|
135
|
+
"""Records the thought process.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
thought_process: The thought process to record
|
|
139
|
+
"""
|
|
140
|
+
self.set_tag_maybe(GEN_AI_THOUGHT_PROCESS_KEY, thought_process)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright (c) Microsoft. All rights reserved.
|
|
2
|
+
|
|
3
|
+
# Data class for invoke agent details.
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import ParseResult
|
|
7
|
+
|
|
8
|
+
from .agent_details import AgentDetails
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class InvokeAgentDetails:
|
|
13
|
+
"""Details for agent invocation tracing."""
|
|
14
|
+
|
|
15
|
+
details: AgentDetails
|
|
16
|
+
endpoint: ParseResult | None = None
|
|
17
|
+
session_id: str | None = None
|