Skip to content

Commit d3ae336

Browse files
address the PR comment
1 parent e6721c7 commit d3ae336

4 files changed

Lines changed: 104 additions & 75 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .execution_type import ExecutionType
1515
from .exporters.enriched_span import EnrichedReadableSpan
1616
from .exporters.enriching_span_processor import (
17+
get_span_enricher,
1718
register_span_enricher,
1819
unregister_span_enricher,
1920
)
@@ -40,6 +41,7 @@
4041
# Span enrichment
4142
"register_span_enricher",
4243
"unregister_span_enricher",
44+
"get_span_enricher",
4345
"EnrichedReadableSpan",
4446
# Span processor
4547
"SpanProcessor",

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

Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,69 +3,84 @@
33

44
"""Span enrichment support for the Agent365 exporter pipeline."""
55

6+
import logging
67
import threading
78
from collections.abc import Callable
89

910
from opentelemetry.sdk.trace import ReadableSpan
1011
from opentelemetry.sdk.trace.export import BatchSpanProcessor
1112

12-
# Registry for span enrichers - allows extensions to add attributes to spans before export
13-
_span_enrichers: list[Callable[[ReadableSpan], ReadableSpan]] = []
14-
_enrichers_lock = threading.Lock()
13+
logger = logging.getLogger(__name__)
14+
15+
# Single span enricher - only one platform instrumentor should be active at a time
16+
_span_enricher: Callable[[ReadableSpan], ReadableSpan] | None = None
17+
_enricher_lock = threading.Lock()
1518

1619

1720
def register_span_enricher(enricher: Callable[[ReadableSpan], ReadableSpan]) -> None:
18-
"""
19-
Register a function that enriches spans before export.
21+
"""Register the span enricher for the active platform instrumentor.
2022
21-
Extensions (like Semantic Kernel, LangChain, etc.) can register enrichers
22-
that modify spans before they are exported by the BatchSpanProcessor.
23+
Only one enricher can be registered at a time since auto-instrumentation
24+
is platform-specific (Semantic Kernel, LangChain, or OpenAI Agents).
2325
2426
Args:
25-
enricher: A function that takes a ReadableSpan and returns an
26-
enriched ReadableSpan (or the same span if no changes).
27+
enricher: Function that takes a ReadableSpan and returns an enriched span.
28+
29+
Raises:
30+
RuntimeError: If an enricher is already registered.
2731
"""
28-
with _enrichers_lock:
29-
if enricher not in _span_enrichers:
30-
_span_enrichers.append(enricher)
32+
global _span_enricher
33+
with _enricher_lock:
34+
if _span_enricher is not None:
35+
raise RuntimeError(
36+
"A span enricher is already registered. "
37+
"Only one platform instrumentor can be active at a time."
38+
)
39+
_span_enricher = enricher
40+
logger.debug("Span enricher registered: %s", enricher.__name__)
3141

3242

33-
def unregister_span_enricher(enricher: Callable[[ReadableSpan], ReadableSpan]) -> None:
34-
"""
35-
Remove a previously registered enricher.
43+
def unregister_span_enricher() -> None:
44+
"""Unregister the current span enricher.
3645
37-
Args:
38-
enricher: The enricher function to remove.
46+
Called during uninstrumentation to clean up.
3947
"""
40-
with _enrichers_lock:
41-
if enricher in _span_enrichers:
42-
_span_enrichers.remove(enricher)
48+
global _span_enricher
49+
with _enricher_lock:
50+
if _span_enricher is not None:
51+
logger.debug("Span enricher unregistered: %s", _span_enricher.__name__)
52+
_span_enricher = None
4353

4454

45-
class _EnrichingBatchSpanProcessor(BatchSpanProcessor):
46-
"""
47-
BatchSpanProcessor that applies registered enrichers before export.
55+
def get_span_enricher() -> Callable[[ReadableSpan], ReadableSpan] | None:
56+
"""Get the currently registered span enricher.
4857
49-
This allows extensions to modify spans after they end but before
50-
they are batched and exported.
58+
Returns:
59+
The registered enricher function, or None if no enricher is registered.
5160
"""
61+
with _enricher_lock:
62+
return _span_enricher
63+
64+
65+
class _EnrichingBatchSpanProcessor(BatchSpanProcessor):
66+
"""BatchSpanProcessor that applies the registered enricher before batching."""
5267

5368
def on_end(self, span: ReadableSpan) -> None:
54-
"""
55-
Apply all registered enrichers to the span before batching.
69+
"""Apply the span enricher and pass to parent for batching.
5670
5771
Args:
58-
span: The ReadableSpan that has ended.
72+
span: The span that has ended.
5973
"""
6074
enriched_span = span
61-
with _enrichers_lock:
62-
enrichers = list(_span_enrichers) # Copy to avoid holding lock during enrichment
6375

64-
for enricher in enrichers:
76+
enricher = get_span_enricher()
77+
if enricher is not None:
6578
try:
66-
enriched_span = enricher(enriched_span)
79+
enriched_span = enricher(span)
6780
except Exception:
68-
# Don't let enrichment failures break the pipeline
69-
pass
81+
logger.exception(
82+
"Span enricher %s raised an exception, using original span",
83+
enricher.__name__,
84+
)
7085

7186
super().on_end(enriched_span)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def _uninstrument(self, **kwargs: Any) -> None:
6767
**kwargs: Optional configuration parameters.
6868
"""
6969
# Unregister the enricher
70-
unregister_span_enricher(enrich_semantic_kernel_span)
70+
unregister_span_enricher()
7171

7272
# Shutdown the processor
7373
if hasattr(self, "_processor"):

tests/observability/core/exporters/test_enriching_span_processor.py

Lines changed: 52 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from microsoft_agents_a365.observability.core.exporters.enriching_span_processor import (
1010
_EnrichingBatchSpanProcessor,
11-
_span_enrichers,
11+
get_span_enricher,
1212
register_span_enricher,
1313
unregister_span_enricher,
1414
)
@@ -18,58 +18,66 @@ class TestSpanEnricherRegistry(unittest.TestCase):
1818
"""Test suite for span enricher registration functions."""
1919

2020
def setUp(self):
21-
"""Clear enrichers before each test."""
22-
_span_enrichers.clear()
21+
"""Ensure clean state before each test."""
22+
unregister_span_enricher()
2323

2424
def tearDown(self):
25-
"""Clear enrichers after each test."""
26-
_span_enrichers.clear()
25+
"""Clean up after each test."""
26+
unregister_span_enricher()
2727

2828
def test_register_and_unregister_enricher(self):
29-
"""Test that enrichers can be registered and unregistered."""
29+
"""Test that enricher can be registered and unregistered."""
3030

31-
# Define a simple enricher
3231
def my_enricher(span):
3332
return span
3433

35-
# Register
36-
register_span_enricher(my_enricher)
37-
self.assertIn(my_enricher, _span_enrichers)
38-
self.assertEqual(len(_span_enrichers), 1)
34+
# Initially no enricher
35+
self.assertIsNone(get_span_enricher())
3936

40-
# Duplicate registration should not add again
37+
# Register
4138
register_span_enricher(my_enricher)
42-
self.assertEqual(len(_span_enrichers), 1)
39+
self.assertEqual(get_span_enricher(), my_enricher)
4340

4441
# Unregister
45-
unregister_span_enricher(my_enricher)
46-
self.assertNotIn(my_enricher, _span_enrichers)
47-
self.assertEqual(len(_span_enrichers), 0)
42+
unregister_span_enricher()
43+
self.assertIsNone(get_span_enricher())
4844

49-
def test_unregister_nonexistent_enricher_does_not_raise(self):
50-
"""Test that unregistering a non-existent enricher doesn't raise an error."""
45+
def test_register_second_enricher_raises_error(self):
46+
"""Test that registering a second enricher raises RuntimeError."""
5147

52-
def my_enricher(span):
48+
def enricher_one(span):
5349
return span
5450

51+
def enricher_two(span):
52+
return span
53+
54+
register_span_enricher(enricher_one)
55+
56+
with self.assertRaises(RuntimeError) as context:
57+
register_span_enricher(enricher_two)
58+
59+
self.assertIn("already registered", str(context.exception))
60+
61+
def test_unregister_when_none_registered_is_safe(self):
62+
"""Test that unregistering when no enricher is registered doesn't raise."""
5563
# Should not raise
56-
unregister_span_enricher(my_enricher)
57-
self.assertEqual(len(_span_enrichers), 0)
64+
unregister_span_enricher()
65+
self.assertIsNone(get_span_enricher())
5866

5967

6068
class TestEnrichingBatchSpanProcessor(unittest.TestCase):
6169
"""Test suite for _EnrichingBatchSpanProcessor."""
6270

6371
def setUp(self):
64-
"""Clear enrichers before each test."""
65-
_span_enrichers.clear()
72+
"""Ensure clean state before each test."""
73+
unregister_span_enricher()
6674

6775
def tearDown(self):
68-
"""Clear enrichers after each test."""
69-
_span_enrichers.clear()
76+
"""Clean up after each test."""
77+
unregister_span_enricher()
7078

71-
def test_on_end_applies_enrichers_to_span(self):
72-
"""Test that on_end applies all registered enrichers to the span."""
79+
def test_on_end_applies_enricher_to_span(self):
80+
"""Test that on_end applies the registered enricher to the span."""
7381
# Create processor with a mock exporter
7482
mock_exporter = Mock()
7583
processor = _EnrichingBatchSpanProcessor(mock_exporter)
@@ -103,23 +111,14 @@ def enricher(span):
103111
processor.shutdown()
104112

105113
def test_on_end_continues_if_enricher_raises_exception(self):
106-
"""Test that on_end continues processing even if an enricher raises an exception."""
114+
"""Test that on_end continues processing even if enricher raises an exception."""
107115
mock_exporter = Mock()
108116
processor = _EnrichingBatchSpanProcessor(mock_exporter)
109117

110-
# Track which enrichers were called
111-
called_enrichers = []
112-
113118
def failing_enricher(span):
114-
called_enrichers.append("failing")
115119
raise ValueError("Enricher failed!")
116120

117-
def succeeding_enricher(span):
118-
called_enrichers.append("succeeding")
119-
return span
120-
121121
register_span_enricher(failing_enricher)
122-
register_span_enricher(succeeding_enricher)
123122

124123
# Create a mock span
125124
original_span = Mock(name="original_span")
@@ -130,9 +129,22 @@ def succeeding_enricher(span):
130129
# Should not raise despite failing enricher
131130
processor.on_end(original_span)
132131

133-
# Verify both enrichers were called (failing one didn't stop the chain)
134-
self.assertIn("failing", called_enrichers)
135-
self.assertIn("succeeding", called_enrichers)
132+
# Cleanup
133+
processor.shutdown()
134+
135+
def test_on_end_works_without_enricher(self):
136+
"""Test that on_end works when no enricher is registered."""
137+
mock_exporter = Mock()
138+
processor = _EnrichingBatchSpanProcessor(mock_exporter)
139+
140+
# Create a mock span (no enricher registered)
141+
original_span = Mock(name="original_span")
142+
original_span.context = Mock()
143+
original_span.context.trace_id = 123
144+
original_span.context.span_id = 456
145+
146+
# Should not raise
147+
processor.on_end(original_span)
136148

137149
# Cleanup
138150
processor.shutdown()

0 commit comments

Comments
 (0)