diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 446a95b8b6..a472a4ede9 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -646,6 +646,12 @@ class SPANDATA: Example: [{"role": "assistant", "parts": [{"type": "text", "content": "The weather in Paris is currently rainy with a temperature of 57°F."}], "finish_reason": "stop"}] """ + GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK = "gen_ai.response.time_to_first_chunk" + """ + Time in seconds when the first response content chunk arrived in streaming responses. + Example: 0.6853435 + """ + GEN_AI_RESPONSE_TIME_TO_FIRST_TOKEN = "gen_ai.response.time_to_first_token" """ The time it took to receive the first token from the model. diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index e58b6bcded..7f2c59b44c 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -1,3 +1,4 @@ +import time from contextlib import contextmanager from typing import TYPE_CHECKING @@ -116,6 +117,8 @@ def sentry_patched_make_api_call( attributes: "Attributes" = {} try: + if service_ext is not None: + service_ext.request_start_time = time.perf_counter() with span_ctx: try: parsed = orig_make_api_call(self, operation_name, api_params) diff --git a/sentry_sdk/integrations/boto3/_services/base.py b/sentry_sdk/integrations/boto3/_services/base.py index 5c7dd257b5..ddba36b9be 100644 --- a/sentry_sdk/integrations/boto3/_services/base.py +++ b/sentry_sdk/integrations/boto3/_services/base.py @@ -17,9 +17,12 @@ class _ServiceExtension: to use the generic instrumentation. """ - __slots__ = () + __slots__ = "request_start_time" skip_if_no_active_span = True + def __init__(self) -> None: + self.request_start_time: "Optional[float]" = None + def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]": """Return an optional `sentry.op` override, or `None` to keep the default.""" return None diff --git a/sentry_sdk/integrations/boto3/_services/bedrock.py b/sentry_sdk/integrations/boto3/_services/bedrock.py index 4a95f1f638..7a58b84443 100644 --- a/sentry_sdk/integrations/boto3/_services/bedrock.py +++ b/sentry_sdk/integrations/boto3/_services/bedrock.py @@ -1,5 +1,6 @@ import json import sys +import time from typing import TYPE_CHECKING, cast import sentry_sdk @@ -44,6 +45,10 @@ from sentry_sdk.integrations.boto3._context import AwsCallContext from sentry_sdk.tracing import Span + class _PatchedEventStream(EventStream[Any]): + _span: "Union[Span, StreamedSpan]" + _request_start_time: "Optional[float]" + def _finish_span( stream: "EventStream[Any]", @@ -89,6 +94,8 @@ def sentry_patched_iter( yield from original_iter(self) return + self = cast("_PatchedEventStream", self) + set_on_span = ( span.set_attribute if isinstance(span, StreamedSpan) else span.set_data ) @@ -97,6 +104,7 @@ def sentry_patched_iter( parts: "dict[int, Union[TextPart, ReasoningPart, ToolCallPart, ToolCallResponsePart, UriPart, BlobPart]]" = {} try: + start_time: "Optional[float]" = self._request_start_time for event in original_iter(self): if "metadata" in event: usage = event["metadata"]["usage"] @@ -132,6 +140,13 @@ def sentry_patched_iter( yield event continue + if start_time is not None and "contentBlockDelta" in event: + set_on_span( + SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK, + time.perf_counter() - start_time, + ) + start_time = None + if "messageStop" in event: finish_reasons: "list[str]" = [event["messageStop"]["stopReason"]] set_on_span(SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, finish_reasons) @@ -602,9 +617,12 @@ def _transform_tool_definitions( class _BedrockExtension(_ServiceExtension): - __slots__ = () + __slots__ = "request_start_time" skip_if_no_active_span = False + def __init__(self) -> None: + self.request_start_time: "Optional[float]" = None + def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]": if ctx.operation_name != "Converse" and ctx.operation_name != "ConverseStream": return None @@ -743,6 +761,7 @@ def on_success_response( if ctx.operation_name == "ConverseStream": stream = cast("ConverseStreamResponseTypeDef", response)["stream"] stream._span = span # type: ignore[attr-defined] + stream._request_start_time = self.request_start_time # type: ignore[attr-defined] return if isinstance(span, StreamedSpan): diff --git a/tests/integrations/boto3/test_bedrock.py b/tests/integrations/boto3/test_bedrock.py index 4be765e4aa..013f650e66 100644 --- a/tests/integrations/boto3/test_bedrock.py +++ b/tests/integrations/boto3/test_bedrock.py @@ -995,6 +995,7 @@ def test_converse_stream( assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == [ "end_turn" ] + assert SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 assert span["attributes"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30 @@ -1035,6 +1036,7 @@ def test_converse_stream( assert span["data"][SPANDATA.GEN_AI_REQUEST_TEMPERATURE] == 0.7 assert span["data"][SPANDATA.GEN_AI_REQUEST_TOP_P] == 0.9 assert span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == ["end_turn"] + assert SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30