Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,25 @@ class AgentDetails:
"""A description of the AI agent's purpose or capabilities."""

agent_auid: Optional[str] = None
"""Optional Agent User ID for the agent."""
"""Agentic User ID for the agent."""

agent_upn: Optional[str] = None
"""Optional User Principal Name (UPN) for the agent."""
"""User Principal Name (UPN) for the agentic user."""

agent_blueprint_id: Optional[str] = None
"""Optional Blueprint/Application ID for the agent."""
"""Blueprint/Application ID for the agent."""

agent_type: Optional[AgentType] = None
"""The agent type."""

tenant_id: Optional[str] = None
"""Optional Tenant ID for the agent."""
"""Tenant ID for the agent."""

conversation_id: Optional[str] = None
"""Optional conversation ID for compatibility."""

icon_uri: Optional[str] = None
"""Optional icon URI for the agent."""

agent_client_ip: Optional[str] = None
"""Client IP address of the agent user."""
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
GEN_AI_CALLER_ID_KEY = "gen_ai.caller.id"
GEN_AI_CALLER_NAME_KEY = "gen_ai.caller.name"
GEN_AI_CALLER_UPN_KEY = "gen_ai.caller.upn"
GEN_AI_CALLER_CLIENT_IP_KEY = "gen_ai.caller.client.ip"

# Agent to Agent caller agent dimensions
GEN_AI_CALLER_AGENT_USER_ID_KEY = "gen_ai.caller.agent.userid"
Expand All @@ -77,6 +78,7 @@
GEN_AI_CALLER_AGENT_ID_KEY = "gen_ai.caller.agent.id"
GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY = "gen_ai.caller.agent.applicationid"
GEN_AI_CALLER_AGENT_TYPE_KEY = "gen_ai.caller.agent.type"
GEN_AI_CALLER_AGENT_USER_CLIENT_IP = "gen_ai.caller.agent.user.client.ip"

# Agent-specific dimensions
AGENT_ID_KEY = "gen_ai.agent.id"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
kind_name,
partition_by_identity,
status_name,
truncate_span,
)

# ---- Exporter ---------------------------------------------------------------
Expand Down Expand Up @@ -295,7 +296,7 @@ def _map_span(self, sp: ReadableSpan) -> dict[str, Any]:
start_ns = sp.start_time
end_ns = sp.end_time

return {
span_dict = {
"traceId": hex_trace_id(ctx.trace_id),
"spanId": hex_span_id(ctx.span_id),
"parentSpanId": parent_span_id,
Expand All @@ -308,3 +309,6 @@ def _map_span(self, sp: ReadableSpan) -> dict[str, Any]:
"links": links,
"status": status,
}

# Apply truncation if needed
return truncate_span(span_dict)
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.

import json
import logging
import os
from collections.abc import Sequence
from typing import Any
Expand All @@ -13,6 +15,11 @@
TENANT_ID_KEY,
)

logger = logging.getLogger(__name__)

# Maximum allowed span size in bytes (250KB)
MAX_SPAN_SIZE_BYTES = 250 * 1024


def hex_trace_id(value: int) -> str:
# 128-bit -> 32 hex chars
Expand Down Expand Up @@ -46,6 +53,76 @@ def status_name(code: StatusCode) -> str:
return str(code)


def truncate_span(span_dict: dict[str, Any]) -> dict[str, Any]:
"""
Truncate span attributes if the serialized span exceeds MAX_SPAN_SIZE_BYTES.

Args:
span_dict: The span dictionary to potentially truncate

Returns:
The potentially truncated span dictionary
"""
try:
# Serialize the span to check its size
serialized = json.dumps(span_dict, separators=(",", ":"))
current_size = len(serialized.encode("utf-8"))

if current_size <= MAX_SPAN_SIZE_BYTES:
return span_dict

logger.warning(
f"Span size ({current_size} bytes) exceeds limit ({MAX_SPAN_SIZE_BYTES} bytes). "
"Truncating large payload attributes."
)

# Create a deep copy to modify (shallow copy would still reference original attributes)
truncated_span = span_dict.copy()
if "attributes" in truncated_span:
truncated_span["attributes"] = truncated_span["attributes"].copy()
attributes = truncated_span.get("attributes", {})

# Track what was truncated for logging
truncated_keys = []

# Sort attributes by size (largest first) and truncate until size is acceptable
if attributes:
# Calculate size of each attribute value when serialized
attr_sizes = []
for key, value in attributes.items():
try:
value_size = len(json.dumps(value, separators=(",", ":")).encode("utf-8"))
attr_sizes.append((key, value_size))
except Exception:
# If we can't serialize the value, assume it's small
attr_sizes.append((key, 0))

# Sort by size (descending - largest first)
attr_sizes.sort(key=lambda x: x[1], reverse=True)

# Truncate largest attributes first until size is acceptable
for key, _ in attr_sizes:
if key in attributes:
attributes[key] = "TRUNCATED"
truncated_keys.append(key)

# Check size after truncation
serialized = json.dumps(truncated_span, separators=(",", ":"))
current_size = len(serialized.encode("utf-8"))

if current_size <= MAX_SPAN_SIZE_BYTES:
break

if truncated_keys:
logger.info(f"Truncated attributes: {', '.join(truncated_keys)}")

return truncated_span

except Exception as e:
logger.error(f"Error during span truncation: {e}")
return span_dict


def partition_by_identity(
spans: Sequence[ReadableSpan],
) -> dict[tuple[str, str], list[ReadableSpan]]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@

# Invoke agent scope for tracing agent invocation.

import logging

from .agent_details import AgentDetails
from .constants import (
GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY,
GEN_AI_CALLER_AGENT_ID_KEY,
GEN_AI_CALLER_AGENT_NAME_KEY,
GEN_AI_CALLER_AGENT_TENANT_ID_KEY,
GEN_AI_CALLER_AGENT_UPN_KEY,
GEN_AI_CALLER_AGENT_USER_CLIENT_IP,
GEN_AI_CALLER_AGENT_USER_ID_KEY,
GEN_AI_CALLER_ID_KEY,
GEN_AI_CALLER_NAME_KEY,
Expand All @@ -31,7 +34,9 @@
from .opentelemetry_scope import OpenTelemetryScope
from .request import Request
from .tenant_details import TenantDetails
from .utils import safe_json_dumps
from .utils import safe_json_dumps, validate_and_normalize_ip

logger = logging.getLogger(__name__)


class InvokeAgentScope(OpenTelemetryScope):
Expand Down Expand Up @@ -139,6 +144,11 @@ def __init__(
self.set_tag_maybe(GEN_AI_CALLER_AGENT_USER_ID_KEY, caller_agent_details.agent_auid)
self.set_tag_maybe(GEN_AI_CALLER_AGENT_UPN_KEY, caller_agent_details.agent_upn)
self.set_tag_maybe(GEN_AI_CALLER_AGENT_TENANT_ID_KEY, caller_agent_details.tenant_id)
# Validate and set caller agent client IP
self.set_tag_maybe(
GEN_AI_CALLER_AGENT_USER_CLIENT_IP,
validate_and_normalize_ip(caller_agent_details.agent_client_ip),
)

def record_response(self, response: str) -> None:
"""Record response information for telemetry tracking.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

# Per request baggage builder for OpenTelemetry context propagation.

import logging
from typing import Any

from opentelemetry import baggage, context
Expand All @@ -14,6 +15,7 @@
GEN_AI_AGENT_ID_KEY,
GEN_AI_AGENT_NAME_KEY,
GEN_AI_AGENT_UPN_KEY,
GEN_AI_CALLER_CLIENT_IP_KEY,
GEN_AI_CALLER_ID_KEY,
GEN_AI_CALLER_NAME_KEY,
GEN_AI_CALLER_UPN_KEY,
Expand All @@ -28,9 +30,11 @@
TENANT_ID_KEY,
)
from ..models.operation_source import OperationSource
from ..utils import deprecated
from ..utils import deprecated, validate_and_normalize_ip
from .turn_context_baggage import from_turn_context

logger = logging.getLogger(__name__)


class BaggageBuilder:
"""Per request baggage builder.
Expand Down Expand Up @@ -183,6 +187,11 @@ def caller_upn(self, value: str | None) -> "BaggageBuilder":
self._set(GEN_AI_CALLER_UPN_KEY, value)
return self

def caller_client_ip(self, value: str | None) -> "BaggageBuilder":
"""Set the caller client IP baggage value."""
self._set(GEN_AI_CALLER_CLIENT_IP_KEY, validate_and_normalize_ip(value))
return self

def conversation_id(self, value: str | None) -> "BaggageBuilder":
"""Set the conversation ID baggage value."""
self._set(GEN_AI_CONVERSATION_ID_KEY, value)
Expand All @@ -193,11 +202,6 @@ def conversation_item_link(self, value: str | None) -> "BaggageBuilder":
self._set(GEN_AI_CONVERSATION_ITEM_LINK_KEY, value)
return self

@deprecated("This is a no-op. Use channel_name() or channel_links() instead.")
def source_metadata_id(self, value: str | None) -> "BaggageBuilder":
"""Set the execution source metadata ID (e.g., channel ID)."""
return self

@deprecated("Use channel_name() instead")
def source_metadata_name(self, value: str | None) -> "BaggageBuilder":
"""Set the execution source metadata name (e.g., channel name)."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import warnings
from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping
from enum import Enum
from ipaddress import AddressValueError, ip_address
from threading import RLock
from typing import Any, Generic, TypeVar, cast

Expand Down Expand Up @@ -173,3 +174,27 @@ def wrapper(*args, **kwargs):
return wrapper

return decorator


def validate_and_normalize_ip(ip_string: str | None) -> str | None:
"""Validate and normalize an IP address string.

Args:
ip_string: The IP address string to validate (IPv4 or IPv6)

Returns:
The normalized IP address string if valid, None if invalid or None input

Logs:
Error message if the IP address is invalid
"""
if ip_string is None:
return None

try:
# Validate and normalize IP address
ip_obj = ip_address(ip_string)
return str(ip_obj)
except (ValueError, AddressValueError):
logger.error(f"Invalid IP address: '{ip_string}'")
return None
68 changes: 68 additions & 0 deletions tests/observability/core/exporters/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

import unittest

from microsoft_agents_a365.observability.core.exporters.utils import (
truncate_span,
)


class TestUtils(unittest.TestCase):
"""Unit tests for utility functions."""

def test_truncate_span_if_needed(self):
"""Test truncate_span_if_needed with various span sizes."""
# Small span - should return unchanged
small_span = {
"traceId": "abc123",
"spanId": "def456",
"name": "small_span",
"attributes": {"key1": "value1", "key2": "value2"},
}
result = truncate_span(small_span)
self.assertIsNotNone(result)
self.assertEqual(result["name"], "small_span")
self.assertEqual(result["attributes"]["key1"], "value1")

# Large span with large payload attributes - should truncate attributes
large_span = {
"traceId": "abc123",
"spanId": "def456",
"name": "large_span",
"attributes": {
"gen_ai.system": "openai",
"gen_ai.request.model": "gpt-4",
"gen_ai.response.model": "gpt-4",
"gen_ai.input.messages": "x" * 150000, # Large payload
"gen_ai.output.messages": "y" * 150000, # Large payload
"gen_ai.sample.attribute": "x" * 250000, # Large payload
"small_attr": "small_value",
},
}
result = truncate_span(large_span)
self.assertIsNotNone(result)
# The largest attributes should be truncated first
self.assertEqual(result["attributes"]["gen_ai.input.messages"], "TRUNCATED")
self.assertEqual(result["attributes"]["small_attr"], "small_value") # Unchanged
self.assertEqual(result["attributes"]["gen_ai.sample.attribute"], "TRUNCATED")

# Extremely large span - should return truncated span even if still large
extreme_span = {
"traceId": "abc123",
"spanId": "def456",
"name": "extreme_span",
"attributes": {f"attr_{i}": "x" * 10000 for i in range(100)}, # Many large attributes
"events": [
{"name": f"event_{i}", "attributes": {"data": "y" * 10000}} for i in range(50)
],
}
result = truncate_span(extreme_span)
self.assertIsNotNone(result) # Should always return a span, even if still large
# All attributes should be truncated due to size
for key in result["attributes"]:
self.assertEqual(result["attributes"][key], "TRUNCATED")


if __name__ == "__main__":
unittest.main()
Loading
Loading