Skip to content

Commit c86e01f

Browse files
authored
fix(maf): preserve legacy streaming context (#241)
2 parents 9e4d1c8 + c0ddf29 commit c86e01f

3 files changed

Lines changed: 584 additions & 0 deletions

File tree

instrumentation-loongsuite/loongsuite-instrumentation-microsoft-agent-framework/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Avoid enriching non-MAF spans that use overlapping GenAI operation names.
13+
- Preserve parent-child context for legacy Microsoft Agent Framework streaming
14+
agent spans.
1315

1416
## Version 0.7.0 (2026-07-03)
1517

instrumentation-loongsuite/loongsuite-instrumentation-microsoft-agent-framework/src/opentelemetry/instrumentation/microsoft_agent_framework/util_genai_bridge.py

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@
2727
import inspect
2828
import json
2929
import logging
30+
import sys
3031
import timeit
32+
import weakref
33+
from time import perf_counter, time_ns
3134
from typing import Any, AsyncGenerator, Callable, Generator, Mapping, Optional
3235

3336
from opentelemetry import trace as otel_trace
@@ -102,6 +105,8 @@
102105
_original_create_mcp_client_span: Any = None
103106
_original_tools_get_function_span: Any = None
104107
_original_mcp_create_mcp_client_span: Any = None
108+
_original_agent_trace_invocation: Any = None
109+
_legacy_response_stream_originals: dict[str, Any] = {}
105110

106111
_FINALIZED_ATTR = "_loongsuite_util_genai_finalized"
107112
_END_WRAPPED_ATTR = "_loongsuite_util_genai_end_wrapped"
@@ -129,6 +134,7 @@ def apply_util_genai_bridge() -> None:
129134
global _original_get_span
130135
global _original_start_streaming_span
131136
global _original_tools_get_function_span
137+
global _original_agent_trace_invocation
132138

133139
if _applied:
134140
return
@@ -157,6 +163,11 @@ def apply_util_genai_bridge() -> None:
157163
_original_create_mcp_client_span = getattr(
158164
observability, "create_mcp_client_span", None
159165
)
166+
_original_agent_trace_invocation = getattr(
167+
getattr(observability, "AgentTelemetryLayer", None),
168+
"_trace_agent_invocation",
169+
None,
170+
)
160171

161172
wrapped_get_span = (
162173
_wrap_get_span(_original_get_span)
@@ -192,6 +203,16 @@ def apply_util_genai_bridge() -> None:
192203
if wrapped_create_mcp_client_span is not None:
193204
observability.create_mcp_client_span = wrapped_create_mcp_client_span # type: ignore[attr-defined]
194205

206+
legacy_response_stream_patched = _patch_legacy_response_stream()
207+
if legacy_response_stream_patched and _original_agent_trace_invocation:
208+
agent_cls = getattr(observability, "AgentTelemetryLayer", None)
209+
if agent_cls is not None:
210+
agent_cls._trace_agent_invocation = (
211+
_wrap_legacy_agent_trace_invocation( # type: ignore[attr-defined]
212+
_original_agent_trace_invocation
213+
)
214+
)
215+
195216
try:
196217
import agent_framework._tools as tools_mod # type: ignore
197218

@@ -228,6 +249,7 @@ def revert_util_genai_bridge() -> None:
228249
global _original_get_span
229250
global _original_start_streaming_span
230251
global _original_tools_get_function_span
252+
global _original_agent_trace_invocation
231253

232254
if not _applied:
233255
return
@@ -248,6 +270,14 @@ def revert_util_genai_bridge() -> None:
248270
observability.create_mcp_client_span = (
249271
_original_create_mcp_client_span # type: ignore[attr-defined]
250272
)
273+
agent_cls = getattr(observability, "AgentTelemetryLayer", None)
274+
if (
275+
agent_cls is not None
276+
and _original_agent_trace_invocation is not None
277+
):
278+
agent_cls._trace_agent_invocation = ( # type: ignore[attr-defined]
279+
_original_agent_trace_invocation
280+
)
251281
except ImportError:
252282
pass
253283
try:
@@ -275,6 +305,301 @@ def revert_util_genai_bridge() -> None:
275305
_original_create_mcp_client_span = None
276306
_original_tools_get_function_span = None
277307
_original_mcp_create_mcp_client_span = None
308+
_original_agent_trace_invocation = None
309+
_restore_legacy_response_stream()
310+
311+
312+
def _patch_legacy_response_stream() -> bool:
313+
"""Add per-pull context support to older MAF ``ResponseStream``.
314+
315+
MAF 1.0 streaming spans were created detached and the stream type had no
316+
pull-context hook, so child spans created while resolving/iterating the
317+
stream became roots. MAF 1.10 added ``with_pull_context_manager``; this
318+
compatibility shim backports only that context propagation surface.
319+
"""
320+
global _legacy_response_stream_originals
321+
322+
if _legacy_response_stream_originals:
323+
return True
324+
try:
325+
from agent_framework._types import ResponseStream # type: ignore
326+
except ImportError:
327+
return False
328+
if hasattr(ResponseStream, "with_pull_context_manager"):
329+
return False
330+
331+
originals = {
332+
"__init__": ResponseStream.__init__,
333+
"_get_stream": ResponseStream._get_stream,
334+
"__anext__": ResponseStream.__anext__,
335+
"_run_cleanup_hooks": ResponseStream._run_cleanup_hooks,
336+
}
337+
_legacy_response_stream_originals = originals
338+
339+
original_init = originals["__init__"]
340+
original_get_stream = originals["_get_stream"]
341+
original_anext = originals["__anext__"]
342+
original_run_cleanup_hooks = originals["_run_cleanup_hooks"]
343+
344+
def _init(self: Any, *args: Any, **kwargs: Any) -> None:
345+
original_init(self, *args, **kwargs)
346+
self._pull_context_manager_factories = []
347+
self._stream_error = None
348+
349+
async def _get_stream(self: Any) -> Any:
350+
if getattr(self, "_stream", None) is not None:
351+
# The stream is already resolved; child spans are created while
352+
# pulling updates, which is covered by the patched ``__anext__``.
353+
return await original_get_stream(self)
354+
with contextlib.ExitStack() as stack:
355+
for factory in getattr(
356+
self, "_pull_context_manager_factories", ()
357+
):
358+
stack.enter_context(factory())
359+
return await original_get_stream(self)
360+
361+
async def _anext(self: Any) -> Any:
362+
with contextlib.ExitStack() as stack:
363+
for factory in getattr(
364+
self, "_pull_context_manager_factories", ()
365+
):
366+
stack.enter_context(factory())
367+
return await original_anext(self)
368+
369+
async def _run_cleanup_hooks(self: Any) -> Any:
370+
_, exc, _ = sys.exc_info()
371+
if exc is not None and not isinstance(exc, StopAsyncIteration):
372+
self._stream_error = exc
373+
try:
374+
with contextlib.ExitStack() as stack:
375+
for factory in getattr(
376+
self, "_pull_context_manager_factories", ()
377+
):
378+
stack.enter_context(factory())
379+
return await original_run_cleanup_hooks(self)
380+
finally:
381+
if exc is not None:
382+
self._stream_error = None
383+
384+
def _with_pull_context_manager(self: Any, cm_factory: Callable[[], Any]):
385+
self._pull_context_manager_factories.append(cm_factory)
386+
return self
387+
388+
ResponseStream.__init__ = _init # type: ignore[assignment]
389+
ResponseStream._get_stream = _get_stream # type: ignore[assignment]
390+
ResponseStream.__anext__ = _anext # type: ignore[assignment]
391+
ResponseStream._run_cleanup_hooks = _run_cleanup_hooks # type: ignore[assignment]
392+
ResponseStream.with_pull_context_manager = _with_pull_context_manager # type: ignore[attr-defined]
393+
return True
394+
395+
396+
def _restore_legacy_response_stream() -> None:
397+
global _legacy_response_stream_originals
398+
if not _legacy_response_stream_originals:
399+
return
400+
try:
401+
from agent_framework._types import ResponseStream # type: ignore
402+
except ImportError:
403+
_legacy_response_stream_originals = {}
404+
return
405+
ResponseStream.__init__ = _legacy_response_stream_originals["__init__"] # type: ignore[assignment]
406+
ResponseStream._get_stream = _legacy_response_stream_originals[
407+
"_get_stream"
408+
] # type: ignore[assignment]
409+
ResponseStream.__anext__ = _legacy_response_stream_originals["__anext__"] # type: ignore[assignment]
410+
ResponseStream._run_cleanup_hooks = _legacy_response_stream_originals[ # type: ignore[assignment]
411+
"_run_cleanup_hooks"
412+
]
413+
try:
414+
delattr(ResponseStream, "with_pull_context_manager")
415+
except AttributeError:
416+
pass
417+
_legacy_response_stream_originals = {}
418+
419+
420+
def _wrap_legacy_agent_trace_invocation(
421+
original: Callable[..., Any],
422+
) -> Callable[..., Any]:
423+
def _trace_agent_invocation(self: Any, *args: Any, **kwargs: Any) -> Any:
424+
if args or not kwargs.get("stream"):
425+
return original(self, *args, **kwargs)
426+
return _legacy_streaming_agent_invocation(self, **kwargs)
427+
428+
return _trace_agent_invocation
429+
430+
431+
def _legacy_streaming_agent_invocation(self: Any, **kwargs: Any) -> Any:
432+
import agent_framework.observability as observability # type: ignore
433+
from agent_framework._types import ResponseStream # type: ignore
434+
435+
execute = kwargs["execute"]
436+
messages = kwargs.get("messages")
437+
session = kwargs.get("session")
438+
merged_options = kwargs.get("merged_options") or {}
439+
client_kwargs = kwargs.get("client_kwargs")
440+
if not observability.OBSERVABILITY_SETTINGS.ENABLED:
441+
return execute()
442+
443+
provider_name = str(getattr(self, "otel_provider_name", "unknown"))
444+
merged_client_kwargs = (
445+
dict(client_kwargs) if client_kwargs is not None else {}
446+
)
447+
OtelAttr = observability.OtelAttr
448+
attributes = observability._get_span_attributes(
449+
operation_name=OtelAttr.AGENT_INVOKE_OPERATION,
450+
provider_name=provider_name,
451+
agent_id=getattr(self, "id", "unknown"),
452+
agent_name=getattr(self, "name", None)
453+
or getattr(self, "id", "unknown"),
454+
agent_description=getattr(self, "description", None),
455+
thread_id=session.service_session_id if session else None,
456+
all_options=dict(merged_options),
457+
**merged_client_kwargs,
458+
)
459+
460+
operation = attributes.get(OtelAttr.OPERATION, "operation")
461+
span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown")
462+
span = observability.get_tracer().start_span(
463+
f"{operation} {span_name}",
464+
kind=otel_trace.SpanKind.INTERNAL,
465+
attributes=attributes,
466+
)
467+
_mark_maf_live_span(span)
468+
_wrap_span_end(span)
469+
470+
if (
471+
observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED
472+
and messages
473+
and span.is_recording()
474+
):
475+
observability._capture_messages(
476+
span=span,
477+
provider_name=provider_name,
478+
messages=messages,
479+
system_instructions=observability._get_instructions_from_options(
480+
dict(merged_options)
481+
),
482+
)
483+
484+
span_state = {"closed": False}
485+
duration_state: dict[str, float] = {}
486+
start_time = perf_counter()
487+
inner_response_telemetry_captured_fields: set[str] = set()
488+
inner_accumulated_usage: dict[str, Any] = {}
489+
490+
def _close_span() -> None:
491+
if span_state["closed"]:
492+
return
493+
span_state["closed"] = True
494+
span.end()
495+
496+
def _record_duration() -> None:
497+
duration_state["duration"] = perf_counter() - start_time
498+
499+
try:
500+
with _activate_live_span(span):
501+
run_result = execute()
502+
if isinstance(run_result, ResponseStream):
503+
result_stream = run_result
504+
elif inspect.isawaitable(run_result):
505+
result_stream = ResponseStream.from_awaitable(run_result)
506+
else:
507+
raise RuntimeError(
508+
"Streaming telemetry requires a ResponseStream result."
509+
)
510+
except Exception as exception:
511+
observability.capture_exception(
512+
span=span, exception=exception, timestamp=time_ns()
513+
)
514+
_close_span()
515+
raise
516+
517+
async def _finalize_stream() -> None:
518+
try:
519+
stream_error = getattr(result_stream, "_stream_error", None)
520+
if stream_error is not None:
521+
observability.capture_exception(
522+
span=span, exception=stream_error, timestamp=time_ns()
523+
)
524+
return
525+
response = await result_stream.get_final_response()
526+
response_attributes = observability._get_response_attributes(
527+
attributes,
528+
response,
529+
capture_response_id=(
530+
observability.INNER_RESPONSE_ID_CAPTURED_FIELD
531+
not in inner_response_telemetry_captured_fields
532+
),
533+
capture_usage=(
534+
observability.INNER_USAGE_CAPTURED_FIELD
535+
not in inner_response_telemetry_captured_fields
536+
),
537+
)
538+
observability._apply_accumulated_usage(
539+
response_attributes,
540+
inner_response_telemetry_captured_fields,
541+
)
542+
observability._capture_response(
543+
span=span,
544+
attributes=response_attributes,
545+
duration=duration_state.get("duration"),
546+
)
547+
if (
548+
observability.OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED
549+
and getattr(response, "messages", None)
550+
and span.is_recording()
551+
):
552+
observability._capture_messages(
553+
span=span,
554+
provider_name=provider_name,
555+
messages=response.messages,
556+
output=True,
557+
)
558+
except Exception as exception:
559+
observability.capture_exception(
560+
span=span, exception=exception, timestamp=time_ns()
561+
)
562+
finally:
563+
_close_span()
564+
565+
@contextlib.contextmanager
566+
def _inner_telemetry_pull_context() -> Any:
567+
fields_token = (
568+
observability.INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set(
569+
inner_response_telemetry_captured_fields
570+
)
571+
)
572+
usage_token = observability.INNER_ACCUMULATED_USAGE.set(
573+
inner_accumulated_usage
574+
)
575+
try:
576+
with _activate_live_span(span):
577+
yield
578+
finally:
579+
observability.INNER_ACCUMULATED_USAGE.reset(usage_token)
580+
observability.INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(
581+
fields_token
582+
)
583+
584+
wrapped_stream = (
585+
result_stream.with_cleanup_hook(_record_duration)
586+
.with_cleanup_hook(_finalize_stream)
587+
.with_pull_context_manager(_inner_telemetry_pull_context)
588+
)
589+
try:
590+
weakref.finalize(wrapped_stream, _close_span)
591+
except TypeError:
592+
logger.debug("MAF ResponseStream is not weak-referenceable")
593+
return wrapped_stream
594+
595+
596+
def _activate_live_span(span: OtelSpan) -> Any:
597+
return otel_trace.use_span(
598+
span=span,
599+
end_on_exit=False,
600+
record_exception=False,
601+
set_status_on_exception=False,
602+
)
278603

279604

280605
def _wrap_get_span(original: Callable[..., Any]) -> Callable[..., Any]:

0 commit comments

Comments
 (0)