Skip to content

Commit 4cdae03

Browse files
authored
Align GenAI main-agent span processor with upstream OpenTelemetry SDK (>= 1.43) immutable BoundedAttributes on span end, fixing TypeErrors (#47796)
* Align GenAI main-agent span processor with upstream OpenTelemetry SDK (>= 1.43) immutable BoundedAttributes on span end, fixing TypeErrors * Update CHANGELOG
1 parent 28ab242 commit 4cdae03

3 files changed

Lines changed: 79 additions & 1 deletion

File tree

sdk/monitor/azure-monitor-opentelemetry-exporter/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
### Breaking Changes
88

99
### Bugs Fixed
10+
- Align GenAI main-agent span processor with upstream OpenTelemetry SDK (>= 1.43)
11+
immutable `BoundedAttributes` on span end, fixing a `TypeError` when writing
12+
`microsoft.gen_ai.main_agent.*` attributes in `on_end`
13+
([#47796](https://github.com/Azure/azure-sdk-for-python/pull/47796))
1014

1115
### Other Changes
1216

sdk/monitor/azure-monitor-opentelemetry-exporter/azure/monitor/opentelemetry/exporter/_gen_ai/_processor.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,39 @@ def on_end(self, span: ReadableSpan) -> None:
6161
if key.startswith(_MAIN_AGENT_PREFIX):
6262
return
6363

64+
# Access the internal mutable attributes mapping. on_end receives a
65+
# ReadableSpan which has no set_attribute, so we write to the underlying
66+
# BoundedAttributes mapping directly.
67+
mutable = getattr(span, "_attributes", None)
68+
if mutable is None:
69+
return
70+
71+
# Build the attributes to write before touching the (now frozen) span.
72+
updates = {}
6473
# Self-attribute from the span's own gen_ai attributes
6574
for target, source in _MAIN_AGENT_SELF_ATTRIBUTES:
6675
value = attributes.get(source)
6776
if value is not None:
68-
span._attributes[target] = value # type: ignore
77+
updates[target] = value
78+
79+
if not updates:
80+
return
81+
82+
# OTel SDK >= 1.43 freezes span attributes (_immutable = True) inside
83+
# end() *before* invoking on_end. Writing then raises TypeError.
84+
# Temporarily lift the freeze for our own synchronous writes and always
85+
# restore it so the exported ReadableSpan snapshot stays frozen.
86+
was_immutable = getattr(mutable, "_immutable", False)
87+
if was_immutable:
88+
mutable._immutable = False # type: ignore # pylint: disable=protected-access
89+
try:
90+
for target, value in updates.items():
91+
mutable[target] = value
92+
finally:
93+
mutable._immutable = True # type: ignore # pylint: disable=protected-access
94+
else:
95+
for target, value in updates.items():
96+
mutable[target] = value
6997

7098
def shutdown(self):
7199
pass

sdk/monitor/azure-monitor-opentelemetry-exporter/tests/test_gen_ai_processor.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import unittest
55
from unittest.mock import MagicMock, patch
66

7+
from opentelemetry.attributes import BoundedAttributes
78
from opentelemetry.context import Context
89
from opentelemetry.trace import INVALID_SPAN_CONTEXT, SpanContext, TraceFlags
910

@@ -221,6 +222,51 @@ def test_on_end_partial_self_attribution(self):
221222
self.assertNotIn("microsoft.gen_ai.main_agent.version", span._attributes)
222223
self.assertNotIn("microsoft.gen_ai.main_agent.conversation_id", span._attributes)
223224

225+
def test_on_end_writes_to_immutable_attributes(self):
226+
"""on_end must self-attribute even when span attributes are frozen.
227+
228+
OTel SDK >= 1.43 sets BoundedAttributes._immutable = True inside
229+
Span.end() *before* invoking on_end. The processor must temporarily lift
230+
the freeze to write and restore it afterwards, without raising.
231+
"""
232+
attributes = BoundedAttributes(
233+
immutable=False,
234+
attributes={
235+
"gen_ai.operation.name": "invoke_agent",
236+
"gen_ai.agent.name": "RootAgent",
237+
"gen_ai.agent.id": "agent-001",
238+
"gen_ai.agent.version": "3.0",
239+
"gen_ai.conversation.id": "conv-xyz",
240+
},
241+
)
242+
# Simulate the frozen state Span.end() leaves the attributes in.
243+
attributes._immutable = True
244+
245+
span = MagicMock()
246+
span.attributes = attributes
247+
span._attributes = attributes
248+
249+
# Must not raise even though the mapping is immutable.
250+
self.processor.on_end(span)
251+
252+
self.assertEqual(attributes["microsoft.gen_ai.main_agent.name"], "RootAgent")
253+
self.assertEqual(attributes["microsoft.gen_ai.main_agent.id"], "agent-001")
254+
self.assertEqual(attributes["microsoft.gen_ai.main_agent.version"], "3.0")
255+
self.assertEqual(attributes["microsoft.gen_ai.main_agent.conversation_id"], "conv-xyz")
256+
# The freeze must be restored so the exported snapshot stays immutable.
257+
self.assertTrue(attributes._immutable)
258+
259+
def test_immutable_attributes_reject_direct_write(self):
260+
"""Regression guard: proves the failure the fix guards against.
261+
262+
A direct write to frozen BoundedAttributes (the pre-fix behavior)
263+
raises TypeError. This is why on_end must lift the freeze first.
264+
"""
265+
attributes = BoundedAttributes(immutable=False, attributes={})
266+
attributes._immutable = True
267+
with self.assertRaises(TypeError):
268+
attributes["microsoft.gen_ai.main_agent.name"] = "RootAgent"
269+
224270

225271
class TestGenAIMainAgentLogRecordProcessor(unittest.TestCase):
226272
def setUp(self):

0 commit comments

Comments
 (0)