Skip to content

Commit a5a144b

Browse files
Move suppress_invoke_agent_input to core SDK (exporter and span processor)
Co-authored-by: juliomenendez <9697+juliomenendez@users.noreply.github.com>
1 parent c5b96b6 commit a5a144b

6 files changed

Lines changed: 73 additions & 74 deletions

File tree

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def configure(
5353
token_resolver: Callable[[str, str], str | None] | None = None,
5454
cluster_category: str = "prod",
5555
exporter_options: Optional[Agent365ExporterOptions] = None,
56+
suppress_invoke_agent_input: bool = False,
5657
**kwargs: Any,
5758
) -> bool:
5859
"""
@@ -67,6 +68,7 @@ def configure(
6768
Use exporter_options instead.
6869
:param exporter_options: Agent365ExporterOptions instance for configuring the exporter.
6970
If provided, exporter_options takes precedence. If exporter_options is None, the token_resolver and cluster_category parameters are used as fallback/legacy support to construct a default Agent365ExporterOptions instance.
71+
:param suppress_invoke_agent_input: If True, suppress input messages for spans that are children of InvokeAgent spans.
7072
:return: True if configuration succeeded, False otherwise.
7173
"""
7274
try:
@@ -78,6 +80,7 @@ def configure(
7880
token_resolver,
7981
cluster_category,
8082
exporter_options,
83+
suppress_invoke_agent_input,
8184
**kwargs,
8285
)
8386
except Exception as e:
@@ -92,6 +95,7 @@ def _configure_internal(
9295
token_resolver: Callable[[str, str], str | None] | None = None,
9396
cluster_category: str = "prod",
9497
exporter_options: Optional[Agent365ExporterOptions] = None,
98+
suppress_invoke_agent_input: bool = False,
9599
**kwargs: Any,
96100
) -> bool:
97101
"""Internal configuration method - not thread-safe, must be called with lock."""
@@ -151,6 +155,7 @@ def _configure_internal(
151155
token_resolver=exporter_options.token_resolver,
152156
cluster_category=exporter_options.cluster_category,
153157
use_s2s_endpoint=exporter_options.use_s2s_endpoint,
158+
suppress_invoke_agent_input=suppress_invoke_agent_input,
154159
)
155160
else:
156161
exporter = ConsoleSpanExporter()
@@ -162,7 +167,7 @@ def _configure_internal(
162167

163168
# Create BatchSpanProcessor with optimized settings
164169
batch_processor = BatchSpanProcessor(exporter, **batch_processor_kwargs)
165-
agent_processor = SpanProcessor()
170+
agent_processor = SpanProcessor(suppress_invoke_agent_input=suppress_invoke_agent_input)
166171

167172
tracer_provider.add_span_processor(batch_processor)
168173
tracer_provider.add_span_processor(agent_processor)

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
1818
from opentelemetry.trace import StatusCode
1919

20+
from ..constants import GEN_AI_INPUT_MESSAGES_KEY, GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME
2021
from .utils import (
2122
get_validated_domain_override,
2223
hex_span_id,
@@ -52,6 +53,7 @@ def __init__(
5253
token_resolver: Callable[[str, str], str | None],
5354
cluster_category: str = "prod",
5455
use_s2s_endpoint: bool = False,
56+
suppress_invoke_agent_input: bool = False,
5557
):
5658
if token_resolver is None:
5759
raise ValueError("token_resolver must be provided.")
@@ -61,6 +63,7 @@ def __init__(
6163
self._token_resolver = token_resolver
6264
self._cluster_category = cluster_category
6365
self._use_s2s_endpoint = use_s2s_endpoint
66+
self._suppress_invoke_agent_input = suppress_invoke_agent_input
6467
# Read domain override once at initialization
6568
self._domain_override = get_validated_domain_override()
6669

@@ -222,13 +225,22 @@ def _post_with_retries(self, url: str, body: str, headers: dict[str, str]) -> bo
222225
# ------------- Payload mapping ------------------
223226

224227
def _build_export_request(self, spans: Sequence[ReadableSpan]) -> dict[str, Any]:
228+
# Build a map of span IDs to their operation names for parent lookups
229+
span_operation_map = {}
230+
if self._suppress_invoke_agent_input:
231+
for sp in spans:
232+
attrs = sp.attributes or {}
233+
operation_name = attrs.get(GEN_AI_OPERATION_NAME_KEY)
234+
if operation_name:
235+
span_operation_map[sp.context.span_id] = operation_name
236+
225237
# Group by instrumentation scope (name, version)
226238
scope_map: dict[tuple[str, str | None], list[dict[str, Any]]] = {}
227239

228240
for sp in spans:
229241
scope = sp.instrumentation_scope
230242
scope_key = (scope.name, scope.version)
231-
scope_map.setdefault(scope_key, []).append(self._map_span(sp))
243+
scope_map.setdefault(scope_key, []).append(self._map_span(sp, span_operation_map))
232244

233245
scope_spans: list[dict[str, Any]] = []
234246
for (name, version), mapped_spans in scope_map.items():
@@ -257,7 +269,7 @@ def _build_export_request(self, spans: Sequence[ReadableSpan]) -> dict[str, Any]
257269
]
258270
}
259271

260-
def _map_span(self, sp: ReadableSpan) -> dict[str, Any]:
272+
def _map_span(self, sp: ReadableSpan, span_operation_map: dict[int, str] = None) -> dict[str, Any]:
261273
ctx = sp.context
262274

263275
parent_span_id = None
@@ -266,6 +278,16 @@ def _map_span(self, sp: ReadableSpan) -> dict[str, Any]:
266278

267279
# attributes
268280
attrs = dict(sp.attributes or {})
281+
282+
# Suppress input messages if configured and parent is an InvokeAgent span
283+
if self._suppress_invoke_agent_input and span_operation_map:
284+
# Check if parent span is an InvokeAgent span
285+
if sp.parent is not None and sp.parent.span_id != 0:
286+
parent_operation = span_operation_map.get(sp.parent.span_id)
287+
if parent_operation == INVOKE_AGENT_OPERATION_NAME:
288+
# Remove input messages attribute
289+
attrs.pop(GEN_AI_INPUT_MESSAGES_KEY, None)
290+
269291
# events
270292
events = []
271293
for ev in sp.events:

libraries/microsoft-agents-a365-observability-core/microsoft_agents_a365/observability/core/trace_processor/span_processor.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,15 @@
2424
class SpanProcessor(BaseSpanProcessor):
2525
"""Span processor that propagates every baggage key/value to span attributes."""
2626

27-
def __init__(self):
27+
def __init__(self, suppress_invoke_agent_input: bool = False):
28+
"""Initialize the span processor.
29+
30+
Args:
31+
suppress_invoke_agent_input: If True, suppress input messages for spans
32+
that are children of InvokeAgent spans.
33+
"""
2834
super().__init__()
35+
self._suppress_invoke_agent_input = suppress_invoke_agent_input
2936

3037
def on_start(self, span, parent_context=None):
3138
ctx = parent_context or context.get_current()
@@ -81,4 +88,9 @@ def on_start(self, span, parent_context=None):
8188
return super().on_start(span, parent_context)
8289

8390
def on_end(self, span):
91+
"""Called when a span ends.
92+
93+
Note: Input suppression for InvokeAgent scopes is handled by the exporter,
94+
not in this processor, because span attributes cannot be removed after they're set.
95+
"""
8496
super().on_end(span)

libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_instrumentor.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@ def _instrument(self, **kwargs: Any) -> None:
4747
"""Instruments the OpenAI Agents SDK with Microsoft Agent 365 tracing."""
4848
tracer_name = kwargs["tracer_name"] if kwargs.get("tracer_name") else None
4949
tracer_version = kwargs["tracer_version"] if kwargs.get("tracer_version") else None
50-
suppress_invoke_agent_input = kwargs.get("suppress_invoke_agent_input", False)
5150

5251
# Get the configured Microsoft Agent 365 Tracer
5352
try:
@@ -65,9 +64,7 @@ def _instrument(self, **kwargs: Any) -> None:
6564

6665
agent365_tracer = cast(Tracer, tracer)
6766

68-
set_trace_processors(
69-
[OpenAIAgentsTraceProcessor(agent365_tracer, suppress_invoke_agent_input)]
70-
)
67+
set_trace_processors([OpenAIAgentsTraceProcessor(agent365_tracer)])
7168

7269
def _uninstrument(self, **kwargs: Any) -> None:
7370
pass

libraries/microsoft-agents-a365-observability-extensions-openai/microsoft_agents_a365/observability/extensions/openai/trace_processor.py

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,8 @@
6969
class OpenAIAgentsTraceProcessor(TracingProcessor):
7070
_MAX_HANDOFFS_IN_FLIGHT = 1000
7171

72-
def __init__(self, tracer: Tracer, suppress_invoke_agent_input: bool = False) -> None:
72+
def __init__(self, tracer: Tracer) -> None:
7373
self._tracer = tracer
74-
self._suppress_invoke_agent_input = suppress_invoke_agent_input
7574
self._root_spans: dict[str, OtelSpan] = {}
7675
self._otel_spans: dict[str, OtelSpan] = {}
7776
self._tokens: dict[str, object] = {}
@@ -90,29 +89,6 @@ def _stamp_custom_parent(self, otel_span: OtelSpan, trace_id: str) -> None:
9089
pid_hex = "0x" + ot_trace.format_span_id(sc.span_id)
9190
otel_span.set_attribute(CUSTOM_PARENT_SPAN_ID_KEY, pid_hex)
9291

93-
def _should_suppress_input(self, span: Span[Any], otel_span: OtelSpan) -> bool:
94-
"""Check if input messages should be suppressed for the given span.
95-
96-
Args:
97-
span: The agents SDK span being processed.
98-
otel_span: The corresponding OpenTelemetry span.
99-
100-
Returns:
101-
True if suppression is enabled and the span has an InvokeAgent parent.
102-
"""
103-
if not self._suppress_invoke_agent_input:
104-
return False
105-
106-
# Check if this span has a parent that is an InvokeAgent span
107-
if span.parent_id:
108-
parent_otel_span = self._otel_spans.get(span.parent_id)
109-
if parent_otel_span and hasattr(parent_otel_span, 'attributes'):
110-
operation_name = parent_otel_span.attributes.get(GEN_AI_OPERATION_NAME_KEY)
111-
if operation_name == INVOKE_AGENT_OPERATION_NAME:
112-
return True
113-
114-
return False
115-
11692
def on_trace_start(self, trace: Trace) -> None:
11793
"""Called when a trace is started.
11894
@@ -178,8 +154,7 @@ def on_span_end(self, span: Span[Any]) -> None:
178154
otel_span.set_attribute(GEN_AI_OUTPUT_MESSAGES_KEY, response.model_dump_json())
179155
for k, v in get_attributes_from_response(response):
180156
otel_span.set_attribute(k, v)
181-
# Only record input messages if not suppressing or not in InvokeAgent scope
182-
if not self._should_suppress_input(span, otel_span) and hasattr(data, "input") and (input := data.input):
157+
if hasattr(data, "input") and (input := data.input):
183158
if isinstance(input, str):
184159
otel_span.set_attribute(GEN_AI_INPUT_MESSAGES_KEY, input)
185160
elif isinstance(input, list):
@@ -189,12 +164,7 @@ def on_span_end(self, span: Span[Any]) -> None:
189164
elif TYPE_CHECKING:
190165
assert_never(input)
191166
elif isinstance(data, GenerationSpanData):
192-
# Collect all attributes once and filter if suppression is enabled
193-
should_suppress = self._should_suppress_input(span, otel_span)
194167
for k, v in get_attributes_from_generation_span_data(data):
195-
# Skip input messages if suppression is enabled and in InvokeAgent scope
196-
if should_suppress and k == GEN_AI_INPUT_MESSAGES_KEY:
197-
continue
198168
otel_span.set_attribute(k, v)
199169
self._stamp_custom_parent(otel_span, span.trace_id)
200170
otel_span.update_name(

tests/observability/extensions/openai/test_prompt_suppression.py

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,65 +2,58 @@
22

33
import unittest
44

5-
from microsoft_agents_a365.observability.core import configure, get_tracer
6-
from microsoft_agents_a365.observability.extensions.openai.trace_processor import (
7-
OpenAIAgentsTraceProcessor,
8-
)
5+
from microsoft_agents_a365.observability.core.exporters.agent365_exporter import _Agent365Exporter
6+
from microsoft_agents_a365.observability.core.trace_processor.span_processor import SpanProcessor
97

108

119
class TestPromptSuppressionConfiguration(unittest.TestCase):
12-
"""Unit tests for prompt suppression configuration in OpenAIAgentsTraceProcessor."""
13-
14-
@classmethod
15-
def setUpClass(cls):
16-
"""Set up test environment once for all tests."""
17-
configure(
18-
service_name="test-service-prompt-suppression",
19-
service_namespace="test-namespace-prompt-suppression",
20-
)
10+
"""Unit tests for prompt suppression configuration in the core SDK."""
2111

22-
def test_default_suppression_is_false(self):
23-
"""Test that the default value for suppress_invoke_agent_input is False."""
24-
tracer = get_tracer()
25-
processor = OpenAIAgentsTraceProcessor(tracer)
12+
def test_span_processor_default_suppression_is_false(self):
13+
"""Test that the default value for suppress_invoke_agent_input is False in SpanProcessor."""
14+
processor = SpanProcessor()
2615

2716
self.assertFalse(
2817
processor._suppress_invoke_agent_input,
2918
"Default value for suppress_invoke_agent_input should be False",
3019
)
3120

32-
def test_can_enable_suppression(self):
33-
"""Test that suppression can be enabled via constructor."""
34-
tracer = get_tracer()
35-
processor = OpenAIAgentsTraceProcessor(tracer, suppress_invoke_agent_input=True)
21+
def test_span_processor_can_enable_suppression(self):
22+
"""Test that suppression can be enabled via SpanProcessor constructor."""
23+
processor = SpanProcessor(suppress_invoke_agent_input=True)
3624

3725
self.assertTrue(
3826
processor._suppress_invoke_agent_input,
3927
"suppress_invoke_agent_input should be True when explicitly set",
4028
)
4129

42-
def test_can_disable_suppression(self):
43-
"""Test that suppression can be explicitly disabled via constructor."""
44-
tracer = get_tracer()
45-
processor = OpenAIAgentsTraceProcessor(tracer, suppress_invoke_agent_input=False)
30+
def test_span_processor_can_disable_suppression(self):
31+
"""Test that suppression can be explicitly disabled via SpanProcessor constructor."""
32+
processor = SpanProcessor(suppress_invoke_agent_input=False)
4633

4734
self.assertFalse(
4835
processor._suppress_invoke_agent_input,
4936
"suppress_invoke_agent_input should be False when explicitly set",
5037
)
5138

52-
def test_has_should_suppress_input_method(self):
53-
"""Test that the processor has the helper method for suppression logic."""
54-
tracer = get_tracer()
55-
processor = OpenAIAgentsTraceProcessor(tracer, suppress_invoke_agent_input=True)
39+
def test_exporter_default_suppression_is_false(self):
40+
"""Test that the default value for suppress_invoke_agent_input is False in exporter."""
41+
exporter = _Agent365Exporter(token_resolver=lambda x, y: "test")
5642

57-
self.assertTrue(
58-
hasattr(processor, "_should_suppress_input"),
59-
"Processor should have _should_suppress_input method",
43+
self.assertFalse(
44+
exporter._suppress_invoke_agent_input,
45+
"Default value for suppress_invoke_agent_input should be False",
6046
)
47+
48+
def test_exporter_can_enable_suppression(self):
49+
"""Test that suppression can be enabled via exporter constructor."""
50+
exporter = _Agent365Exporter(
51+
token_resolver=lambda x, y: "test", suppress_invoke_agent_input=True
52+
)
53+
6154
self.assertTrue(
62-
callable(processor._should_suppress_input),
63-
"_should_suppress_input should be callable",
55+
exporter._suppress_invoke_agent_input,
56+
"suppress_invoke_agent_input should be True when explicitly set",
6457
)
6558

6659

0 commit comments

Comments
 (0)