Skip to content

Commit febb4e2

Browse files
feat(boto3): Instrument Bedrock Runtime streaming calls as gen_ai spans
Record ConverseStream and InvokeModelWithResponseStream calls as gen_ai spans that finish once the caller has received the stream: when it's exhausted, closed, iteration stops early, or it fails. The finish reason, token usage, and output messages are accumulated from the events the caller receives, which are unchanged. `EventStream.__iter__` and `close` are patched globally, since the iterator is a method; only streams of instrumented calls are recorded. Refs #5470 Co-Authored-By: Sergiy Dybskiy <3760543+sergical@users.noreply.github.com>
1 parent 0062606 commit febb4e2

4 files changed

Lines changed: 1208 additions & 40 deletions

File tree

‎sentry_sdk/integrations/boto3/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version
22
from sentry_sdk.integrations.boto3._client import _patch_botocore_client
3+
from sentry_sdk.integrations.boto3._instrumentation import _patch_event_stream
34
from sentry_sdk.integrations.boto3.consts import IDENTIFIER, ORIGIN
45
from sentry_sdk.utils import parse_version
56

@@ -24,3 +25,4 @@ def setup_once() -> None:
2425
_check_minimum_version(Boto3Integration, version, "botocore")
2526

2627
_patch_botocore_client()
28+
_patch_event_stream()

‎sentry_sdk/integrations/boto3/_instrumentation.py‎

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@
2525
)
2626

2727
if TYPE_CHECKING:
28-
from typing import Any, Callable, Dict, Mapping, Optional, Union
28+
from typing import Any, Callable, Dict, Iterator, Mapping, Optional, Union
2929

3030
from sentry_sdk._types import Attributes
3131
from sentry_sdk.integrations.boto3._context import AwsCallContext
3232
from sentry_sdk.integrations.boto3._services.base import _ServiceExtension
3333

3434
try:
3535
from botocore.awsrequest import AWSRequest
36+
from botocore.eventstream import EventStream
3637
from botocore.exceptions import ClientError
3738
from botocore.response import StreamingBody
3839
except ImportError:
@@ -382,6 +383,92 @@ def sentry_raw_stream_close(*args: "Any", **kwargs: "Any") -> None:
382383
raise
383384

384385

386+
class _EventStreamHooks:
387+
"""
388+
Callbacks of an instrumented `EventStream`, stored on the stream.
389+
"""
390+
391+
__slots__ = ("on_event", "on_finish", "finished")
392+
393+
def __init__(
394+
self,
395+
on_event: "Callable[[Any], None]",
396+
on_finish: "Callable[[Optional[BaseException]], None]",
397+
) -> None:
398+
self.on_event = on_event
399+
self.on_finish = on_finish
400+
self.finished = False
401+
402+
def finish(self, error: "Optional[BaseException]" = None) -> None:
403+
# only finish once across iteration and close.
404+
if self.finished:
405+
return
406+
407+
self.finished = True
408+
with capture_internal_exceptions():
409+
self.on_finish(error)
410+
411+
412+
def _instrument_event_stream(
413+
stream: "EventStream[Any]",
414+
on_event: "Callable[[Any], None]",
415+
on_finish: "Callable[[Optional[BaseException]], None]",
416+
) -> None:
417+
"""
418+
Call `on_event` with each event the caller receives from `stream`, and
419+
`on_finish` once, when `stream` is exhausted, closed, stops being iterated,
420+
or fails.
421+
"""
422+
stream._sentry_hooks = _EventStreamHooks(on_event, on_finish) # type: ignore[attr-defined]
423+
424+
425+
def _patch_event_stream() -> None:
426+
"""
427+
Patch `EventStream` globally, since its iterator is a method; streams without
428+
hooks are unchanged.
429+
"""
430+
orig_iter = EventStream.__iter__
431+
orig_close = EventStream.close
432+
433+
def sentry_event_stream_iter(self: "EventStream[Any]") -> "Iterator[Any]":
434+
hooks: "Optional[_EventStreamHooks]" = getattr(self, "_sentry_hooks", None)
435+
if hooks is None:
436+
yield from orig_iter(self)
437+
return
438+
439+
try:
440+
for event in orig_iter(self):
441+
with capture_internal_exceptions():
442+
hooks.on_event(event)
443+
yield event
444+
except GeneratorExit:
445+
# the caller stopped iterating, e.g. with `break`.
446+
hooks.finish()
447+
raise
448+
except BaseException as error:
449+
hooks.finish(error)
450+
raise
451+
452+
hooks.finish()
453+
454+
def sentry_event_stream_close(
455+
self: "EventStream[Any]", *args: "Any", **kwargs: "Any"
456+
) -> None:
457+
hooks: "Optional[_EventStreamHooks]" = getattr(self, "_sentry_hooks", None)
458+
try:
459+
orig_close(self, *args, **kwargs)
460+
except BaseException as error:
461+
if hooks is not None:
462+
hooks.finish(error)
463+
raise
464+
465+
if hooks is not None:
466+
hooks.finish()
467+
468+
EventStream.__iter__ = sentry_event_stream_iter # type: ignore[method-assign]
469+
EventStream.close = sentry_event_stream_close # type: ignore[method-assign]
470+
471+
385472
def _set_request_attributes(
386473
span: "Union[Span, StreamedSpan]",
387474
request: "AWSRequest",

0 commit comments

Comments
 (0)