Skip to content

Commit 4592dc0

Browse files
Tasks (#75)
* channel should flow to all scopes * track client ip address * track client ip address - add tests * truncate large spans * fix linting * address pr comments --------- Co-authored-by: Nikhil Chitlur Navakiran (from Dev Box) <[email protected]>
1 parent bf32f95 commit 4592dc0

File tree

11 files changed

+307
-18
lines changed

11 files changed

+307
-18
lines changed

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,25 @@ class AgentDetails:
2121
"""A description of the AI agent's purpose or capabilities."""
2222

2323
agent_auid: Optional[str] = None
24-
"""Optional Agent User ID for the agent."""
24+
"""Agentic User ID for the agent."""
2525

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

2929
agent_blueprint_id: Optional[str] = None
30-
"""Optional Blueprint/Application ID for the agent."""
30+
"""Blueprint/Application ID for the agent."""
3131

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

3535
tenant_id: Optional[str] = None
36-
"""Optional Tenant ID for the agent."""
36+
"""Tenant ID for the agent."""
3737

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

4141
icon_uri: Optional[str] = None
4242
"""Optional icon URI for the agent."""
43+
44+
agent_client_ip: Optional[str] = None
45+
"""Client IP address of the agent user."""

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
GEN_AI_CALLER_ID_KEY = "gen_ai.caller.id"
6969
GEN_AI_CALLER_NAME_KEY = "gen_ai.caller.name"
7070
GEN_AI_CALLER_UPN_KEY = "gen_ai.caller.upn"
71+
GEN_AI_CALLER_CLIENT_IP_KEY = "gen_ai.caller.client.ip"
7172

7273
# Agent to Agent caller agent dimensions
7374
GEN_AI_CALLER_AGENT_USER_ID_KEY = "gen_ai.caller.agent.userid"
@@ -77,6 +78,7 @@
7778
GEN_AI_CALLER_AGENT_ID_KEY = "gen_ai.caller.agent.id"
7879
GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY = "gen_ai.caller.agent.applicationid"
7980
GEN_AI_CALLER_AGENT_TYPE_KEY = "gen_ai.caller.agent.type"
81+
GEN_AI_CALLER_AGENT_USER_CLIENT_IP = "gen_ai.caller.agent.user.client.ip"
8082

8183
# Agent-specific dimensions
8284
AGENT_ID_KEY = "gen_ai.agent.id"

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
kind_name,
2424
partition_by_identity,
2525
status_name,
26+
truncate_span,
2627
)
2728

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

298-
return {
299+
span_dict = {
299300
"traceId": hex_trace_id(ctx.trace_id),
300301
"spanId": hex_span_id(ctx.span_id),
301302
"parentSpanId": parent_span_id,
@@ -308,3 +309,6 @@ def _map_span(self, sp: ReadableSpan) -> dict[str, Any]:
308309
"links": links,
309310
"status": status,
310311
}
312+
313+
# Apply truncation if needed
314+
return truncate_span(span_dict)

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

3+
import json
4+
import logging
35
import os
46
from collections.abc import Sequence
57
from typing import Any
@@ -13,6 +15,11 @@
1315
TENANT_ID_KEY,
1416
)
1517

18+
logger = logging.getLogger(__name__)
19+
20+
# Maximum allowed span size in bytes (250KB)
21+
MAX_SPAN_SIZE_BYTES = 250 * 1024
22+
1623

1724
def hex_trace_id(value: int) -> str:
1825
# 128-bit -> 32 hex chars
@@ -46,6 +53,76 @@ def status_name(code: StatusCode) -> str:
4653
return str(code)
4754

4855

56+
def truncate_span(span_dict: dict[str, Any]) -> dict[str, Any]:
57+
"""
58+
Truncate span attributes if the serialized span exceeds MAX_SPAN_SIZE_BYTES.
59+
60+
Args:
61+
span_dict: The span dictionary to potentially truncate
62+
63+
Returns:
64+
The potentially truncated span dictionary
65+
"""
66+
try:
67+
# Serialize the span to check its size
68+
serialized = json.dumps(span_dict, separators=(",", ":"))
69+
current_size = len(serialized.encode("utf-8"))
70+
71+
if current_size <= MAX_SPAN_SIZE_BYTES:
72+
return span_dict
73+
74+
logger.warning(
75+
f"Span size ({current_size} bytes) exceeds limit ({MAX_SPAN_SIZE_BYTES} bytes). "
76+
"Truncating large payload attributes."
77+
)
78+
79+
# Create a deep copy to modify (shallow copy would still reference original attributes)
80+
truncated_span = span_dict.copy()
81+
if "attributes" in truncated_span:
82+
truncated_span["attributes"] = truncated_span["attributes"].copy()
83+
attributes = truncated_span.get("attributes", {})
84+
85+
# Track what was truncated for logging
86+
truncated_keys = []
87+
88+
# Sort attributes by size (largest first) and truncate until size is acceptable
89+
if attributes:
90+
# Calculate size of each attribute value when serialized
91+
attr_sizes = []
92+
for key, value in attributes.items():
93+
try:
94+
value_size = len(json.dumps(value, separators=(",", ":")).encode("utf-8"))
95+
attr_sizes.append((key, value_size))
96+
except Exception:
97+
# If we can't serialize the value, assume it's small
98+
attr_sizes.append((key, 0))
99+
100+
# Sort by size (descending - largest first)
101+
attr_sizes.sort(key=lambda x: x[1], reverse=True)
102+
103+
# Truncate largest attributes first until size is acceptable
104+
for key, _ in attr_sizes:
105+
if key in attributes:
106+
attributes[key] = "TRUNCATED"
107+
truncated_keys.append(key)
108+
109+
# Check size after truncation
110+
serialized = json.dumps(truncated_span, separators=(",", ":"))
111+
current_size = len(serialized.encode("utf-8"))
112+
113+
if current_size <= MAX_SPAN_SIZE_BYTES:
114+
break
115+
116+
if truncated_keys:
117+
logger.info(f"Truncated attributes: {', '.join(truncated_keys)}")
118+
119+
return truncated_span
120+
121+
except Exception as e:
122+
logger.error(f"Error during span truncation: {e}")
123+
return span_dict
124+
125+
49126
def partition_by_identity(
50127
spans: Sequence[ReadableSpan],
51128
) -> dict[tuple[str, str], list[ReadableSpan]]:

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@
33

44
# Invoke agent scope for tracing agent invocation.
55

6+
import logging
7+
68
from .agent_details import AgentDetails
79
from .constants import (
810
GEN_AI_CALLER_AGENT_APPLICATION_ID_KEY,
911
GEN_AI_CALLER_AGENT_ID_KEY,
1012
GEN_AI_CALLER_AGENT_NAME_KEY,
1113
GEN_AI_CALLER_AGENT_TENANT_ID_KEY,
1214
GEN_AI_CALLER_AGENT_UPN_KEY,
15+
GEN_AI_CALLER_AGENT_USER_CLIENT_IP,
1316
GEN_AI_CALLER_AGENT_USER_ID_KEY,
1417
GEN_AI_CALLER_ID_KEY,
1518
GEN_AI_CALLER_NAME_KEY,
@@ -31,7 +34,9 @@
3134
from .opentelemetry_scope import OpenTelemetryScope
3235
from .request import Request
3336
from .tenant_details import TenantDetails
34-
from .utils import safe_json_dumps
37+
from .utils import safe_json_dumps, validate_and_normalize_ip
38+
39+
logger = logging.getLogger(__name__)
3540

3641

3742
class InvokeAgentScope(OpenTelemetryScope):
@@ -139,6 +144,11 @@ def __init__(
139144
self.set_tag_maybe(GEN_AI_CALLER_AGENT_USER_ID_KEY, caller_agent_details.agent_auid)
140145
self.set_tag_maybe(GEN_AI_CALLER_AGENT_UPN_KEY, caller_agent_details.agent_upn)
141146
self.set_tag_maybe(GEN_AI_CALLER_AGENT_TENANT_ID_KEY, caller_agent_details.tenant_id)
147+
# Validate and set caller agent client IP
148+
self.set_tag_maybe(
149+
GEN_AI_CALLER_AGENT_USER_CLIENT_IP,
150+
validate_and_normalize_ip(caller_agent_details.agent_client_ip),
151+
)
142152

143153
def record_response(self, response: str) -> None:
144154
"""Record response information for telemetry tracking.

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/middleware/baggage_builder.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
# Per request baggage builder for OpenTelemetry context propagation.
44

5+
import logging
56
from typing import Any
67

78
from opentelemetry import baggage, context
@@ -14,6 +15,7 @@
1415
GEN_AI_AGENT_ID_KEY,
1516
GEN_AI_AGENT_NAME_KEY,
1617
GEN_AI_AGENT_UPN_KEY,
18+
GEN_AI_CALLER_CLIENT_IP_KEY,
1719
GEN_AI_CALLER_ID_KEY,
1820
GEN_AI_CALLER_NAME_KEY,
1921
GEN_AI_CALLER_UPN_KEY,
@@ -28,9 +30,11 @@
2830
TENANT_ID_KEY,
2931
)
3032
from ..models.operation_source import OperationSource
31-
from ..utils import deprecated
33+
from ..utils import deprecated, validate_and_normalize_ip
3234
from .turn_context_baggage import from_turn_context
3335

36+
logger = logging.getLogger(__name__)
37+
3438

3539
class BaggageBuilder:
3640
"""Per request baggage builder.
@@ -183,6 +187,11 @@ def caller_upn(self, value: str | None) -> "BaggageBuilder":
183187
self._set(GEN_AI_CALLER_UPN_KEY, value)
184188
return self
185189

190+
def caller_client_ip(self, value: str | None) -> "BaggageBuilder":
191+
"""Set the caller client IP baggage value."""
192+
self._set(GEN_AI_CALLER_CLIENT_IP_KEY, validate_and_normalize_ip(value))
193+
return self
194+
186195
def conversation_id(self, value: str | None) -> "BaggageBuilder":
187196
"""Set the conversation ID baggage value."""
188197
self._set(GEN_AI_CONVERSATION_ID_KEY, value)
@@ -193,11 +202,6 @@ def conversation_item_link(self, value: str | None) -> "BaggageBuilder":
193202
self._set(GEN_AI_CONVERSATION_ITEM_LINK_KEY, value)
194203
return self
195204

196-
@deprecated("This is a no-op. Use channel_name() or channel_links() instead.")
197-
def source_metadata_id(self, value: str | None) -> "BaggageBuilder":
198-
"""Set the execution source metadata ID (e.g., channel ID)."""
199-
return self
200-
201205
@deprecated("Use channel_name() instead")
202206
def source_metadata_name(self, value: str | None) -> "BaggageBuilder":
203207
"""Set the execution source metadata name (e.g., channel name)."""

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import warnings
1010
from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping
1111
from enum import Enum
12+
from ipaddress import AddressValueError, ip_address
1213
from threading import RLock
1314
from typing import Any, Generic, TypeVar, cast
1415

@@ -173,3 +174,27 @@ def wrapper(*args, **kwargs):
173174
return wrapper
174175

175176
return decorator
177+
178+
179+
def validate_and_normalize_ip(ip_string: str | None) -> str | None:
180+
"""Validate and normalize an IP address string.
181+
182+
Args:
183+
ip_string: The IP address string to validate (IPv4 or IPv6)
184+
185+
Returns:
186+
The normalized IP address string if valid, None if invalid or None input
187+
188+
Logs:
189+
Error message if the IP address is invalid
190+
"""
191+
if ip_string is None:
192+
return None
193+
194+
try:
195+
# Validate and normalize IP address
196+
ip_obj = ip_address(ip_string)
197+
return str(ip_obj)
198+
except (ValueError, AddressValueError):
199+
logger.error(f"Invalid IP address: '{ip_string}'")
200+
return None
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import unittest
5+
6+
from microsoft_agents_a365.observability.core.exporters.utils import (
7+
truncate_span,
8+
)
9+
10+
11+
class TestUtils(unittest.TestCase):
12+
"""Unit tests for utility functions."""
13+
14+
def test_truncate_span_if_needed(self):
15+
"""Test truncate_span_if_needed with various span sizes."""
16+
# Small span - should return unchanged
17+
small_span = {
18+
"traceId": "abc123",
19+
"spanId": "def456",
20+
"name": "small_span",
21+
"attributes": {"key1": "value1", "key2": "value2"},
22+
}
23+
result = truncate_span(small_span)
24+
self.assertIsNotNone(result)
25+
self.assertEqual(result["name"], "small_span")
26+
self.assertEqual(result["attributes"]["key1"], "value1")
27+
28+
# Large span with large payload attributes - should truncate attributes
29+
large_span = {
30+
"traceId": "abc123",
31+
"spanId": "def456",
32+
"name": "large_span",
33+
"attributes": {
34+
"gen_ai.system": "openai",
35+
"gen_ai.request.model": "gpt-4",
36+
"gen_ai.response.model": "gpt-4",
37+
"gen_ai.input.messages": "x" * 150000, # Large payload
38+
"gen_ai.output.messages": "y" * 150000, # Large payload
39+
"gen_ai.sample.attribute": "x" * 250000, # Large payload
40+
"small_attr": "small_value",
41+
},
42+
}
43+
result = truncate_span(large_span)
44+
self.assertIsNotNone(result)
45+
# The largest attributes should be truncated first
46+
self.assertEqual(result["attributes"]["gen_ai.input.messages"], "TRUNCATED")
47+
self.assertEqual(result["attributes"]["small_attr"], "small_value") # Unchanged
48+
self.assertEqual(result["attributes"]["gen_ai.sample.attribute"], "TRUNCATED")
49+
50+
# Extremely large span - should return truncated span even if still large
51+
extreme_span = {
52+
"traceId": "abc123",
53+
"spanId": "def456",
54+
"name": "extreme_span",
55+
"attributes": {f"attr_{i}": "x" * 10000 for i in range(100)}, # Many large attributes
56+
"events": [
57+
{"name": f"event_{i}", "attributes": {"data": "y" * 10000}} for i in range(50)
58+
],
59+
}
60+
result = truncate_span(extreme_span)
61+
self.assertIsNotNone(result) # Should always return a span, even if still large
62+
# All attributes should be truncated due to size
63+
for key in result["attributes"]:
64+
self.assertEqual(result["attributes"][key], "TRUNCATED")
65+
66+
67+
if __name__ == "__main__":
68+
unittest.main()

0 commit comments

Comments
 (0)