Skip to content

Commit 91d0c69

Browse files
authored
Merge branch 'main' into feature/filter-genai-spans
2 parents 365af51 + d08cf0a commit 91d0c69

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
@@ -302,3 +302,133 @@ def is_agent365_exporter_enabled() -> bool:
302302
# Check environment variable
303303
enable_exporter = os.getenv(ENABLE_A365_OBSERVABILITY_EXPORTER, "").lower()
304304
return (enable_exporter) in ("true", "1", "yes", "on")
305+
306+
307+
# ---------------------------------------------------------------------------
308+
# Span size estimation and byte-level chunking
309+
# ---------------------------------------------------------------------------
310+
311+
# Default upper bound on HTTP request body size in bytes. Provides ~100 KB
312+
# headroom under the A365 1 MB server limit for estimator error and JSON/
313+
# envelope overhead (e.g. resource attributes and scope wrappers).
314+
DEFAULT_MAX_PAYLOAD_BYTES = 900_000
315+
316+
# Overhead constant for OTLP JSON span fixed fields (traceId, spanId,
317+
# parentSpanId, kind, timestamps, status, scope wrapper, etc.). Intentionally
318+
# generous to account for serializer variance.
319+
_SPAN_BASE_OVERHEAD = 2000
320+
321+
# Overhead per attribute in OTLP JSON format. Covers key/value JSON wrapping.
322+
_ATTR_OVERHEAD = 80
323+
324+
# Overhead per event in OTLP JSON.
325+
_EVENT_OVERHEAD = 200
326+
327+
328+
def _utf8_len(s: str) -> int:
329+
return len(s.encode("utf-8"))
330+
331+
332+
def estimate_value_bytes(value: Any) -> int:
333+
"""Estimate the serialized byte size of a single attribute value in OTLP/HTTP JSON."""
334+
if isinstance(value, str):
335+
return 40 + _utf8_len(value)
336+
# bool is a subclass of int; check before sequence/list handling below
337+
if isinstance(value, bool):
338+
return 40
339+
if isinstance(value, (list, tuple)):
340+
if len(value) == 0:
341+
return 60
342+
first = value[0]
343+
if isinstance(first, str):
344+
total = 60
345+
for s in value:
346+
total += 40 + _utf8_len(str(s))
347+
return total
348+
return 60 + 50 * len(value)
349+
return 40 # int/float/None/other
350+
351+
352+
def estimate_span_bytes(span: dict[str, Any]) -> int:
353+
"""Heuristic estimator for the serialized size of an OTLP span in HTTP JSON.
354+
355+
Uses generous constants tuned to over-estimate by ~25-50%, providing
356+
headroom for JSON serializer variance (whitespace, enum representation,
357+
integer-as-string).
358+
"""
359+
total = _SPAN_BASE_OVERHEAD
360+
name = span.get("name")
361+
if isinstance(name, str):
362+
total += _utf8_len(name)
363+
364+
attributes = span.get("attributes")
365+
if attributes:
366+
for key, value in attributes.items():
367+
total += _ATTR_OVERHEAD
368+
total += _utf8_len(str(key))
369+
total += estimate_value_bytes(value)
370+
371+
events = span.get("events")
372+
if events:
373+
for ev in events:
374+
total += _EVENT_OVERHEAD
375+
ev_name = ev.get("name") if isinstance(ev, dict) else None
376+
if isinstance(ev_name, str):
377+
total += _utf8_len(ev_name)
378+
ev_attrs = ev.get("attributes") if isinstance(ev, dict) else None
379+
if ev_attrs:
380+
for key, value in ev_attrs.items():
381+
total += _ATTR_OVERHEAD
382+
total += _utf8_len(str(key))
383+
total += estimate_value_bytes(value)
384+
return total
385+
386+
387+
T = TypeVar("T")
388+
389+
390+
def chunk_by_size(
391+
items: Sequence[T],
392+
get_size: Callable[[T], int],
393+
max_chunk_bytes: int,
394+
) -> list[list[T]]:
395+
"""Split items into sub-batches whose cumulative estimated size stays under ``max_chunk_bytes``.
396+
397+
Multi-item chunks are guaranteed to stay within the limit. A single item
398+
whose estimated size exceeds ``max_chunk_bytes`` forms its own one-item
399+
chunk (never silently dropped) even though that chunk exceeds the limit.
400+
401+
Invariants:
402+
- Input order is preserved across chunks.
403+
- Empty input produces empty output.
404+
- No item is ever dropped.
405+
- No chunk is ever empty.
406+
407+
Raises:
408+
ValueError: If ``max_chunk_bytes`` is not positive, or if ``get_size``
409+
returns a negative value for any item.
410+
"""
411+
if max_chunk_bytes <= 0:
412+
raise ValueError(f"max_chunk_bytes must be positive, got {max_chunk_bytes}")
413+
414+
chunks: list[list[T]] = []
415+
current: list[T] = []
416+
current_bytes = 0
417+
418+
for item in items:
419+
item_bytes = get_size(item)
420+
if item_bytes < 0:
421+
raise ValueError(
422+
f"get_size returned a negative value ({item_bytes}); sizes must be non-negative"
423+
)
424+
if current and current_bytes + item_bytes > max_chunk_bytes:
425+
chunks.append(current)
426+
current = []
427+
current_bytes = 0
428+
current.append(item)
429+
current_bytes += item_bytes
430+
431+
if current:
432+
chunks.append(current)
433+
434+
return chunks

0 commit comments

Comments
 (0)