Skip to content

Commit 956fd30

Browse files
committed
fix(loongsuite): align advice and stream lifecycle
1 parent 237f8b3 commit 956fd30

5 files changed

Lines changed: 99 additions & 47 deletions

File tree

instrumentation-loongsuite/loongsuite-instrumentation-litellm/CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12-
- Isolate streamed chunk accumulation and finalization callback failures so
13-
instrumentation errors do not interrupt application stream consumption.
12+
- Route streamed chunk accumulation and finalization callbacks through shared
13+
fail-open advice while keeping stream iteration and lifecycle handling in the
14+
LiteLLM wrapper.
1415

1516
## Version 0.7.0 (2026-07-03)
1617

instrumentation-loongsuite/loongsuite-instrumentation-litellm/src/opentelemetry/instrumentation/litellm/_stream_wrapper.py

Lines changed: 35 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -44,16 +44,10 @@ def _debug_safely(message: str, *args: Any, exc_info: bool = False) -> None:
4444
pass
4545

4646

47+
@hook_advice("litellm", "stream_chunk")
4748
def _record_stream_chunk(accumulator: Any, chunk: Any) -> None:
4849
"""Record probe-owned chunk state without affecting stream delivery."""
49-
try:
50-
accumulator.record_chunk(chunk)
51-
except Exception as exc: # pylint: disable=broad-exception-caught
52-
_debug_safely(
53-
"Error recording LiteLLM stream chunk: %s",
54-
exc,
55-
exc_info=True,
56-
)
50+
accumulator.record_chunk(chunk)
5751

5852

5953
@hook_advice("litellm", "stream_finalize")
@@ -232,7 +226,7 @@ class StreamWrapper:
232226

233227
def __init__(
234228
self,
235-
stream: Iterator,
229+
stream: Iterator[Any],
236230
span: Any,
237231
callback: callable,
238232
invocation: Any = None,
@@ -331,26 +325,30 @@ def _finalize(self, error: Optional[BaseException] = None):
331325
return
332326

333327
self._finalized = True
334-
self._close_stream()
335328
try:
336-
# Call the callback with only the last chunk
337-
# Note: The callback is responsible for calling handler.stop_llm()
338-
# or handler.fail_llm().
339-
# which will end the span. We no longer call span.end() here.
340-
if self.callback:
341-
_invoke_stream_callback(
342-
self.callback,
343-
self.span,
344-
self.last_chunk,
345-
error,
346-
)
329+
self._close_stream()
330+
finally:
331+
try:
332+
# Call the callback with only the last chunk
333+
# Note: The callback is responsible for calling handler.stop_llm()
334+
# or handler.fail_llm().
335+
# which will end the span. We no longer call span.end() here.
336+
if self.callback:
337+
_invoke_stream_callback(
338+
self.callback,
339+
self.span,
340+
self.last_chunk,
341+
error,
342+
)
347343

348-
# Clear reference to avoid holding memory
349-
self.last_chunk = None
350-
except Exception as e:
351-
_debug_safely(
352-
"Error finalizing LiteLLM stream: %s", e, exc_info=True
353-
)
344+
# Clear reference to avoid holding memory
345+
self.last_chunk = None
346+
except Exception as e:
347+
_debug_safely(
348+
"Error finalizing LiteLLM stream: %s",
349+
e,
350+
exc_info=True,
351+
)
354352

355353
def get_output_messages(self) -> list[OutputMessage]:
356354
return self._accumulator.get_output_messages()
@@ -468,10 +466,12 @@ def _close_stream(self) -> None:
468466
if self._stream_closed:
469467
return
470468

471-
self._stream_closed = True
469+
self._stream_closed = self._close_sync_stream()
470+
471+
def _close_sync_stream(self) -> bool:
472472
close = getattr(self.stream, "close", None)
473473
if not callable(close):
474-
return
474+
return False
475475

476476
try:
477477
close()
@@ -481,36 +481,28 @@ def _close_stream(self) -> None:
481481
exc,
482482
exc_info=True,
483483
)
484+
return False
485+
return True
484486

485487
async def _aclose_stream(self) -> None:
486488
if self._stream_closed:
487489
return
488490

489-
self._stream_closed = True
490491
aclose = getattr(self.stream, "aclose", None)
491492
if callable(aclose):
492493
try:
493494
await aclose()
494-
return
495495
except Exception as exc:
496496
_debug_safely(
497497
"Error closing LiteLLM async stream: %s",
498498
exc,
499499
exc_info=True,
500500
)
501+
else:
502+
self._stream_closed = True
503+
return
501504

502-
close = getattr(self.stream, "close", None)
503-
if not callable(close):
504-
return
505-
506-
try:
507-
close()
508-
except Exception as exc:
509-
_debug_safely(
510-
"Error closing LiteLLM async stream: %s",
511-
exc,
512-
exc_info=True,
513-
)
505+
self._stream_closed = self._close_sync_stream()
514506

515507
def _finalize(self, error: Optional[BaseException] = None):
516508
"""Finalize the span with data from last chunk."""

instrumentation-loongsuite/loongsuite-instrumentation-litellm/tests/test_stream_isolation.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,11 @@
1616

1717
import pytest
1818

19-
from opentelemetry.instrumentation.litellm import _stream_wrapper
2019
from opentelemetry.instrumentation.litellm._stream_wrapper import (
2120
AsyncStreamWrapper,
2221
StreamWrapper,
2322
)
23+
from opentelemetry.util.genai import extended_advice
2424

2525

2626
def test_stream_chunk_advice_failure_preserves_chunk(monkeypatch):
@@ -60,7 +60,7 @@ def test_stream_chunk_logging_failure_also_preserves_chunk(monkeypatch):
6060
),
6161
)
6262
monkeypatch.setattr(
63-
_stream_wrapper.logger,
63+
extended_advice._logger,
6464
"debug",
6565
lambda *_args, **_kwargs: (_ for _ in ()).throw(
6666
RuntimeError("logger boom")
@@ -110,6 +110,34 @@ def callback(*_args):
110110
assert calls == [True]
111111

112112

113+
def test_stream_close_base_exception_still_finalizes():
114+
calls = []
115+
expected = KeyboardInterrupt("business interruption")
116+
117+
class Source:
118+
def __iter__(self):
119+
return self
120+
121+
def __next__(self):
122+
raise StopIteration
123+
124+
def close(self):
125+
raise expected
126+
127+
stream = StreamWrapper(
128+
stream=Source(),
129+
span=None,
130+
callback=lambda *_args: calls.append(_args),
131+
)
132+
133+
with pytest.raises(KeyboardInterrupt) as caught:
134+
stream.close()
135+
136+
assert caught.value is expected
137+
assert len(calls) == 1
138+
assert stream._finalized is True
139+
140+
113141
@pytest.mark.asyncio
114142
async def test_async_stream_chunk_advice_failure_preserves_chunk(monkeypatch):
115143
expected = object()
@@ -209,6 +237,29 @@ async def aclose(self):
209237
assert stream._finalized is True
210238

211239

240+
@pytest.mark.asyncio
241+
async def test_async_only_stream_can_aclose_after_sync_close():
242+
close_calls = []
243+
244+
class Source:
245+
async def aclose(self):
246+
close_calls.append(True)
247+
248+
stream = AsyncStreamWrapper(
249+
stream=Source(),
250+
span=None,
251+
callback=lambda *_args: None,
252+
)
253+
254+
stream.close()
255+
assert close_calls == []
256+
assert stream._stream_closed is False
257+
258+
await stream.aclose()
259+
assert close_calls == [True]
260+
assert stream._stream_closed is True
261+
262+
212263
def test_async_stream_sync_close_base_exception_still_finalizes():
213264
calls = []
214265
expected = KeyboardInterrupt("business interruption")

util/opentelemetry-util-genai/src/opentelemetry/util/genai/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
# LoongSuite Extension: expose a stable advice facade for instrumentations and
16+
# downstream packaging adaptations.
1517
from opentelemetry.util.genai.extended_advice import (
1618
async_hook_advice,
1719
hook_advice,

util/opentelemetry-util-genai/src/opentelemetry/util/genai/extended_advice.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ def hook_advice(
5555
) -> Callable[[_F], _F]:
5656
"""Decorate synchronous instrumentation-only work with fail-open semantics.
5757
58+
Suppressed failures return ``None``, so callers must not depend on advice
59+
return values unless ``throw_exception`` is enabled.
60+
5861
Generator functions are rejected because their bodies execute during later
5962
iteration, after this decorator has returned the generator object. Stream
6063
iteration must instead be isolated by the owning instrumentation wrapper.
@@ -100,6 +103,9 @@ def async_hook_advice(
100103
) -> Callable[[_F], _F]:
101104
"""Decorate asynchronous instrumentation-only work with fail-open semantics.
102105
106+
Suppressed failures return ``None``, so callers must not depend on advice
107+
return values unless ``throw_exception`` is enabled.
108+
103109
Async generators are rejected because their bodies execute during later
104110
iteration. The owning instrumentation wrapper must isolate that lifecycle.
105111
"""

0 commit comments

Comments
 (0)