Skip to content

Commit 9013a49

Browse files
authored
Merge branch 'main' into copilot/fix-broken-links-documentation
2 parents f40ae43 + d08cf0a commit 9013a49

7 files changed

Lines changed: 474 additions & 30 deletions

File tree

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: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
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,
@@ -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

@@ -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

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

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
import json
77
import logging
88
import os
9-
from collections.abc import Sequence
10-
from typing import Any
9+
from collections.abc import Callable, Sequence
10+
from typing import Any, TypeVar
1111
from urllib.parse import urlparse
1212

1313
from opentelemetry.sdk.trace import ReadableSpan
@@ -262,3 +262,133 @@ def is_agent365_exporter_enabled() -> bool:
262262
# Check environment variable
263263
enable_exporter = os.getenv(ENABLE_A365_OBSERVABILITY_EXPORTER, "").lower()
264264
return (enable_exporter) in ("true", "1", "yes", "on")
265+
266+
267+
# ---------------------------------------------------------------------------
268+
# Span size estimation and byte-level chunking
269+
# ---------------------------------------------------------------------------
270+
271+
# Default upper bound on HTTP request body size in bytes. Provides ~100 KB
272+
# headroom under the A365 1 MB server limit for estimator error and JSON/
273+
# envelope overhead (e.g. resource attributes and scope wrappers).
274+
DEFAULT_MAX_PAYLOAD_BYTES = 900_000
275+
276+
# Overhead constant for OTLP JSON span fixed fields (traceId, spanId,
277+
# parentSpanId, kind, timestamps, status, scope wrapper, etc.). Intentionally
278+
# generous to account for serializer variance.
279+
_SPAN_BASE_OVERHEAD = 2000
280+
281+
# Overhead per attribute in OTLP JSON format. Covers key/value JSON wrapping.
282+
_ATTR_OVERHEAD = 80
283+
284+
# Overhead per event in OTLP JSON.
285+
_EVENT_OVERHEAD = 200
286+
287+
288+
def _utf8_len(s: str) -> int:
289+
return len(s.encode("utf-8"))
290+
291+
292+
def estimate_value_bytes(value: Any) -> int:
293+
"""Estimate the serialized byte size of a single attribute value in OTLP/HTTP JSON."""
294+
if isinstance(value, str):
295+
return 40 + _utf8_len(value)
296+
# bool is a subclass of int; check before sequence/list handling below
297+
if isinstance(value, bool):
298+
return 40
299+
if isinstance(value, (list, tuple)):
300+
if len(value) == 0:
301+
return 60
302+
first = value[0]
303+
if isinstance(first, str):
304+
total = 60
305+
for s in value:
306+
total += 40 + _utf8_len(str(s))
307+
return total
308+
return 60 + 50 * len(value)
309+
return 40 # int/float/None/other
310+
311+
312+
def estimate_span_bytes(span: dict[str, Any]) -> int:
313+
"""Heuristic estimator for the serialized size of an OTLP span in HTTP JSON.
314+
315+
Uses generous constants tuned to over-estimate by ~25-50%, providing
316+
headroom for JSON serializer variance (whitespace, enum representation,
317+
integer-as-string).
318+
"""
319+
total = _SPAN_BASE_OVERHEAD
320+
name = span.get("name")
321+
if isinstance(name, str):
322+
total += _utf8_len(name)
323+
324+
attributes = span.get("attributes")
325+
if attributes:
326+
for key, value in attributes.items():
327+
total += _ATTR_OVERHEAD
328+
total += _utf8_len(str(key))
329+
total += estimate_value_bytes(value)
330+
331+
events = span.get("events")
332+
if events:
333+
for ev in events:
334+
total += _EVENT_OVERHEAD
335+
ev_name = ev.get("name") if isinstance(ev, dict) else None
336+
if isinstance(ev_name, str):
337+
total += _utf8_len(ev_name)
338+
ev_attrs = ev.get("attributes") if isinstance(ev, dict) else None
339+
if ev_attrs:
340+
for key, value in ev_attrs.items():
341+
total += _ATTR_OVERHEAD
342+
total += _utf8_len(str(key))
343+
total += estimate_value_bytes(value)
344+
return total
345+
346+
347+
T = TypeVar("T")
348+
349+
350+
def chunk_by_size(
351+
items: Sequence[T],
352+
get_size: Callable[[T], int],
353+
max_chunk_bytes: int,
354+
) -> list[list[T]]:
355+
"""Split items into sub-batches whose cumulative estimated size stays under ``max_chunk_bytes``.
356+
357+
Multi-item chunks are guaranteed to stay within the limit. A single item
358+
whose estimated size exceeds ``max_chunk_bytes`` forms its own one-item
359+
chunk (never silently dropped) even though that chunk exceeds the limit.
360+
361+
Invariants:
362+
- Input order is preserved across chunks.
363+
- Empty input produces empty output.
364+
- No item is ever dropped.
365+
- No chunk is ever empty.
366+
367+
Raises:
368+
ValueError: If ``max_chunk_bytes`` is not positive, or if ``get_size``
369+
returns a negative value for any item.
370+
"""
371+
if max_chunk_bytes <= 0:
372+
raise ValueError(f"max_chunk_bytes must be positive, got {max_chunk_bytes}")
373+
374+
chunks: list[list[T]] = []
375+
current: list[T] = []
376+
current_bytes = 0
377+
378+
for item in items:
379+
item_bytes = get_size(item)
380+
if item_bytes < 0:
381+
raise ValueError(
382+
f"get_size returned a negative value ({item_bytes}); sizes must be non-negative"
383+
)
384+
if current and current_bytes + item_bytes > max_chunk_bytes:
385+
chunks.append(current)
386+
current = []
387+
current_bytes = 0
388+
current.append(item)
389+
current_bytes += item_bytes
390+
391+
if current:
392+
chunks.append(current)
393+
394+
return chunks

0 commit comments

Comments
 (0)