Skip to content

Commit af280ea

Browse files
Merge branch 'main' into docs/integrating-with-existing-opentelemetry
2 parents 749acbc + 42e1670 commit af280ea

21 files changed

Lines changed: 1101 additions & 256 deletions

File tree

libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/agent_notification.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,121 @@ async def handle_user_deleted(context, state, notification):
426426
"""
427427
return self.on_lifecycle_notification(AgentLifecycleEvent.USERDELETED, **kwargs)
428428

429+
def on_user_undeleted(
430+
self, **kwargs: Any
431+
) -> Callable[[AgentHandler], Callable[[TurnContext, TurnState], Awaitable[None]]]:
432+
"""Register a handler for user un-deletion lifecycle events.
433+
434+
This is a convenience decorator that registers a handler specifically for
435+
agentic user identity un-deletion events.
436+
437+
Args:
438+
**kwargs: Additional keyword arguments passed to the app's add_route method.
439+
440+
Returns:
441+
A decorator function that registers the handler with the application.
442+
443+
Example:
444+
```python
445+
@notifications.on_user_undeleted()
446+
async def handle_user_undeleted(context, state, notification):
447+
print("Agentic user identity un-deleted")
448+
```
449+
"""
450+
return self.on_lifecycle_notification(AgentLifecycleEvent.USERUNDELETED, **kwargs)
451+
452+
def on_user_updated(
453+
self, **kwargs: Any
454+
) -> Callable[[AgentHandler], Callable[[TurnContext, TurnState], Awaitable[None]]]:
455+
"""Register a handler for user updated lifecycle events.
456+
457+
This is a convenience decorator that registers a handler specifically for
458+
agentic user identity updated events.
459+
460+
Args:
461+
**kwargs: Additional keyword arguments passed to the app's add_route method.
462+
463+
Returns:
464+
A decorator function that registers the handler with the application.
465+
466+
Example:
467+
```python
468+
@notifications.on_user_updated()
469+
async def handle_user_updated(context, state, notification):
470+
print("Agentic user identity updated")
471+
```
472+
"""
473+
return self.on_lifecycle_notification(AgentLifecycleEvent.USERUPDATED, **kwargs)
474+
475+
def on_user_manager_updated(
476+
self, **kwargs: Any
477+
) -> Callable[[AgentHandler], Callable[[TurnContext, TurnState], Awaitable[None]]]:
478+
"""Register a handler for user manager updated lifecycle events.
479+
480+
This is a convenience decorator that registers a handler specifically for
481+
agentic user manager updated events.
482+
483+
Args:
484+
**kwargs: Additional keyword arguments passed to the app's add_route method.
485+
486+
Returns:
487+
A decorator function that registers the handler with the application.
488+
489+
Example:
490+
```python
491+
@notifications.on_user_manager_updated()
492+
async def handle_user_manager_updated(context, state, notification):
493+
print("Agentic user manager updated")
494+
```
495+
"""
496+
return self.on_lifecycle_notification(AgentLifecycleEvent.USERMANAGERUPDATED, **kwargs)
497+
498+
def on_user_enabled(
499+
self, **kwargs: Any
500+
) -> Callable[[AgentHandler], Callable[[TurnContext, TurnState], Awaitable[None]]]:
501+
"""Register a handler for user enabled lifecycle events.
502+
503+
This is a convenience decorator that registers a handler specifically for
504+
agentic user enabled events.
505+
506+
Args:
507+
**kwargs: Additional keyword arguments passed to the app's add_route method.
508+
509+
Returns:
510+
A decorator function that registers the handler with the application.
511+
512+
Example:
513+
```python
514+
@notifications.on_user_enabled()
515+
async def handle_user_enabled(context, state, notification):
516+
print("Agentic user enabled")
517+
```
518+
"""
519+
return self.on_lifecycle_notification(AgentLifecycleEvent.USERENABLED, **kwargs)
520+
521+
def on_user_disabled(
522+
self, **kwargs: Any
523+
) -> Callable[[AgentHandler], Callable[[TurnContext, TurnState], Awaitable[None]]]:
524+
"""Register a handler for user disabled lifecycle events.
525+
526+
This is a convenience decorator that registers a handler specifically for
527+
agentic user disabled events.
528+
529+
Args:
530+
**kwargs: Additional keyword arguments passed to the app's add_route method.
531+
532+
Returns:
533+
A decorator function that registers the handler with the application.
534+
535+
Example:
536+
```python
537+
@notifications.on_user_disabled()
538+
async def handle_user_disabled(context, state, notification):
539+
print("Agentic user disabled")
540+
```
541+
"""
542+
return self.on_lifecycle_notification(AgentLifecycleEvent.USERDISABLED, **kwargs)
543+
429544
@staticmethod
430545
def _normalize_subchannel(value: str | AgentSubChannel | None) -> str:
431546
"""Normalize a subchannel value to a lowercase string.

libraries/microsoft-agents-a365-notifications/microsoft_agents_a365/notifications/models/agent_lifecycle_event.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,18 @@ class AgentLifecycleEvent(str, Enum):
1515
USERWORKLOADONBOARDINGUPDATED: Event triggered when a user's workload
1616
onboarding status is updated.
1717
USERDELETED: Event triggered when an agentic user identity is deleted.
18+
USERUNDELETED: Event triggered when an agentic user identity is un-deleted.
19+
USERUPDATED: Event triggered when an agentic user identity is updated.
20+
USERMANAGERUPDATED: Event triggered when an agentic user's manager is updated.
21+
USERENABLED: Event triggered when an agentic user is enabled.
22+
USERDISABLED: Event triggered when an agentic user is disabled.
1823
"""
1924

2025
USERCREATED = "agenticuseridentitycreated"
2126
USERWORKLOADONBOARDINGUPDATED = "agenticuserworkloadonboardingupdated"
22-
USERDELETED = "agenticuseridentitydeleted"
27+
USERDELETED = "agenticuserdeleted"
28+
USERUNDELETED = "agenticuserundeleted"
29+
USERUPDATED = "agenticuseridentityupdated"
30+
USERMANAGERUPDATED = "agenticusermanagerupdated"
31+
USERENABLED = "agenticuserenabled"
32+
USERDISABLED = "agenticuserdisabled"

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ def _configure_internal(
180180
token_resolver=exporter_options.token_resolver,
181181
cluster_category=exporter_options.cluster_category,
182182
use_s2s_endpoint=exporter_options.use_s2s_endpoint,
183+
max_payload_bytes=exporter_options.max_payload_bytes,
183184
)
184185

185186
else:

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py

Lines changed: 94 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@
1818
from opentelemetry.trace import StatusCode
1919

2020
from .utils import (
21+
DEFAULT_MAX_PAYLOAD_BYTES,
2122
build_export_url,
23+
chunk_by_size,
24+
estimate_span_bytes,
2225
get_validated_domain_override,
2326
hex_span_id,
2427
hex_trace_id,
2528
kind_name,
2629
parse_retry_after,
27-
partition_by_identity,
30+
filter_and_partition_by_identity,
2831
status_name,
2932
truncate_span,
3033
)
@@ -56,6 +59,7 @@ def __init__(
5659
token_resolver: Callable[[str, str], str | None],
5760
cluster_category: str = "prod",
5861
use_s2s_endpoint: bool = False,
62+
max_payload_bytes: int = DEFAULT_MAX_PAYLOAD_BYTES,
5963
):
6064
if token_resolver is None:
6165
raise ValueError("token_resolver must be provided.")
@@ -65,6 +69,7 @@ def __init__(
6569
self._token_resolver = token_resolver
6670
self._cluster_category = cluster_category
6771
self._use_s2s_endpoint = use_s2s_endpoint
72+
self._max_payload_bytes = max_payload_bytes
6873
# Read domain override once at initialization
6974
self._domain_override = get_validated_domain_override()
7075

@@ -75,10 +80,10 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
7580
return SpanExportResult.FAILURE
7681

7782
try:
78-
groups = partition_by_identity(spans)
83+
groups = filter_and_partition_by_identity(spans)
7984
if not groups:
80-
# No spans with identity; treat as success
81-
logger.info("No spans with tenant/agent identity found; nothing exported.")
85+
# No eligible genAI spans to export after filtering/partitioning; treat as success
86+
logger.info("No eligible genAI spans to export; nothing exported.")
8287
return SpanExportResult.SUCCESS
8388

8489
# Log number of groups and total span count
@@ -89,8 +94,21 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
8994

9095
any_failure = False
9196
for (tenant_id, agent_id), activities in groups.items():
92-
payload = self._build_export_request(activities)
93-
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
97+
# Map and truncate spans first, then chunk by estimated byte size
98+
mapped_spans = self._map_and_truncate_spans(activities)
99+
resource_attrs = self._get_resource_attributes(activities)
100+
chunks = chunk_by_size(
101+
mapped_spans,
102+
lambda ms: estimate_span_bytes(ms[0]),
103+
self._max_payload_bytes,
104+
)
105+
106+
if len(chunks) > 1:
107+
# Logged at DEBUG to avoid leaking tenant/agent IDs in production logs.
108+
logger.debug(
109+
f"Split {len(activities)} spans into {len(chunks)} chunks "
110+
f"for tenantId: {tenant_id}, agentId: {agent_id}"
111+
)
94112

95113
# Resolve endpoint: domain override > default URL
96114
if self._domain_override:
@@ -128,11 +146,40 @@ def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
128146
any_failure = True
129147
continue
130148

131-
# Basic retry loop
132-
ok = self._post_with_retries(url, body, headers)
133-
134-
if not ok:
135-
any_failure = True
149+
# Send each chunk (all-or-nothing: fail group on first chunk failure)
150+
group_failed = False
151+
for i, chunk in enumerate(chunks):
152+
payload = self._build_envelope(chunk, resource_attrs)
153+
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
154+
body_bytes = len(body.encode("utf-8"))
155+
logger.debug(
156+
f"Sending chunk {i + 1} of {len(chunks)} "
157+
f"({len(chunk)} spans, {body_bytes} bytes)"
158+
)
159+
# Defensive check: the estimator covers per-span content but not
160+
# envelope overhead (resource attributes, scope wrappers). Warn if
161+
# the assembled body exceeds the configured limit so operators can
162+
# observe estimator drift before the server starts rejecting requests.
163+
if body_bytes > self._max_payload_bytes:
164+
logger.warning(
165+
f"Chunk {i + 1} of {len(chunks)} body size ({body_bytes} bytes) "
166+
f"exceeds max_payload_bytes ({self._max_payload_bytes}); "
167+
"estimator may be under-counting envelope overhead. "
168+
f"Tenant: {tenant_id}, agent: {agent_id}, spans: {len(chunk)}."
169+
)
170+
171+
ok = self._post_with_retries(url, body, headers)
172+
if not ok:
173+
logger.error(
174+
f"Chunk {i + 1} of {len(chunks)} failed for "
175+
f"tenant {tenant_id}, agent {agent_id}"
176+
)
177+
any_failure = True
178+
group_failed = True
179+
break
180+
181+
if group_failed:
182+
continue
136183

137184
return SpanExportResult.FAILURE if any_failure else SpanExportResult.SUCCESS
138185

@@ -231,32 +278,47 @@ def _post_with_retries(self, url: str, body: str, headers: dict[str, str]) -> bo
231278

232279
# ------------- Payload mapping ------------------
233280

234-
def _build_export_request(self, spans: Sequence[ReadableSpan]) -> dict[str, Any]:
235-
# Group by instrumentation scope (name, version)
236-
scope_map: dict[tuple[str, str | None], list[dict[str, Any]]] = {}
281+
def _map_and_truncate_spans(
282+
self, spans: Sequence[ReadableSpan]
283+
) -> list[tuple[dict[str, Any], str, str | None]]:
284+
"""Map ReadableSpans to OTLP dicts and apply per-span truncation.
237285
286+
Returns a list of (mapped_span, scope_name, scope_version) tuples so
287+
that envelope grouping by instrumentation scope can be performed
288+
efficiently after byte-size chunking.
289+
"""
290+
result: list[tuple[dict[str, Any], str, str | None]] = []
238291
for sp in spans:
239292
scope = sp.instrumentation_scope
240-
scope_key = (scope.name, scope.version)
241-
scope_map.setdefault(scope_key, []).append(self._map_span(sp))
242-
243-
scope_spans: list[dict[str, Any]] = []
244-
for (name, version), mapped_spans in scope_map.items():
245-
scope_spans.append(
246-
{
247-
"scope": {
248-
"name": name,
249-
"version": version,
250-
},
251-
"spans": mapped_spans,
252-
}
253-
)
293+
scope_name = scope.name if scope is not None else "unknown"
294+
scope_version = scope.version if scope is not None else None
295+
result.append((self._map_span(sp), scope_name, scope_version))
296+
return result
254297

255-
# Resource attributes (from the first span – all spans in a batch usually share resource)
256-
# If you need to merge across spans, adapt accordingly.
257-
resource_attrs = {}
298+
@staticmethod
299+
def _get_resource_attributes(spans: Sequence[ReadableSpan]) -> dict[str, Any]:
300+
"""Extract resource attributes from the first span in the batch."""
258301
if spans:
259-
resource_attrs = dict(getattr(spans[0].resource, "attributes", {}) or {})
302+
return dict(getattr(spans[0].resource, "attributes", {}) or {})
303+
return {}
304+
305+
def _build_envelope(
306+
self,
307+
mapped_spans: Sequence[tuple[dict[str, Any], str, str | None]],
308+
resource_attrs: dict[str, Any],
309+
) -> dict[str, Any]:
310+
"""Build an OTLP export request envelope from pre-mapped spans."""
311+
scope_map: dict[tuple[str, str | None], list[dict[str, Any]]] = {}
312+
for mapped_span, scope_name, scope_version in mapped_spans:
313+
scope_map.setdefault((scope_name, scope_version), []).append(mapped_span)
314+
315+
scope_spans: list[dict[str, Any]] = [
316+
{
317+
"scope": {"name": name, "version": version},
318+
"spans": spans,
319+
}
320+
for (name, version), spans in scope_map.items()
321+
]
260322

261323
return {
262324
"resourceSpans": [

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter_options.py

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

44
from typing import Awaitable, Callable, Optional
55

6+
from .utils import DEFAULT_MAX_PAYLOAD_BYTES
7+
68

79
class Agent365ExporterOptions:
810
"""
@@ -19,6 +21,7 @@ def __init__(
1921
scheduled_delay_ms: int = 5000,
2022
exporter_timeout_ms: int = 30000,
2123
max_export_batch_size: int = 512,
24+
max_payload_bytes: int = DEFAULT_MAX_PAYLOAD_BYTES,
2225
):
2326
"""
2427
Args:
@@ -29,6 +32,10 @@ def __init__(
2932
scheduled_delay_ms: Delay between export batches (ms). Default is 5000.
3033
exporter_timeout_ms: Timeout for the export operation (ms). Default is 30000.
3134
max_export_batch_size: Maximum batch size for export operations. Default is 512.
35+
max_payload_bytes: Upper bound on HTTP request body size in bytes. The exporter
36+
splits per-identity batches into sub-batches whose estimated size stays under
37+
this limit, providing headroom under the A365 1 MB server limit. Default is
38+
900_000 (~100 KB headroom for estimator error and JSON envelope overhead).
3239
"""
3340
self.cluster_category = cluster_category
3441
self.token_resolver = token_resolver
@@ -37,3 +44,4 @@ def __init__(
3744
self.scheduled_delay_ms = scheduled_delay_ms
3845
self.exporter_timeout_ms = exporter_timeout_ms
3946
self.max_export_batch_size = max_export_batch_size
47+
self.max_payload_bytes = max_payload_bytes

0 commit comments

Comments
 (0)