diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py index 53989833..b09aa67f 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/agent_details.py @@ -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.""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py index 823bc4c6..46634e5a 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/constants.py @@ -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" @@ -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" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py index efe6c0e3..2824416e 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/agent365_exporter.py @@ -23,6 +23,7 @@ kind_name, partition_by_identity, status_name, + truncate_span, ) # ---- Exporter --------------------------------------------------------------- @@ -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, @@ -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) diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py index 2d57a607..3eda274f 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/exporters/utils.py @@ -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 @@ -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 @@ -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]]: diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py index b0d6fa2b..9dc30067 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/invoke_agent_scope.py @@ -3,6 +3,8 @@ # Invoke agent scope for tracing agent invocation. +import logging + from .agent_details import AgentDetails from .constants import ( GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY, @@ -10,6 +12,7 @@ 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, @@ -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): @@ -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. diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py index b321306d..9736dd16 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py @@ -2,6 +2,7 @@ # Per request baggage builder for OpenTelemetry context propagation. +import logging from typing import Any from opentelemetry import baggage, context @@ -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, @@ -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. @@ -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) @@ -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).""" diff --git a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py index b1461b3f..73622815 100644 --- a/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py +++ b/libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/utils.py @@ -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 @@ -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 diff --git a/tests/observability/core/exporters/test_utils.py b/tests/observability/core/exporters/test_utils.py new file mode 100644 index 00000000..9bdeedba --- /dev/null +++ b/tests/observability/core/exporters/test_utils.py @@ -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() diff --git a/tests/observability/core/test_baggage_builder.py b/tests/observability/core/test_baggage_builder.py index df0bbe9b..196a2414 100644 --- a/tests/observability/core/test_baggage_builder.py +++ b/tests/observability/core/test_baggage_builder.py @@ -10,6 +10,7 @@ GEN_AI_AGENT_BLUEPRINT_ID_KEY, GEN_AI_AGENT_ID_KEY, GEN_AI_AGENT_UPN_KEY, + GEN_AI_CALLER_CLIENT_IP_KEY, GEN_AI_CALLER_ID_KEY, GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, GEN_AI_EXECUTION_SOURCE_NAME_KEY, @@ -94,6 +95,7 @@ def test_all_baggage_keys(self): .agent_blueprint_id("blueprint-1") .correlation_id("corr-1") .caller_id("caller-1") + .caller_client_ip("192.168.1.100") .hiring_manager_id("manager-1") .build() ): @@ -106,6 +108,7 @@ def test_all_baggage_keys(self): self.assertEqual(current_baggage.get(GEN_AI_AGENT_BLUEPRINT_ID_KEY), "blueprint-1") self.assertEqual(current_baggage.get(CORRELATION_ID_KEY), "corr-1") self.assertEqual(current_baggage.get(GEN_AI_CALLER_ID_KEY), "caller-1") + self.assertEqual(current_baggage.get(GEN_AI_CALLER_CLIENT_IP_KEY), "192.168.1.100") self.assertEqual(current_baggage.get(HIRING_MANAGER_ID_KEY), "manager-1") print("✅ All baggage keys work correctly!") @@ -363,6 +366,33 @@ def test_channel_links_method(self): "https://teams.microsoft.com/channel/123", ) + def test_caller_client_ip_method(self): + """Test caller_client_ip method sets client IP baggage with validation.""" + # Should exist and be callable + self.assertTrue(hasattr(self.builder, "caller_client_ip")) + self.assertTrue(callable(self.builder.caller_client_ip)) + + # Test valid IPv4 address + with BaggageBuilder().caller_client_ip("192.168.1.100").build(): + current_baggage = baggage.get_all() + self.assertEqual(current_baggage.get(GEN_AI_CALLER_CLIENT_IP_KEY), "192.168.1.100") + + # Test valid IPv6 address + with BaggageBuilder().caller_client_ip("2001:db8::1").build(): + current_baggage = baggage.get_all() + self.assertEqual(current_baggage.get(GEN_AI_CALLER_CLIENT_IP_KEY), "2001:db8::1") + + # Test None value (should not set baggage) + with BaggageBuilder().caller_client_ip(None).build(): + current_baggage = baggage.get_all() + self.assertIsNone(current_baggage.get(GEN_AI_CALLER_CLIENT_IP_KEY)) + + # Test invalid IP address (should be handled gracefully now) + with BaggageBuilder().caller_client_ip("not.an.ip.address").build(): + current_baggage = baggage.get_all() + # Should be None due to proper exception handling + self.assertIsNone(current_baggage.get(GEN_AI_CALLER_CLIENT_IP_KEY)) + if __name__ == "__main__": unittest.main() diff --git a/tests/observability/core/test_invoke_agent_scope.py b/tests/observability/core/test_invoke_agent_scope.py index 14fbda96..924d469d 100644 --- a/tests/observability/core/test_invoke_agent_scope.py +++ b/tests/observability/core/test_invoke_agent_scope.py @@ -14,18 +14,19 @@ TenantDetails, configure, ) +from microsoft_agents_a365.observability.core.constants import ( + GEN_AI_CALLER_AGENT_USER_CLIENT_IP, + GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY, + GEN_AI_EXECUTION_SOURCE_NAME_KEY, + GEN_AI_EXECUTION_TYPE_KEY, + GEN_AI_INPUT_MESSAGES_KEY, +) from microsoft_agents_a365.observability.core.models.caller_details import CallerDetails from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter -# Constants for span attribute keys -GEN_AI_EXECUTION_SOURCE_NAME_KEY = "gen_ai.channel.name" -GEN_AI_EXECUTION_SOURCE_DESCRIPTION_KEY = "gen_ai.channel.link" -GEN_AI_EXECUTION_TYPE_KEY = "gen_ai.execution.type" -GEN_AI_INPUT_MESSAGES_KEY = "gen_ai.input.messages" - class TestInvokeAgentScope(unittest.TestCase): """Unit tests for InvokeAgentScope and its methods.""" @@ -85,6 +86,7 @@ def setUpClass(cls): agent_auid="auid-123", agent_upn="agent@contoso.com", tenant_id="tenant-789", + agent_client_ip="192.168.1.100", ) def test_record_response_method_exists(self): @@ -173,6 +175,38 @@ def test_request_attributes_set_on_span(self): input_messages, ) + def test_caller_agent_client_ip_in_scope(self): + """Test that caller agent client IP is properly handled when creating InvokeAgentScope.""" + # Set up tracer to capture spans + span_exporter = InMemorySpanExporter() + tracer_provider = TracerProvider() + tracer_provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + trace.set_tracer_provider(tracer_provider) + + # Create scope with caller agent details that include client IP + scope = InvokeAgentScope.start( + invoke_agent_details=self.invoke_details, + tenant_details=self.tenant_details, + caller_agent_details=self.caller_agent_details, # Contains agent_client_ip="192.168.1.100" + ) + + if scope is not None: + # Verify the caller agent details contain the expected IP + self.assertEqual(self.caller_agent_details.agent_client_ip, "192.168.1.100") + scope.dispose() + + # Verify the IP is set as a span attribute + finished_spans = span_exporter.get_finished_spans() + if finished_spans: + span = finished_spans[-1] + span_attributes = getattr(span, "attributes", {}) or {} + + # Verify the caller agent client IP is set as a span attribute + if GEN_AI_CALLER_AGENT_USER_CLIENT_IP in span_attributes: + self.assertEqual( + span_attributes[GEN_AI_CALLER_AGENT_USER_CLIENT_IP], "192.168.1.100" + ) + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/tests/observability/core/test_utils.py b/tests/observability/core/test_utils.py new file mode 100644 index 00000000..b82d6d71 --- /dev/null +++ b/tests/observability/core/test_utils.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import unittest + +from microsoft_agents_a365.observability.core.utils import validate_and_normalize_ip + + +class TestUtils(unittest.TestCase): + """Unit tests for utility functions.""" + + def test_validate_and_normalize_ip(self): + """Test validate_and_normalize_ip with various IP address scenarios.""" + # Valid IPv4 and IPv6 addresses + self.assertEqual(validate_and_normalize_ip("192.168.1.1"), "192.168.1.1") + self.assertEqual(validate_and_normalize_ip("2001:db8::1"), "2001:db8::1") + + # IPv6 normalization + self.assertEqual( + validate_and_normalize_ip("2001:0db8:0000:0000:0000:0000:0000:0001"), "2001:db8::1" + ) + + # Invalid IP addresses and edge cases + self.assertIsNone(validate_and_normalize_ip("256.1.1.1")) + self.assertIsNone(validate_and_normalize_ip("not.an.ip.address")) + self.assertIsNone(validate_and_normalize_ip("2001:db8::g1")) + self.assertIsNone(validate_and_normalize_ip(None)) + self.assertIsNone(validate_and_normalize_ip("")) + + +if __name__ == "__main__": + unittest.main()