From a1f5b5ecfdcc74203dcee519d2936d47b065838c Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 11 Nov 2026 16:43:29 +0100 Subject: [PATCH 01/13] feat(bedrock): Set gen_ai.output.messages for non-streaming responses --- .../integrations/boto3/_services/bedrock.py | 19 +++- tests/integrations/boto3/test_bedrock.py | 90 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/boto3/_services/bedrock.py b/sentry_sdk/integrations/boto3/_services/bedrock.py index 5c9b16325e..1d60b45008 100644 --- a/sentry_sdk/integrations/boto3/_services/bedrock.py +++ b/sentry_sdk/integrations/boto3/_services/bedrock.py @@ -488,7 +488,8 @@ def on_success_response( ) if ctx.operation_name == "Converse": - usage = cast("ConverseResponseTypeDef", response)["usage"] + response = cast("ConverseResponseTypeDef", response) + usage = response["usage"] set_on_span(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage["outputTokens"]) set_on_span(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage["totalTokens"]) @@ -511,6 +512,22 @@ def on_success_response( set_on_span(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, input_tokens) + client = sentry_sdk.get_client() + if "message" in response["output"] and ( + ( + has_data_collection_enabled(client.options) + and client.options["data_collection"]["gen_ai"]["outputs"] + ) + or ( + not has_data_collection_enabled(client.options) + and should_send_default_pii() + ) + ): + set_on_span( + SPANDATA.GEN_AI_OUTPUT_MESSAGES, + json.dumps(_transform_messages([response["output"]["message"]])), + ) + if isinstance(span, StreamedSpan): span.end() return diff --git a/tests/integrations/boto3/test_bedrock.py b/tests/integrations/boto3/test_bedrock.py index 9461bce69c..4696293eda 100644 --- a/tests/integrations/boto3/test_bedrock.py +++ b/tests/integrations/boto3/test_bedrock.py @@ -834,6 +834,96 @@ def test_converse_input_attributes( ) +@pytest.mark.parametrize("data_collection", [True, False]) +@pytest.mark.parametrize("span_streaming", [True, False]) +@pytest.mark.parametrize("stream_gen_ai_spans", [True, False]) +def test_converse_output_attributes( + sentry_init, + capture_items, + capture_events, + data_collection, + span_streaming, + stream_gen_ai_spans, + converse_response, + content_block_messages, +): + if data_collection: + sentry_init( + traces_sample_rate=1.0, + integrations=[Boto3Integration()], + data_collection={"gen_ai": {"outputs": True}}, + stream_gen_ai_spans=stream_gen_ai_spans, + trace_lifecycle="stream" if span_streaming else "static", + ) + else: + sentry_init( + traces_sample_rate=1.0, + integrations=[Boto3Integration()], + send_default_pii=True, + stream_gen_ai_spans=stream_gen_ai_spans, + trace_lifecycle="stream" if span_streaming else "static", + ) + + messages, expected_messages = content_block_messages + converse_response["output"]["message"] = { + "role": "assistant", + "content": [block for message in messages for block in message["content"]], + } + expected_output_messages = [ + { + "role": "assistant", + "parts": [ + part for message in expected_messages for part in message["parts"] + ], + } + ] + + session = boto3.Session( + aws_access_key_id="-", aws_secret_access_key="-", region_name="us-east-1" + ) + client = session.client("bedrock-runtime") + stubber = Stubber(client) + stubber.add_response("converse", converse_response) + + if span_streaming or stream_gen_ai_spans: + items = capture_items("span") + + with stubber, sentry_sdk.start_transaction(name="parent"): + client.converse( + modelId="anthropic.claude-3-sonnet-20240229-v1:0", + messages=[{"role": "user", "content": [{"text": "Hi"}]}], + ) + + sentry_sdk.flush() + spans = [item.payload for item in items] + (span,) = ( + span + for span in spans + if span["attributes"].get("sentry.op") == OP.GEN_AI_CHAT + ) + + assert ( + json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) + == expected_output_messages + ) + else: + events = capture_events() + + with stubber, sentry_sdk.start_transaction(name="parent"): + client.converse( + modelId="anthropic.claude-3-sonnet-20240229-v1:0", + messages=[{"role": "user", "content": [{"text": "Hi"}]}], + ) + + (transaction,) = events + (span,) = transaction["spans"] + + assert ( + json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) + == expected_output_messages + ) + + @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize("stream_gen_ai_spans", [True, False]) def test_converse_stream( From fc6b034aa4d849bb6744fd8efc359284c4201fb0 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 11 Nov 2026 17:36:38 +0100 Subject: [PATCH 02/13] make mypy happy --- sentry_sdk/integrations/boto3/_services/bedrock.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/bedrock.py b/sentry_sdk/integrations/boto3/_services/bedrock.py index 1d60b45008..d35fc26efb 100644 --- a/sentry_sdk/integrations/boto3/_services/bedrock.py +++ b/sentry_sdk/integrations/boto3/_services/bedrock.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: - from typing import Any, Iterator, Optional, Union + from typing import Any, Iterator, Mapping, Optional, Union from mypy_boto3_bedrock_runtime.type_defs import ( ConverseResponseTypeDef, @@ -171,7 +171,7 @@ def _transform_system_instructions( def _transform_messages( - input_messages: "Union[list[dict[str, Any]], tuple[dict[str, Any], ...]]", + input_messages: "Union[list[Mapping[str, Any]], tuple[Mapping[str, Any], ...]]", ) -> "list[InputMessage]": """ Unlike other providers, AWS declares that input and output messages use the same message type. From 444f537a7af86f5e9b0181010a02fa9cf48b7f2b Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 11 Nov 2026 17:46:18 +0100 Subject: [PATCH 03/13] feat(bedrock): Record gen_ai.response.finish_reasons for non-streaming responses --- sentry_sdk/integrations/boto3/_services/bedrock.py | 3 +++ tests/integrations/boto3/test_bedrock.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/sentry_sdk/integrations/boto3/_services/bedrock.py b/sentry_sdk/integrations/boto3/_services/bedrock.py index d35fc26efb..e7b7ddcf2e 100644 --- a/sentry_sdk/integrations/boto3/_services/bedrock.py +++ b/sentry_sdk/integrations/boto3/_services/bedrock.py @@ -512,6 +512,9 @@ def on_success_response( set_on_span(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, input_tokens) + finish_reasons: "list[str]" = [response["stopReason"]] + set_on_span(SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, finish_reasons) + client = sentry_sdk.get_client() if "message" in response["output"] and ( ( diff --git a/tests/integrations/boto3/test_bedrock.py b/tests/integrations/boto3/test_bedrock.py index 4696293eda..3ecd3f357e 100644 --- a/tests/integrations/boto3/test_bedrock.py +++ b/tests/integrations/boto3/test_bedrock.py @@ -476,6 +476,9 @@ def test_converse( assert span["attributes"][SPANDATA.GEN_AI_REQUEST_MAX_TOKENS] == 1024 assert span["attributes"][SPANDATA.GEN_AI_REQUEST_TEMPERATURE] == 0.7 assert span["attributes"][SPANDATA.GEN_AI_REQUEST_TOP_P] == 0.9 + assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == [ + "end_turn" + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 assert span["attributes"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30 @@ -513,6 +516,7 @@ def test_converse( assert span["data"][SPANDATA.GEN_AI_REQUEST_MAX_TOKENS] == 1024 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 span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30 From db1a5a41f407a72d4b031f8700a32aef3c081a12 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 11 Nov 2026 18:09:47 +0100 Subject: [PATCH 04/13] feat(bedrock): Set gen_ai.output.messages for streamed responses --- .../integrations/boto3/_services/bedrock.py | 135 +++++++++++--- tests/integrations/boto3/test_bedrock.py | 165 ++++++++++++++++++ 2 files changed, 280 insertions(+), 20 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/bedrock.py b/sentry_sdk/integrations/boto3/_services/bedrock.py index e7b7ddcf2e..6f663a6974 100644 --- a/sentry_sdk/integrations/boto3/_services/bedrock.py +++ b/sentry_sdk/integrations/boto3/_services/bedrock.py @@ -93,37 +93,132 @@ def sentry_patched_iter( span.set_attribute if isinstance(span, StreamedSpan) else span.set_data ) + role: "Optional[str]" = None + content_blocks: "dict[int, dict[str, Any]]" = {} + try: for event in original_iter(self): - if "metadata" not in event: - yield event - continue + if "metadata" in event: + usage = event["metadata"]["usage"] - usage = event["metadata"]["usage"] + input_tokens = usage["inputTokens"] + set_on_span( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage["outputTokens"] + ) + set_on_span( + SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage["totalTokens"] + ) - input_tokens = usage["inputTokens"] - set_on_span(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage["outputTokens"]) - set_on_span(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage["totalTokens"]) + cache_read_tokens = usage.get("cacheReadInputTokens") + if cache_read_tokens is not None: + input_tokens += cache_read_tokens + set_on_span( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, cache_read_tokens + ) + + cache_write_tokens = usage.get("cacheWriteInputTokens") + if cache_write_tokens is not None: + input_tokens += cache_write_tokens + set_on_span( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE, + cache_write_tokens, + ) - cache_read_tokens = usage.get("cacheReadInputTokens") - if cache_read_tokens is not None: - input_tokens += cache_read_tokens set_on_span( - SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, cache_read_tokens + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, + input_tokens, ) - cache_write_tokens = usage.get("cacheWriteInputTokens") - if cache_write_tokens is not None: - input_tokens += cache_write_tokens + yield event + continue + + client = sentry_sdk.get_client() + if ( + not has_data_collection_enabled(client.options) + or not client.options["data_collection"]["gen_ai"]["outputs"] + ) and ( + has_data_collection_enabled(client.options) + or not should_send_default_pii() + ): + yield event + continue + + if "messageStart" in event: + role = event["messageStart"]["role"] + + yield event + continue + + if "contentBlockStart" in event: + block_start = event["contentBlockStart"] + tool_use = block_start["start"].get("toolUse") + if tool_use is not None: + tool_use_block: "dict[str, Any]" = {"input": ""} + if "name" in tool_use: + tool_use_block["name"] = tool_use["name"] + content_blocks[block_start["contentBlockIndex"]] = { + "toolUse": tool_use_block + } + + yield event + continue + + if "contentBlockDelta" in event: + index = event["contentBlockDelta"]["contentBlockIndex"] + delta = event["contentBlockDelta"]["delta"] + + if "text" in delta: + block = content_blocks.setdefault(index, {"text": ""}) + block["text"] += delta["text"] + elif "toolUse" in delta: + block = content_blocks.setdefault( + index, {"toolUse": {"input": ""}} + ) + block["toolUse"]["input"] += delta["toolUse"]["input"] + elif "reasoningContent" in delta: + block = content_blocks.setdefault( + index, {"reasoningContent": {}} + ) + reasoning_text = delta["reasoningContent"].get("text") + if reasoning_text is not None: + block["reasoningContent"].setdefault( + "reasoningText", {"text": ""} + )["text"] += reasoning_text + + yield event + continue + + if "messageStop" in event and role is not None: + for block in content_blocks.values(): + if "toolUse" not in block: + continue + # Tool input is streamed as a JSON string; non-streaming responses contain the parsed object. + try: + block["toolUse"]["input"] = json.loads( + block["toolUse"]["input"] + ) + except ValueError: + pass + set_on_span( - SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE, - cache_write_tokens, + SPANDATA.GEN_AI_OUTPUT_MESSAGES, + json.dumps( + _transform_messages( + [ + { + "role": role, + "content": [ + content_blocks[index] + for index in sorted(content_blocks) + ], + } + ] + ) + ), ) - set_on_span( - SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, - input_tokens, - ) + yield event + continue yield event except GeneratorExit: diff --git a/tests/integrations/boto3/test_bedrock.py b/tests/integrations/boto3/test_bedrock.py index 3ecd3f357e..cb7a57ff66 100644 --- a/tests/integrations/boto3/test_bedrock.py +++ b/tests/integrations/boto3/test_bedrock.py @@ -1320,3 +1320,168 @@ def test_converse_stream_input_attributes( json.loads(span["data"][SPANDATA.GEN_AI_INPUT_MESSAGES]) == expected_input_messages ) + + +@pytest.mark.parametrize("data_collection", [True, False]) +@pytest.mark.parametrize("span_streaming", [True, False]) +@pytest.mark.parametrize("stream_gen_ai_spans", [True, False]) +def test_converse_stream_output_attributes( + sentry_init, + capture_items, + capture_events, + data_collection, + span_streaming, + stream_gen_ai_spans, + converse_stream_response, +): + if data_collection: + sentry_init( + traces_sample_rate=1.0, + integrations=[Boto3Integration()], + data_collection={"gen_ai": {"outputs": True}}, + stream_gen_ai_spans=stream_gen_ai_spans, + trace_lifecycle="stream" if span_streaming else "static", + ) + else: + sentry_init( + traces_sample_rate=1.0, + integrations=[Boto3Integration()], + send_default_pii=True, + stream_gen_ai_spans=stream_gen_ai_spans, + trace_lifecycle="stream" if span_streaming else "static", + ) + + events = [ + ("messageStart", {"role": "assistant"}), + ( + "contentBlockDelta", + { + "contentBlockIndex": 0, + "delta": {"reasoningContent": {"text": "The user wants "}}, + }, + ), + ( + "contentBlockDelta", + { + "contentBlockIndex": 0, + "delta": {"reasoningContent": {"text": "the weather."}}, + }, + ), + ( + "contentBlockDelta", + { + "contentBlockIndex": 0, + "delta": {"reasoningContent": {"signature": "sig"}}, + }, + ), + ("contentBlockStop", {"contentBlockIndex": 0}), + ( + "contentBlockDelta", + {"contentBlockIndex": 1, "delta": {"text": "Let me "}}, + ), + ( + "contentBlockDelta", + {"contentBlockIndex": 1, "delta": {"text": "check."}}, + ), + ("contentBlockStop", {"contentBlockIndex": 1}), + ( + "contentBlockStart", + { + "contentBlockIndex": 2, + "start": {"toolUse": {"toolUseId": "tool-1", "name": "get_weather"}}, + }, + ), + ( + "contentBlockDelta", + {"contentBlockIndex": 2, "delta": {"toolUse": {"input": '{"city": '}}}, + ), + ( + "contentBlockDelta", + {"contentBlockIndex": 2, "delta": {"toolUse": {"input": '"Paris"}'}}}, + ), + ("contentBlockStop", {"contentBlockIndex": 2}), + ("messageStop", {"stopReason": "tool_use"}), + ( + "metadata", + { + "usage": {"inputTokens": 10, "outputTokens": 20, "totalTokens": 30}, + "metrics": {"latencyMs": 500}, + }, + ), + ] + converse_stream_response["stream"]._event_generator = iter( + EventStreamMessage( + prelude=None, + headers={":message-type": "event", ":event-type": event_type}, + payload=json.dumps(payload).encode(), + crc=None, + ) + for event_type, payload in events + ) + + expected_output_messages = [ + { + "role": "assistant", + "parts": [ + {"type": "reasoning", "content": "The user wants the weather."}, + {"type": "text", "content": "Let me check."}, + { + "type": "tool_call", + "name": "get_weather", + "arguments": {"city": "Paris"}, + }, + ], + } + ] + + session = boto3.Session( + aws_access_key_id="-", aws_secret_access_key="-", region_name="us-east-1" + ) + client = session.client("bedrock-runtime") + mock_http = Mock(status_code=200) + client.meta.events.register( + "before-call.bedrock-runtime.ConverseStream", + lambda **kwargs: (mock_http, converse_stream_response), + ) + + if span_streaming or stream_gen_ai_spans: + items = capture_items("span") + + with sentry_sdk.start_transaction(name="parent"): + response = client.converse_stream( + modelId="anthropic.claude-3-sonnet-20240229-v1:0", + messages=[{"role": "user", "content": [{"text": "Hi"}]}], + ) + for _ in response["stream"]: + pass + + sentry_sdk.flush() + spans = [item.payload for item in items] + (span,) = ( + span + for span in spans + if span["attributes"].get("sentry.op") == OP.GEN_AI_CHAT + ) + + assert ( + json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) + == expected_output_messages + ) + else: + events = capture_events() + + with sentry_sdk.start_transaction(name="parent"): + response = client.converse_stream( + modelId="anthropic.claude-3-sonnet-20240229-v1:0", + messages=[{"role": "user", "content": [{"text": "Hi"}]}], + ) + for _ in response["stream"]: + pass + + (transaction,) = events + (span,) = transaction["spans"] + + assert ( + json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) + == expected_output_messages + ) From 5253e2954426cce13cf4ed538bc5b711ef12a9ee Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 11 Nov 2026 18:14:02 +0100 Subject: [PATCH 05/13] feat(bedrock): Record gen_ai.response.finish_reasons for streamed responses --- sentry_sdk/integrations/boto3/_services/bedrock.py | 4 ++++ tests/integrations/boto3/test_bedrock.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/sentry_sdk/integrations/boto3/_services/bedrock.py b/sentry_sdk/integrations/boto3/_services/bedrock.py index 6f663a6974..4b24251e80 100644 --- a/sentry_sdk/integrations/boto3/_services/bedrock.py +++ b/sentry_sdk/integrations/boto3/_services/bedrock.py @@ -188,6 +188,10 @@ def sentry_patched_iter( yield event continue + if "messageStop" in event: + finish_reasons: "list[str]" = [event["messageStop"]["stopReason"]] + set_on_span(SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, finish_reasons) + if "messageStop" in event and role is not None: for block in content_blocks.values(): if "toolUse" not in block: diff --git a/tests/integrations/boto3/test_bedrock.py b/tests/integrations/boto3/test_bedrock.py index cb7a57ff66..2cf9a3cd71 100644 --- a/tests/integrations/boto3/test_bedrock.py +++ b/tests/integrations/boto3/test_bedrock.py @@ -990,6 +990,9 @@ def test_converse_stream( assert span["attributes"][SPANDATA.GEN_AI_REQUEST_MAX_TOKENS] == 1024 assert span["attributes"][SPANDATA.GEN_AI_REQUEST_TEMPERATURE] == 0.7 assert span["attributes"][SPANDATA.GEN_AI_REQUEST_TOP_P] == 0.9 + assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == [ + "end_turn" + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 assert span["attributes"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30 @@ -1029,6 +1032,7 @@ def test_converse_stream( assert span["data"][SPANDATA.GEN_AI_REQUEST_MAX_TOKENS] == 1024 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 span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30 From e72921f70a7786987486620e28a74fb129b07fab Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 11 Nov 2026 18:09:47 +0100 Subject: [PATCH 06/13] feat(bedrock): Set gen_ai.output.messages for streamed responses --- .../integrations/boto3/_services/bedrock.py | 242 +++++++++++++++-- tests/integrations/boto3/test_bedrock.py | 248 ++++++++++++++++++ 2 files changed, 471 insertions(+), 19 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/bedrock.py b/sentry_sdk/integrations/boto3/_services/bedrock.py index e7b7ddcf2e..c95a935dd2 100644 --- a/sentry_sdk/integrations/boto3/_services/bedrock.py +++ b/sentry_sdk/integrations/boto3/_services/bedrock.py @@ -93,37 +93,241 @@ def sentry_patched_iter( span.set_attribute if isinstance(span, StreamedSpan) else span.set_data ) + role: "Optional[str]" = None + parts: "dict[int, Union[TextPart, ReasoningPart, ToolCallPart, ToolCallResponsePart, UriPart, BlobPart]]" = {} + try: for event in original_iter(self): - if "metadata" not in event: + if "metadata" in event: + usage = event["metadata"]["usage"] + + input_tokens = usage["inputTokens"] + set_on_span( + SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage["outputTokens"] + ) + set_on_span( + SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage["totalTokens"] + ) + + cache_read_tokens = usage.get("cacheReadInputTokens") + if cache_read_tokens is not None: + input_tokens += cache_read_tokens + set_on_span( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, cache_read_tokens + ) + + cache_write_tokens = usage.get("cacheWriteInputTokens") + if cache_write_tokens is not None: + input_tokens += cache_write_tokens + set_on_span( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE, + cache_write_tokens, + ) + + set_on_span( + SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, + input_tokens, + ) + yield event continue - usage = event["metadata"]["usage"] + client = sentry_sdk.get_client() + if ( + not has_data_collection_enabled(client.options) + or not client.options["data_collection"]["gen_ai"]["outputs"] + ) and ( + has_data_collection_enabled(client.options) + or not should_send_default_pii() + ): + yield event + continue + + if "messageStart" in event: + role = event["messageStart"]["role"] - input_tokens = usage["inputTokens"] - set_on_span(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS, usage["outputTokens"]) - set_on_span(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS, usage["totalTokens"]) + yield event + continue - cache_read_tokens = usage.get("cacheReadInputTokens") - if cache_read_tokens is not None: - input_tokens += cache_read_tokens + if "messageStop" in event and role is not None: set_on_span( - SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHED, cache_read_tokens + SPANDATA.GEN_AI_OUTPUT_MESSAGES, + json.dumps( + [ + { + "role": role, + "parts": [parts[index] for index in sorted(parts)], + } + ] + ), ) - cache_write_tokens = usage.get("cacheWriteInputTokens") - if cache_write_tokens is not None: - input_tokens += cache_write_tokens - set_on_span( - SPANDATA.GEN_AI_USAGE_INPUT_TOKENS_CACHE_WRITE, - cache_write_tokens, + yield event + continue + + if ( + "contentBlockStart" in event + and "toolUse" in event["contentBlockStart"]["start"] + ): + tool_call_part: "ToolCallPart" = { + "type": "tool_call", + "name": event["contentBlockStart"]["start"]["toolUse"]["name"], + } + parts[event["contentBlockStart"]["contentBlockIndex"]] = ( + tool_call_part ) - set_on_span( - SPANDATA.GEN_AI_USAGE_INPUT_TOKENS, - input_tokens, - ) + yield event + continue + + if ( + "contentBlockStart" in event + and "toolResult" in event["contentBlockStart"]["start"] + ): + tool_call_response_part: "ToolCallResponsePart" = { + "type": "tool_call_response", + } + parts[event["contentBlockStart"]["contentBlockIndex"]] = ( + tool_call_response_part + ) + + yield event + continue + + if "contentBlockDelta" not in event: + yield event + continue + + index = event["contentBlockDelta"]["contentBlockIndex"] + delta = event["contentBlockDelta"]["delta"] + + if "text" in delta and index not in parts: + parts[index] = {"type": "text", "content": delta["text"]} + + yield event + continue + + if "text" in delta: + cast("TextPart", parts[index])["content"] += delta["text"] + + yield event + continue + + if "toolUse" in delta and index not in parts: + parts[index] = { + "type": "tool_call", + "arguments": delta["toolUse"]["input"], + } + + yield event + continue + + if "toolUse" in delta and "arguments" in parts[index]: + cast("ToolCallPart", parts[index])["arguments"] += delta["toolUse"][ + "input" + ] + + yield event + continue + + if "toolUse" in delta: + cast("ToolCallPart", parts[index])["arguments"] = delta["toolUse"][ + "input" + ] + + yield event + continue + + if "toolResult" in delta and index not in parts: + parts[index] = { + "type": "tool_call_response", + "response": [ + item + for item in delta["toolResult"] + if "json" in item or "text" in item + ], + } + + yield event + continue + + if "toolResult" in delta and "response" in parts[index]: + cast("ToolCallResponsePart", parts[index])["response"] += [ + item + for item in delta["toolResult"] + if "json" in item or "text" in item + ] + + yield event + continue + + if "toolResult" in delta: + cast("ToolCallResponsePart", parts[index])["response"] = [ + item + for item in delta["toolResult"] + if "json" in item or "text" in item + ] + + yield event + continue + + if "reasoningContent" in delta and index not in parts: + parts[index] = {"type": "reasoning"} + + if "text" in delta["reasoningContent"]: + cast("ReasoningPart", parts[index])["content"] = delta[ + "reasoningContent" + ]["text"] + + yield event + continue + + if ( + "reasoningContent" in delta + and "text" in delta["reasoningContent"] + and "content" in parts[index] + ): + cast("ReasoningPart", parts[index])["content"] += delta[ + "reasoningContent" + ]["text"] + + yield event + continue + + if "reasoningContent" in delta and "text" in delta["reasoningContent"]: + cast("ReasoningPart", parts[index])["content"] = delta[ + "reasoningContent" + ]["text"] + + yield event + continue + + if ( + "image" in delta + and "source" in delta["image"] + and "s3Location" in delta["image"]["source"] + ): + parts[index] = { + "type": "uri", + "modality": "image", + "uri": delta["image"]["source"]["s3Location"]["uri"], + } + + yield event + continue + + if ( + "image" in delta + and "source" in delta["image"] + and "bytes" in delta["image"]["source"] + ): + parts[index] = { + "type": "blob", + "modality": "image", + } + + yield event + continue yield event except GeneratorExit: diff --git a/tests/integrations/boto3/test_bedrock.py b/tests/integrations/boto3/test_bedrock.py index 3ecd3f357e..cef2505109 100644 --- a/tests/integrations/boto3/test_bedrock.py +++ b/tests/integrations/boto3/test_bedrock.py @@ -1,5 +1,7 @@ +import base64 import io import json +from typing import Any from unittest.mock import Mock import boto3 @@ -1320,3 +1322,249 @@ def test_converse_stream_input_attributes( json.loads(span["data"][SPANDATA.GEN_AI_INPUT_MESSAGES]) == expected_input_messages ) + + +@pytest.mark.parametrize("data_collection", [True, False]) +@pytest.mark.parametrize("span_streaming", [True, False]) +@pytest.mark.parametrize("stream_gen_ai_spans", [True, False]) +def test_converse_stream_output_attributes( + sentry_init, + capture_items, + capture_events, + data_collection, + span_streaming, + stream_gen_ai_spans, + converse_stream_response, +): + if data_collection: + sentry_init( + traces_sample_rate=1.0, + integrations=[Boto3Integration()], + data_collection={"gen_ai": {"outputs": True}}, + stream_gen_ai_spans=stream_gen_ai_spans, + trace_lifecycle="stream" if span_streaming else "static", + ) + else: + sentry_init( + traces_sample_rate=1.0, + integrations=[Boto3Integration()], + send_default_pii=True, + stream_gen_ai_spans=stream_gen_ai_spans, + trace_lifecycle="stream" if span_streaming else "static", + ) + + stream_events: "list[tuple[str, dict[str, Any]]]" = [ + ("messageStart", {"role": "assistant"}), + ( + "contentBlockDelta", + {"contentBlockIndex": 0, "delta": {"text": "What is the weather "}}, + ), + ( + "contentBlockDelta", + {"contentBlockIndex": 0, "delta": {"text": "in Paris?"}}, + ), + ("contentBlockStop", {"contentBlockIndex": 0}), + ( + "contentBlockStart", + { + "contentBlockIndex": 1, + "start": {"toolUse": {"toolUseId": "tooluse_1", "name": "get_weather"}}, + }, + ), + ( + "contentBlockDelta", + {"contentBlockIndex": 1, "delta": {"toolUse": {"input": '{"location": '}}}, + ), + ( + "contentBlockDelta", + {"contentBlockIndex": 1, "delta": {"toolUse": {"input": '"Paris"}'}}}, + ), + ("contentBlockStop", {"contentBlockIndex": 1}), + ] + expected_parts: "list[dict[str, Any]]" = [ + {"type": "text", "content": "What is the weather in Paris?"}, + { + "type": "tool_call", + "name": "get_weather", + "arguments": '{"location": "Paris"}', + }, + ] + + if BOTOCORE_VERSION >= Version("1.37.0"): + stream_events += [ + ( + "contentBlockDelta", + { + "contentBlockIndex": 2, + "delta": {"reasoningContent": {"text": "The user wants "}}, + }, + ), + ( + "contentBlockDelta", + { + "contentBlockIndex": 2, + "delta": {"reasoningContent": {"text": "the weather in Paris."}}, + }, + ), + ( + "contentBlockDelta", + { + "contentBlockIndex": 2, + "delta": {"reasoningContent": {"signature": "signature"}}, + }, + ), + ("contentBlockStop", {"contentBlockIndex": 2}), + ] + expected_parts.append( + {"type": "reasoning", "content": "The user wants the weather in Paris."} + ) + + if BOTOCORE_VERSION >= Version("1.42.1"): + stream_events += [ + ( + "contentBlockStart", + { + "contentBlockIndex": 3, + "start": { + "toolResult": {"toolUseId": "tooluse_1", "status": "success"} + }, + }, + ), + ( + "contentBlockDelta", + { + "contentBlockIndex": 3, + "delta": { + "toolResult": [{"json": {"temperature": 18, "unit": "celsius"}}] + }, + }, + ), + ( + "contentBlockDelta", + { + "contentBlockIndex": 3, + "delta": {"toolResult": [{"text": "Partly cloudy"}]}, + }, + ), + ("contentBlockStop", {"contentBlockIndex": 3}), + ( + "contentBlockStart", + {"contentBlockIndex": 4, "start": {"image": {"format": "png"}}}, + ), + ( + "contentBlockDelta", + { + "contentBlockIndex": 4, + "delta": { + "image": { + "source": {"bytes": base64.b64encode(b"image").decode()} + } + }, + }, + ), + ("contentBlockStop", {"contentBlockIndex": 4}), + ( + "contentBlockStart", + {"contentBlockIndex": 5, "start": {"image": {"format": "png"}}}, + ), + ( + "contentBlockDelta", + { + "contentBlockIndex": 5, + "delta": { + "image": { + "source": { + "s3Location": {"uri": "s3://my-bucket/images/photo.png"} + } + } + }, + }, + ), + ("contentBlockStop", {"contentBlockIndex": 5}), + ] + expected_parts += [ + { + "type": "tool_call_response", + "response": [ + {"json": {"temperature": 18, "unit": "celsius"}}, + {"text": "Partly cloudy"}, + ], + }, + {"type": "blob", "modality": "image"}, + { + "type": "uri", + "modality": "image", + "uri": "s3://my-bucket/images/photo.png", + }, + ] + + stream_events += [ + ("messageStop", {"stopReason": "end_turn"}), + ( + "metadata", + { + "usage": {"inputTokens": 10, "outputTokens": 20, "totalTokens": 30}, + "metrics": {"latencyMs": 500}, + }, + ), + ] + + converse_stream_response["stream"]._event_generator = iter( + EventStreamMessage( + prelude=None, + headers={":message-type": "event", ":event-type": event_type}, + payload=json.dumps(payload).encode(), + crc=None, + ) + for event_type, payload in stream_events + ) + + session = boto3.Session( + aws_access_key_id="-", aws_secret_access_key="-", region_name="us-east-1" + ) + client = session.client("bedrock-runtime") + mock_http = Mock(status_code=200) + client.meta.events.register( + "before-call.bedrock-runtime.ConverseStream", + lambda **kwargs: (mock_http, converse_stream_response), + ) + + if span_streaming or stream_gen_ai_spans: + items = capture_items("span") + + with sentry_sdk.start_transaction(name="parent"): + response = client.converse_stream( + modelId="anthropic.claude-3-sonnet-20240229-v1:0", + messages=[{"role": "user", "content": [{"text": "Hi"}]}], + ) + for _ in response["stream"]: + pass + + sentry_sdk.flush() + spans = [item.payload for item in items] + (span,) = ( + span + for span in spans + if span["attributes"].get("sentry.op") == OP.GEN_AI_CHAT + ) + + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] + else: + events = capture_events() + + with sentry_sdk.start_transaction(name="parent"): + response = client.converse_stream( + modelId="anthropic.claude-3-sonnet-20240229-v1:0", + messages=[{"role": "user", "content": [{"text": "Hi"}]}], + ) + for _ in response["stream"]: + pass + + (transaction,) = events + (span,) = transaction["spans"] + + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] From 7d0b930cb1ce53230e9888f4de3e910f19a96c56 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 11 Nov 2026 21:38:42 +0100 Subject: [PATCH 07/13] empty commit From 80267a21c95cd353bdb39710ec431137ddd5939f Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 11 Nov 2026 22:45:37 +0100 Subject: [PATCH 08/13] empty commit From 38a00abcaf812b17a3b77643cdbf7bd78d06e3d9 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 12 Nov 2026 09:18:15 +0100 Subject: [PATCH 09/13] feat(bedrock): Set gen_ai.response.time_to_first_chunk --- sentry_sdk/integrations/boto3/_client.py | 3 ++ .../integrations/boto3/_services/base.py | 5 +++- .../integrations/boto3/_services/bedrock.py | 30 +++++++++++++++++-- tests/integrations/boto3/test_bedrock.py | 2 ++ 4 files changed, 36 insertions(+), 4 deletions(-) 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 2d5508d8d3..60a0008194 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]", @@ -58,6 +63,8 @@ def _finish_span( if not hasattr(stream, "_span"): return + stream = cast("_PatchedEventStream", stream) + span = stream._span del stream._span @@ -89,6 +96,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 +106,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 +142,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_TOKEN, + 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) @@ -608,9 +625,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 @@ -747,8 +767,12 @@ def on_success_response( return if ctx.operation_name == "ConverseStream": - stream = cast("ConverseStreamResponseTypeDef", response)["stream"] - stream._span = span # type: ignore[attr-defined] + stream = cast( + "_PatchedEventStream", + cast("ConverseStreamResponseTypeDef", response)["stream"], + ) + stream._span = span + stream._request_start_time = self.request_start_time return if isinstance(span, StreamedSpan): diff --git a/tests/integrations/boto3/test_bedrock.py b/tests/integrations/boto3/test_bedrock.py index 4be765e4aa..82ac33ed39 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 span["attributes"][SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_TOKEN] > 0 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 span["data"][SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_TOKEN] > 0 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30 From 00ed1f796375ebe0dde42dbf04b793008b024116 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 12 Nov 2026 11:34:08 +0100 Subject: [PATCH 10/13] remove an unnecessary cast --- sentry_sdk/integrations/boto3/_services/bedrock.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/bedrock.py b/sentry_sdk/integrations/boto3/_services/bedrock.py index 97db650f5e..1a0e16dee0 100644 --- a/sentry_sdk/integrations/boto3/_services/bedrock.py +++ b/sentry_sdk/integrations/boto3/_services/bedrock.py @@ -63,8 +63,6 @@ def _finish_span( if not hasattr(stream, "_span"): return - stream = cast("_PatchedEventStream", stream) - span = stream._span del stream._span From 3981f4cf317411e71dd84d2a259b7a28ca5efd3a Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 12 Nov 2026 11:35:12 +0100 Subject: [PATCH 11/13] remove another cast --- sentry_sdk/integrations/boto3/_services/bedrock.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/bedrock.py b/sentry_sdk/integrations/boto3/_services/bedrock.py index 1a0e16dee0..84a9193147 100644 --- a/sentry_sdk/integrations/boto3/_services/bedrock.py +++ b/sentry_sdk/integrations/boto3/_services/bedrock.py @@ -759,12 +759,9 @@ def on_success_response( return if ctx.operation_name == "ConverseStream": - stream = cast( - "_PatchedEventStream", - cast("ConverseStreamResponseTypeDef", response)["stream"], - ) - stream._span = span - stream._request_start_time = self.request_start_time + 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): From 6942d8e77aaa9cc6d06a336e1b6d828d0bb8466a Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 12 Nov 2026 12:10:46 +0100 Subject: [PATCH 12/13] use non-deprecated attribute --- sentry_sdk/consts.py | 6 ++++++ sentry_sdk/integrations/boto3/_services/bedrock.py | 2 +- tests/integrations/boto3/test_bedrock.py | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) 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/_services/bedrock.py b/sentry_sdk/integrations/boto3/_services/bedrock.py index 84a9193147..7a58b84443 100644 --- a/sentry_sdk/integrations/boto3/_services/bedrock.py +++ b/sentry_sdk/integrations/boto3/_services/bedrock.py @@ -142,7 +142,7 @@ def sentry_patched_iter( if start_time is not None and "contentBlockDelta" in event: set_on_span( - SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_TOKEN, + SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK, time.perf_counter() - start_time, ) start_time = None diff --git a/tests/integrations/boto3/test_bedrock.py b/tests/integrations/boto3/test_bedrock.py index 82ac33ed39..6094b64831 100644 --- a/tests/integrations/boto3/test_bedrock.py +++ b/tests/integrations/boto3/test_bedrock.py @@ -995,7 +995,7 @@ def test_converse_stream( assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == [ "end_turn" ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_TOKEN] > 0 + assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK] > 0 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 assert span["attributes"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30 @@ -1036,7 +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 span["data"][SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_TOKEN] > 0 + assert span["data"][SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK] > 0 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30 From a0dff72ed0a2ee0be6cdaffa14a09b60aecf0f85 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 12 Nov 2026 12:19:27 +0100 Subject: [PATCH 13/13] simplify test --- tests/integrations/boto3/test_bedrock.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integrations/boto3/test_bedrock.py b/tests/integrations/boto3/test_bedrock.py index 6094b64831..013f650e66 100644 --- a/tests/integrations/boto3/test_bedrock.py +++ b/tests/integrations/boto3/test_bedrock.py @@ -995,7 +995,7 @@ def test_converse_stream( assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == [ "end_turn" ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK] > 0 + 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 @@ -1036,7 +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 span["data"][SPANDATA.GEN_AI_RESPONSE_TIME_TO_FIRST_CHUNK] > 0 + 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