Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

### Fixed

- Tolerate the framework-owned baggage context detach that runs when an
application closes a strand stream from another context, so `strands-agents`
1.60.0 and later no longer surface a spurious `Failed to detach context`
error while the instrumentation's own context hygiene is unchanged.

## Version 0.9.0 (2026-09-07)

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,12 @@ invoke_agent <agent_name> (INTERNAL, gen_ai.span.kind=AGENT)
the reporter error while its parent spans retain the original business error.
- Strands-native `strands.*` metrics remain available, while the LoongSuite OSS
product contract derives its unified GenAI metrics from the emitted spans.
- Strands 1.60.0 and later attach an OpenTelemetry baggage context for each
invocation inside `Agent.stream_async` and detach it when the stream is
closed. That baggage only exists in the context that started the stream, so
closing a stream from another context (for example
`asyncio.create_task(stream.aclose())`) cannot restore it. The
instrumentation tolerates that framework-owned detach and keeps its own
context tokens strictly paired, so closing a stream does not surface a
spurious `Failed to detach context` error.

Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Compat shims for Strands Agents behaviour that spans OTel contexts."""

import inspect
import logging
from contextvars import ContextVar
from typing import Any

logger = logging.getLogger(__name__)

# Private probe used to tell whether a context token can still be reset in the
# current context. A dedicated ContextVar keeps that check off the
# ``opentelemetry.context`` logging path.
_CONTEXT_PROBE: ContextVar = ContextVar("loongsuite_strands_context_probe")


class _TolerantContextApi:
"""Delegate to the OTel context API and keep one framework detach safe.

``strands.agent.agent`` keeps a module level ``context_api`` binding and,
from Strands 1.60.0 on, wraps every invocation in an OpenTelemetry baggage
attach/detach pair. ``context_api.attach`` returns a token that only the
context that created it can reset, and ``opentelemetry.context.detach``
logs ``Failed to detach context`` *before* it raises ``ValueError`` when the
ambient context changed. An application that closes the stream from another
context - ``asyncio.create_task(stream.aclose())`` copies the current
context - resumes the framework's ``finally`` somewhere else, so tolerating
the exception would still leave that error record behind.

This proxy pairs every framework attach with a private context probe. When
the probe proves the token can still be reset, the proxy resets it as
before; when it cannot, the impossible reset is skipped, so nothing is
logged and no exception escapes. Attach and detach of tokens the proxy did
not hand out are delegated unchanged.
"""

def __init__(self, delegate: Any) -> None:
self._delegate = delegate
# Context tokens are unhashable, so pair them by identity and keep the
# token alive for as long as the pairing exists.
self._probes: dict[int, tuple[Any, Any]] = {}

def __getattr__(self, name: str) -> Any:
return getattr(self._delegate, name)

def attach(self, context: Any) -> Any:
probe_token = _CONTEXT_PROBE.set(object())
token = self._delegate.attach(context)
self._probes[id(token)] = (token, probe_token)
return token

def detach(self, token: Any) -> None:
paired = self._probes.pop(id(token), None)
if paired is None or paired[0] is not token:
self._delegate.detach(token)
return
if _resets_in_current_context(paired[1]):
self._delegate.detach(token)
return
logger.debug(
"Skipped a Strands baggage detach: the token belongs to the "
"context that started the stream",
)


def _resets_in_current_context(probe_token: Any) -> bool:
"""Whether the ambient context is the one that created ``probe_token``."""
try:
_CONTEXT_PROBE.reset(probe_token)
except ValueError:
return False
return True


class BaggageContextCompat:
"""Keep the framework owned baggage detach from failing on stream close.

Strands 1.60.0 attaches an OpenTelemetry baggage context inside
``Agent.stream_async`` and detaches it in the async generator ``finally``.
The baggage travels with the context that starts the stream, so closing a
stream from another context (for example
``asyncio.create_task(stream.aclose())``) cannot restore it. This shim makes
only that framework owned detach tolerant, keeps the instrumentation's own
ContextVar hygiene intact, and leaves frameworks without the baggage attach
(Strands < 1.60.0) untouched.
"""

def __init__(self) -> None:
self._module: Any = None
self._original: Any = None
self._proxy: Any = None

def install(self, module: Any = None) -> None:
"""Patch ``module.context_api`` when the framework attaches baggage.

``module`` defaults to ``strands.agent.agent`` and is injectable so the
shim can be tested against a stub module.
"""
agent_module = module if module is not None else _agent_module()
original = getattr(agent_module, "context_api", None)
if original is None or not _attaches_invocation_baggage(agent_module):
return
proxy = _TolerantContextApi(original)
agent_module.context_api = proxy
self._module = agent_module
self._original = original
self._proxy = proxy

def restore(self) -> None:
if (
self._module is not None
and self._module.context_api is self._proxy
):
self._module.context_api = self._original
self._module = None
self._original = None
self._proxy = None


def _agent_module() -> Any:
from strands.agent import agent as agent_module # noqa: PLC0415

return agent_module


def _attaches_invocation_baggage(agent_module: Any) -> bool:
"""Whether ``Agent`` accepts the 1.60.0 ``baggage_attributes`` argument."""
agent = getattr(agent_module, "Agent", None)
try:
parameters = inspect.signature(agent.__init__).parameters
except (AttributeError, TypeError, ValueError):
return False
return "baggage_attributes" in parameters
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@

from opentelemetry import context as otel_context
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
from opentelemetry.instrumentation.strands._compat import (
BaggageContextCompat,
)
from opentelemetry.instrumentation.strands._hooks import LoongsuiteHook
from opentelemetry.instrumentation.strands._native_telemetry import (
NativeTelemetrySuppression,
Expand Down Expand Up @@ -48,6 +51,17 @@ def _instrument(self, **kwargs):

self._hook = LoongsuiteHook(handler)
self._native_telemetry = NativeTelemetrySuppression()
self._baggage_context_compat = BaggageContextCompat()

try:
self._baggage_context_compat.install()
except Exception:
self._baggage_context_compat.restore()
logger.warning(
"Failed to tolerate the Strands baggage context detach; "
"LoongSuite instrumentation will continue",
exc_info=True,
)

try:
self._native_telemetry.install()
Expand Down Expand Up @@ -160,6 +174,9 @@ def _safe_hook_call(callback: Any, *args: Any) -> None:

def _uninstrument(self, **kwargs):
self._unwrap_agent_lifecycle()
baggage_context_compat = getattr(self, "_baggage_context_compat", None)
if baggage_context_compat is not None:
baggage_context_compat.restore()
native_telemetry = getattr(self, "_native_telemetry", None)
if native_telemetry is not None:
native_telemetry.restore()
Loading
Loading