From b787a9a9d30a35e676c7a118701131624c1d5494 Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Thu, 5 Nov 2026 10:28:04 +0100 Subject: [PATCH 01/10] fix(sampling): Respect incoming sample rate if not explicitly overridden (#8185) ### Description If there's an incoming sampling decision, `traces_sample_rate` is irrelevant and shouldn't overwrite the sampling rate in the DSC. There are cases where we do want to override the incoming sampling decision, but only in cases where the SDK actually uses a different sample rate for some reason (`traces_sampler`). #### Issues Closes https://github.com/getsentry/sentry-python/issues/8182 #### Reminders - Please add tests to validate your changes, and lint your code using `uv run ruff`. - Add GH Issue ID _&_ Linear ID (if applicable) - PR title should use [conventional commit](https://develop.sentry.dev/engineering-practices/commit-messages/#type) style (`feat:`, `fix:`, `ref:`, `meta:`) - For external contributors: [CONTRIBUTING.md](https://github.com/getsentry/sentry-python/blob/master/CONTRIBUTING.md), [Sentry SDK development docs](https://develop.sentry.dev/sdk/), [Discord community](https://discord.gg/Ww9hbqr) --- sentry_sdk/scope.py | 16 ++++++++++++++-- tests/integrations/stdlib/test_httplib.py | 4 ++-- tests/test_dsc.py | 8 ++++---- tests/tracing/test_integration_tests.py | 6 +++--- tests/tracing/test_span_streaming.py | 12 ++---------- 5 files changed, 25 insertions(+), 21 deletions(-) diff --git a/sentry_sdk/scope.py b/sentry_sdk/scope.py index 0d3a0c6cd3..78f974a533 100644 --- a/sentry_sdk/scope.py +++ b/sentry_sdk/scope.py @@ -1188,10 +1188,17 @@ def start_transaction( "parent_sampled": transaction.parent_sampled, } sampling_context.update(custom_sampling_context) + + keep_incoming_sample_rate = ( + transaction.sampled is None + and transaction.parent_sampled is not None + and not callable(client.options.get("traces_sampler")) + ) + transaction._set_initial_sampling_decision(sampling_context=sampling_context) # update the sample rate in the dsc - if transaction.sample_rate is not None: + if transaction.sample_rate is not None and not keep_incoming_sample_rate: propagation_context = self.get_active_propagation_context() baggage = propagation_context.baggage @@ -1337,7 +1344,12 @@ def start_streamed_span( self, ) - if sample_rate is not None: + keep_incoming_sample_rate = ( + propagation_context.parent_sampled is not None + and not callable(self.get_client().options.get("traces_sampler")) + ) + + if sample_rate is not None and not keep_incoming_sample_rate: self._update_sample_rate(sample_rate) if sampled is False or sampled is None: diff --git a/tests/integrations/stdlib/test_httplib.py b/tests/integrations/stdlib/test_httplib.py index db9c94bc51..068714d4dc 100644 --- a/tests/integrations/stdlib/test_httplib.py +++ b/tests/integrations/stdlib/test_httplib.py @@ -457,7 +457,7 @@ def getresponse(self, *args, **kwargs): expected_outgoing_baggage = ( "sentry-trace_id=771a43a4192642f0b136d5159a501700," "sentry-public_key=49d0f7386ad645858ae85020e393bef3," - "sentry-sample_rate=1.0," + "sentry-sample_rate=0.01337," "sentry-user_id=Am%C3%A9lie," "sentry-sample_rand=0.000005," "sentry-sampled=true" @@ -487,7 +487,7 @@ def getresponse(self, *args, **kwargs): expected_outgoing_baggage = ( "sentry-trace_id=771a43a4192642f0b136d5159a501700," "sentry-public_key=49d0f7386ad645858ae85020e393bef3," - "sentry-sample_rate=1.0," + "sentry-sample_rate=0.01337," "sentry-user_id=Am%C3%A9lie," "sentry-sample_rand=0.000005," "sentry-sampled=true" diff --git a/tests/test_dsc.py b/tests/test_dsc.py index 7aa4669c60..a3c7eda7e5 100644 --- a/tests/test_dsc.py +++ b/tests/test_dsc.py @@ -201,7 +201,7 @@ def test_dsc_continuation_of_trace(sentry_init, capture_envelopes): dsn="https://mysecret@o1234.ingest.sentry.io/12312012", release="myapp@0.0.1", environment="canary", - traces_sample_rate=1.0, + traces_sample_rate=0.5, transport=TestTransportWithOptions, ) envelopes = capture_envelopes() @@ -244,7 +244,7 @@ def test_dsc_continuation_of_trace(sentry_init, capture_envelopes): assert "sample_rate" in envelope_trace_header assert type(envelope_trace_header["sample_rate"]) == str - assert envelope_trace_header["sample_rate"] == "1.0" + assert envelope_trace_header["sample_rate"] == "0.01337" assert "sampled" in envelope_trace_header assert type(envelope_trace_header["sampled"]) == str @@ -272,7 +272,7 @@ def test_dsc_continuation_of_trace_span_streaming(sentry_init, capture_envelopes dsn="https://mysecret@o1234.ingest.sentry.io/12312012", release="myapp@0.0.1", environment="canary", - traces_sample_rate=1.0, + traces_sample_rate=0.5, trace_lifecycle="stream", transport=TestTransportWithOptions, ) @@ -318,7 +318,7 @@ def test_dsc_continuation_of_trace_span_streaming(sentry_init, capture_envelopes assert "sample_rate" in envelope_trace_header assert type(envelope_trace_header["sample_rate"]) == str - assert envelope_trace_header["sample_rate"] == "1.0" + assert envelope_trace_header["sample_rate"] == "0.01337" assert "sampled" in envelope_trace_header assert type(envelope_trace_header["sampled"]) == str diff --git a/tests/tracing/test_integration_tests.py b/tests/tracing/test_integration_tests.py index 59bb1f9d02..3a3447a69f 100644 --- a/tests/tracing/test_integration_tests.py +++ b/tests/tracing/test_integration_tests.py @@ -189,7 +189,7 @@ def test_continue_trace(sentry_init, capture_envelopes, parent_sampled, sample_r ) if parent_sampled is not None: - expected_sample_rate = str(float(parent_sampled)) + expected_sample_rate = "0.01337" else: expected_sample_rate = str(sample_rate) @@ -262,7 +262,7 @@ def test_continue_trace_span_streaming( assert not baggage.mutable if parent_sampled is not None: - expected_sample_rate = str(float(parent_sampled)) + expected_sample_rate = parent_sample_rate else: if sample_rate is None: expected_sample_rate = parent_sample_rate @@ -312,7 +312,7 @@ def test_continue_trace_span_streaming( ) if parent_sampled is not None: - expected_sample_rate = str(float(parent_sampled)) + expected_sample_rate = parent_sample_rate else: if sample_rate is None: expected_sample_rate = parent_sample_rate diff --git a/tests/tracing/test_span_streaming.py b/tests/tracing/test_span_streaming.py index c73eef678b..74c4a28fb3 100644 --- a/tests/tracing/test_span_streaming.py +++ b/tests/tracing/test_span_streaming.py @@ -1082,12 +1082,7 @@ def test_outgoing_traceparent_and_baggage_incoming_trace( baggage_items = dict(tuple(item.split("=")) for item in baggage.split(",")) # As we've received incoming baggage, we shouldn't modify it ourselves - # and should propagate it as-is. However, in the case where our - # effective sample rate overrides the parent sample rate, we update the - # sample rate in the baggage as a consequence of updating the sample rate - # in the DSC. - if traces_sample_rate is not None: - incoming_baggage["sentry-sample_rate"] = str(float(parent_sampled)) + # and should propagate it as-is assert baggage_items == incoming_baggage @@ -1133,12 +1128,9 @@ def test_outgoing_traceparent_and_baggage_inconsistent_incoming_trace( span_id = span.span_id assert traceparent == f"{trace_id}-{span_id}-0" - # We shouldn't be updating incoming baggage, but in the case where our - # effective sample rate overrides the parent sample rate, we do this as - # a consequence of updating the sample rate in the DSC. + # We shouldn't be updating incoming baggage baggage = sentry_sdk.get_baggage() baggage_items = dict(tuple(item.split("=")) for item in baggage.split(",")) - incoming_baggage["sentry-sample_rate"] = "0.0" assert baggage_items == incoming_baggage From 7b1b1cbe3d1f184faf6c7a3e580fcc737e58dcb5 Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Thu, 5 Nov 2026 14:06:53 +0000 Subject: [PATCH 02/10] feat(anthropic): Record reasoning parts in nonstreaming output (#8192) Include reasoning parts in `gen_ai.output.messages` based on Anthropic thinking blocks. Extend `EXAMPLE_MESSAGE` in tests to include a thinking part when a supported library version is installed. Stop re-using `EXAMPLE_MESSAGE` in streaming responses and create two fixtures to reduce duplication among streaming responses in tests. --- sentry_sdk/integrations/anthropic.py | 24 +- .../integrations/anthropic/test_anthropic.py | 1654 ++++++++--------- 2 files changed, 788 insertions(+), 890 deletions(-) diff --git a/sentry_sdk/integrations/anthropic.py b/sentry_sdk/integrations/anthropic.py index 868b94e792..33ebbbdc5e 100644 --- a/sentry_sdk/integrations/anthropic.py +++ b/sentry_sdk/integrations/anthropic.py @@ -85,7 +85,7 @@ ToolUnionParam, ) - from sentry_sdk._types import TextPart + from sentry_sdk._types import ReasoningPart, TextPart class _PatchedRawMessageStream(Stream[Any]): _span: Union[Span, StreamedSpan] @@ -731,30 +731,30 @@ def _set_output_data( record_outputs = True if record_outputs: - output_messages: "dict[str, list[Any]]" = { - "response": [], - "tool": [], - } + parts: "list[Union[TextPart, ReasoningPart]]" = [] + tool_calls = [] for output in content_blocks: if output["type"] == "text": - output_messages["response"].append(output["text"]) + parts.append({"type": "text", "content": output["text"]}) + elif output["type"] == "thinking": + parts.append({"type": "reasoning", "content": output["thinking"]}) elif output["type"] == "tool_use": - output_messages["tool"].append(output) + tool_calls.append(output) - if len(output_messages["tool"]) > 0: + if len(tool_calls) > 0: set_data_normalized( span, SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS, - output_messages["tool"], + tool_calls, unpack=False, ) - if len(output_messages["response"]) > 0: + if len(parts) > 0: set_data_normalized( span, - SPANDATA.GEN_AI_RESPONSE_TEXT, - output_messages["response"], + SPANDATA.GEN_AI_OUTPUT_MESSAGES, + [{"role": "assistant", "parts": parts}], unpack=False, ) diff --git a/tests/integrations/anthropic/test_anthropic.py b/tests/integrations/anthropic/test_anthropic.py index 19a9f846ad..81062e90d1 100644 --- a/tests/integrations/anthropic/test_anthropic.py +++ b/tests/integrations/anthropic/test_anthropic.py @@ -23,6 +23,12 @@ async def __call__(self, *args, **kwargs): from anthropic.types.message_delta_event import MessageDeltaEvent from anthropic.types.message_start_event import MessageStartEvent +try: + from anthropic.types import ThinkingBlock +except ImportError: + ThinkingBlock = None + + try: from anthropic import APIStatusError from anthropic.types import ErrorResponse, OverloadedError @@ -78,7 +84,18 @@ async def __call__(self, *args, **kwargs): id="msg_01XFDUDYJgAACzvnptvVoYEL", model="model", role="assistant", - content=[TextBlock(type="text", text="Hi, I'm Claude.")], + content=( + [ + ThinkingBlock( + type="thinking", + thinking="Let me reason step by step...", + signature="sig123", + ), + TextBlock(type="text", text="Hi, I'm Claude."), + ] + if ThinkingBlock is not None + else [TextBlock(type="text", text="Hi, I'm Claude.")] + ), type="message", stop_reason="end_turn", usage=Usage(input_tokens=10, output_tokens=20), @@ -138,6 +155,83 @@ def data_collection_tool_use_message(): ) +@pytest.fixture +def streaming_anthropic_response(): + def inner(text_deltas, stop_reason): + return [ + MessageStartEvent( + message=Message( + id="msg_01XFDUDYJgAACzvnptvVoYEL", + model="model", + role="assistant", + content=[TextBlock(type="text", text="")], + type="message", + stop_reason="end_turn", + usage=Usage(input_tokens=10, output_tokens=20), + ), + type="message_start", + ), + ContentBlockStartEvent( + type="content_block_start", + index=0, + content_block=TextBlock(type="text", text=""), + ), + *[ + ContentBlockDeltaEvent( + delta=TextDelta(text=text, type="text_delta"), + index=0, + type="content_block_delta", + ) + for text in text_deltas + ], + ContentBlockStopEvent(type="content_block_stop", index=0), + MessageDeltaEvent( + delta=Delta(stop_reason=stop_reason), + usage=MessageDeltaUsage(output_tokens=10), + type="message_delta", + ), + ] + + return inner + + +@pytest.fixture +def streaming_anthropic_error_response(): + return [ + MessageStartEvent( + message=Message( + id="msg_01XFDUDYJgAACzvnptvVoYEL", + model="model", + role="assistant", + content=[TextBlock(type="text", text="")], + type="message", + stop_reason="end_turn", + usage=Usage(input_tokens=10, output_tokens=20), + ), + type="message_start", + ), + ContentBlockStartEvent( + type="content_block_start", + index=0, + content_block=TextBlock(type="text", text=""), + ), + ContentBlockDeltaEvent( + delta=TextDelta(text="Hi", type="text_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=TextDelta(text="!", type="text_delta"), + index=0, + type="content_block_delta", + ), + ErrorResponse( + type="error", + error=OverloadedError(message="Overloaded", type="overloaded_error"), + ), + ] + + @pytest.mark.parametrize("span_streaming", [True, False]) @pytest.mark.parametrize("stream_gen_ai_spans", [True, False]) @pytest.mark.parametrize( @@ -219,12 +313,23 @@ def test_nonstreaming_create_message( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -276,12 +381,23 @@ def test_nonstreaming_create_message( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -328,12 +444,23 @@ def test_nonstreaming_create_message( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -748,15 +875,23 @@ def test_nonstreaming_create_message_data_collection_outputs( assert span_data[SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 if outputs_collected: - assert span_data[SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - [DATA_COLLECTION_EXPECTED_RESPONSE_TEXT] - ) + assert json.loads(span_data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": DATA_COLLECTION_EXPECTED_RESPONSE_TEXT, + } + ], + } + ] assert ( json.loads(span_data[SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS]) == DATA_COLLECTION_EXPECTED_TOOL_CALLS ) else: - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span_data + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span_data assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS not in span_data @@ -875,15 +1010,23 @@ async def test_nonstreaming_create_message_data_collection_outputs_async( assert span_data[SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 if outputs_collected: - assert span_data[SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - [DATA_COLLECTION_EXPECTED_RESPONSE_TEXT] - ) + assert json.loads(span_data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + { + "type": "text", + "content": DATA_COLLECTION_EXPECTED_RESPONSE_TEXT, + } + ], + } + ] assert ( json.loads(span_data[SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS]) == DATA_COLLECTION_EXPECTED_TOOL_CALLS ) else: - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span_data + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span_data assert SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS not in span_data @@ -969,12 +1112,23 @@ async def test_nonstreaming_create_message_async( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -1024,12 +1178,23 @@ async def test_nonstreaming_create_message_async( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -1073,12 +1238,23 @@ async def test_nonstreaming_create_message_async( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -1110,43 +1286,16 @@ def test_streaming_create_message( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): client = Anthropic(api_key="z") response = get_model_response( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason="max_tokens", + ) ) ) @@ -1209,13 +1358,16 @@ def test_streaming_create_message( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -1269,13 +1421,16 @@ def test_streaming_create_message( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -1322,13 +1477,16 @@ def test_streaming_create_message( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -1384,6 +1542,7 @@ def test_streaming_create_message_data_collection( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): sentry_init_kwargs = dict( integrations=[AnthropicIntegration(include_prompts=include_prompts)], @@ -1401,28 +1560,10 @@ def test_streaming_create_message_data_collection( response = get_model_response( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi",), + stop_reason="max_tokens", + ) ) ) @@ -1533,6 +1674,7 @@ def test_streaming_create_message_data_collection_outputs( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): sentry_init_kwargs = dict( integrations=[AnthropicIntegration(include_prompts=include_prompts)], @@ -1550,33 +1692,10 @@ def test_streaming_create_message_data_collection_outputs( response = get_model_response( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="! I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "! I'm Claude!"), + stop_reason="max_tokens", + ) ) ) @@ -1627,11 +1746,14 @@ def test_streaming_create_message_data_collection_outputs( assert span_data[SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 if outputs_collected: - assert span_data[SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span_data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span_data + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span_data @pytest.mark.parametrize("span_streaming", [True, False]) @@ -1644,43 +1766,16 @@ def test_streaming_create_message_close( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): client = Anthropic(api_key="z") response = get_model_response( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason="max_tokens", + ) ) ) @@ -1734,7 +1829,9 @@ def test_streaming_create_message_close( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -1780,7 +1877,9 @@ def test_streaming_create_message_close( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -1825,7 +1924,9 @@ def test_streaming_create_message_close( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -1850,39 +1951,12 @@ def test_streaming_create_message_api_error( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_error_response, ): client = Anthropic(api_key="z") response = get_model_response( - server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ErrorResponse( - type="error", - error=OverloadedError( - message="Overloaded", type="overloaded_error" - ), - ), - ] - ) + server_side_event_chunks(streaming_anthropic_error_response) ) sentry_init( @@ -1934,7 +2008,9 @@ def test_streaming_create_message_api_error( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -1979,7 +2055,9 @@ def test_streaming_create_message_api_error( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -2025,7 +2103,9 @@ def test_streaming_create_message_api_error( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -2061,43 +2141,16 @@ def test_stream_messages( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): client = Anthropic(api_key="z") response = get_model_response( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason="max_tokens", + ) ) ) @@ -2160,13 +2213,16 @@ def test_stream_messages( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -2220,13 +2276,16 @@ def test_stream_messages( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -2273,12 +2332,15 @@ def test_stream_messages( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -2344,6 +2406,7 @@ def test_stream_messages_data_collection_outputs( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): sentry_init_kwargs = dict( integrations=[AnthropicIntegration(include_prompts=include_prompts)], @@ -2361,33 +2424,10 @@ def test_stream_messages_data_collection_outputs( response = get_model_response( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="! I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "! I'm Claude!"), + stop_reason="max_tokens", + ) ) ) @@ -2439,11 +2479,14 @@ def test_stream_messages_data_collection_outputs( assert span_data[SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 if outputs_collected: - assert span_data[SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span_data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span_data + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span_data @pytest.mark.parametrize("span_streaming", [True, False]) @@ -2456,43 +2499,16 @@ def test_stream_messages_close( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): client = Anthropic(api_key="z") response = get_model_response( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason="max_tokens", + ) ) ) @@ -2550,7 +2566,9 @@ def test_stream_messages_close( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -2600,7 +2618,9 @@ def test_stream_messages_close( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -2649,7 +2669,9 @@ def test_stream_messages_close( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -2674,39 +2696,12 @@ def test_stream_messages_api_error( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_error_response, ): client = Anthropic(api_key="z") response = get_model_response( - server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ErrorResponse( - type="error", - error=OverloadedError( - message="Overloaded", type="overloaded_error" - ), - ), - ] - ) + server_side_event_chunks(streaming_anthropic_error_response) ) sentry_init( @@ -2758,7 +2753,9 @@ def test_stream_messages_api_error( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -2804,7 +2801,9 @@ def test_stream_messages_api_error( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -2850,7 +2849,9 @@ def test_stream_messages_api_error( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -2888,46 +2889,19 @@ async def test_streaming_create_message_async( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): client = AsyncAnthropic(api_key="z") response = get_model_response( async_iterator( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason="max_tokens", + ) ) - ), + ) ) sentry_init( @@ -2990,13 +2964,16 @@ async def test_streaming_create_message_async( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -3049,13 +3026,16 @@ async def test_streaming_create_message_async( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -3103,13 +3083,16 @@ async def test_streaming_create_message_async( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -3177,6 +3160,7 @@ async def test_streaming_create_message_data_collection_outputs_async( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): sentry_init_kwargs = dict( integrations=[AnthropicIntegration(include_prompts=include_prompts)], @@ -3195,35 +3179,12 @@ async def test_streaming_create_message_data_collection_outputs_async( response = get_model_response( async_iterator( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="! I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "! I'm Claude!"), + stop_reason="max_tokens", + ) ) - ), + ) ) create_kwargs = dict( @@ -3273,11 +3234,14 @@ async def test_streaming_create_message_data_collection_outputs_async( assert span_data[SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 if outputs_collected: - assert span_data[SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span_data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span_data + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span_data @pytest.mark.parametrize("span_streaming", [True, False]) @@ -3292,44 +3256,17 @@ async def test_streaming_create_message_async_close( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): client = AsyncAnthropic(api_key="z") response = get_model_response( async_iterator( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason="max_tokens", + ) ) ) ) @@ -3383,7 +3320,9 @@ async def test_streaming_create_message_async_close( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -3428,7 +3367,9 @@ async def test_streaming_create_message_async_close( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -3472,7 +3413,9 @@ async def test_streaming_create_message_async_close( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -3499,41 +3442,12 @@ async def test_streaming_create_message_async_api_error( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_error_response, ): client = AsyncAnthropic(api_key="z") response = get_model_response( - async_iterator( - server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ErrorResponse( - type="error", - error=OverloadedError( - message="Overloaded", type="overloaded_error" - ), - ), - ] - ) - ) + async_iterator(server_side_event_chunks(streaming_anthropic_error_response)) ) sentry_init( @@ -3585,7 +3499,9 @@ async def test_streaming_create_message_async_api_error( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -3631,7 +3547,9 @@ async def test_streaming_create_message_async_api_error( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -3677,7 +3595,9 @@ async def test_streaming_create_message_async_api_error( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -3715,46 +3635,19 @@ async def test_stream_message_async( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): client = AsyncAnthropic(api_key="z") response = get_model_response( async_iterator( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason=None, + ) ) - ), + ) ) sentry_init( @@ -3817,13 +3710,16 @@ async def test_stream_message_async( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -3874,13 +3770,16 @@ async def test_stream_message_async( "content": "Hello, Claude", }, ] - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -3925,13 +3824,16 @@ async def test_stream_message_async( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -3998,6 +3900,7 @@ async def test_stream_messages_data_collection_outputs_async( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): sentry_init_kwargs = dict( integrations=[AnthropicIntegration(include_prompts=include_prompts)], @@ -4016,35 +3919,12 @@ async def test_stream_messages_data_collection_outputs_async( response = get_model_response( async_iterator( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="! I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "! I'm Claude!"), + stop_reason="max_tokens", + ) ) - ), + ) ) stream_kwargs = dict( @@ -4093,11 +3973,14 @@ async def test_stream_messages_data_collection_outputs_async( assert span_data[SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 if outputs_collected: - assert span_data[SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span_data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span_data + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span_data @pytest.mark.parametrize("span_streaming", [True, False]) @@ -4112,45 +3995,16 @@ async def test_stream_messages_async_api_error( capture_events, capture_items, get_model_response, - async_iterator, - server_side_event_chunks, - stream_gen_ai_spans, - span_streaming, -): - client = AsyncAnthropic(api_key="z") - - response = get_model_response( - async_iterator( - server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ErrorResponse( - type="error", - error=OverloadedError( - message="Overloaded", type="overloaded_error" - ), - ), - ] - ) - ) + async_iterator, + server_side_event_chunks, + stream_gen_ai_spans, + span_streaming, + streaming_anthropic_error_response, +): + client = AsyncAnthropic(api_key="z") + + response = get_model_response( + async_iterator(server_side_event_chunks(streaming_anthropic_error_response)) ) sentry_init( @@ -4202,7 +4056,9 @@ async def test_stream_messages_async_api_error( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -4246,7 +4102,9 @@ async def test_stream_messages_async_api_error( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -4292,7 +4150,9 @@ async def test_stream_messages_async_api_error( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -4319,44 +4179,17 @@ async def test_stream_messages_async_close( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): client = AsyncAnthropic(api_key="z") response = get_model_response( async_iterator( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(stop_reason="max_tokens"), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason="max_tokens", + ) ) ) ) @@ -4418,7 +4251,9 @@ async def test_stream_messages_async_close( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -4468,7 +4303,9 @@ async def test_stream_messages_async_close( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -4520,7 +4357,9 @@ async def test_stream_messages_async_close( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps(["Hi!"]) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": [{"type": "text", "content": "Hi!"}]} + ] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -4675,12 +4514,17 @@ def test_streaming_create_message_with_input_json_delta( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -4721,12 +4565,17 @@ def test_streaming_create_message_with_input_json_delta( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -4768,12 +4617,17 @@ def test_streaming_create_message_with_input_json_delta( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -4925,12 +4779,17 @@ def test_stream_messages_with_input_json_delta( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -4971,12 +4830,17 @@ def test_stream_messages_with_input_json_delta( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -5017,12 +4881,17 @@ def test_stream_messages_with_input_json_delta( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -5181,13 +5050,18 @@ async def test_streaming_create_message_with_input_json_delta_async( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -5228,13 +5102,18 @@ async def test_streaming_create_message_with_input_json_delta_async( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -5275,13 +5154,18 @@ async def test_streaming_create_message_with_input_json_delta_async( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -5441,12 +5325,17 @@ async def test_stream_message_with_input_json_delta_async( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -5488,12 +5377,17 @@ async def test_stream_message_with_input_json_delta_async( span["attributes"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -5535,13 +5429,18 @@ async def test_stream_message_with_input_json_delta_async( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "What is the weather like in San Francisco?"}]' ) - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ['{"location": "San Francisco, CA"}'] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": '{"location": "San Francisco, CA"}'} + ], + } + ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 366 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41 @@ -6061,9 +5960,14 @@ def test_set_output_data_with_input_json_delta(sentry_init): content_blocks=[{"text": "".join(json_deltas), "type": "text"}], ) - assert span._data.get(SPANDATA.GEN_AI_RESPONSE_TEXT) == json.dumps( - ["{'test': 'data','more': 'json'}"] - ) + assert json.loads(span._data.get(SPANDATA.GEN_AI_OUTPUT_MESSAGES)) == [ + { + "role": "assistant", + "parts": [ + {"type": "text", "content": "{'test': 'data','more': 'json'}"} + ], + } + ] assert span._data.get(SPANDATA.GEN_AI_USAGE_INPUT_TOKENS) == 10 assert span._data.get(SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS) == 20 assert span._data.get(SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS) == 30 @@ -6361,13 +6265,24 @@ def test_nonstreaming_create_message_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -6423,13 +6338,24 @@ def test_nonstreaming_create_message_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -6484,13 +6410,24 @@ def test_nonstreaming_create_message_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["data"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -6586,13 +6523,24 @@ async def test_nonstreaming_create_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -6648,13 +6596,24 @@ async def test_nonstreaming_create_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -6709,13 +6668,24 @@ async def test_nonstreaming_create_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi, I'm Claude."] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi, I'm Claude."}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi, I'm Claude."}] ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + {"role": "assistant", "parts": expected_parts} + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["data"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 @@ -6745,44 +6715,17 @@ def test_streaming_create_message_with_system_prompt( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): """Test that system prompts are properly captured in streaming mode.""" client = Anthropic(api_key="z") response = get_model_response( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason=None, + ) ) ) @@ -6850,14 +6793,17 @@ def test_streaming_create_message_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -6913,14 +6859,17 @@ def test_streaming_create_message_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -6975,14 +6924,17 @@ def test_streaming_create_message_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["data"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -7011,44 +6963,17 @@ def test_stream_messages_with_system_prompt( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): """Test that system prompts are properly captured in streaming mode.""" client = Anthropic(api_key="z") response = get_model_response( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason=None, + ) ) ) @@ -7112,13 +7037,16 @@ def test_stream_messages_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -7170,13 +7098,16 @@ def test_stream_messages_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -7226,13 +7157,16 @@ def test_stream_messages_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["data"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -7263,6 +7197,7 @@ async def test_stream_message_with_system_prompt_async( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): """Test that system prompts are properly captured in streaming mode (async).""" client = AsyncAnthropic(api_key="z") @@ -7270,38 +7205,10 @@ async def test_stream_message_with_system_prompt_async( response = get_model_response( async_iterator( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason=None, + ) ) ) ) @@ -7368,13 +7275,16 @@ async def test_stream_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -7428,13 +7338,16 @@ async def test_stream_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -7486,14 +7399,17 @@ async def test_stream_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["data"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -7524,6 +7440,7 @@ async def test_streaming_create_message_with_system_prompt_async( server_side_event_chunks, stream_gen_ai_spans, span_streaming, + streaming_anthropic_response, ): """Test that system prompts are properly captured in streaming mode (async).""" client = AsyncAnthropic(api_key="z") @@ -7531,38 +7448,10 @@ async def test_streaming_create_message_with_system_prompt_async( response = get_model_response( async_iterator( server_side_event_chunks( - [ - MessageStartEvent( - message=EXAMPLE_MESSAGE, - type="message_start", - ), - ContentBlockStartEvent( - type="content_block_start", - index=0, - content_block=TextBlock(type="text", text=""), - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="Hi", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text="!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockDeltaEvent( - delta=TextDelta(text=" I'm Claude!", type="text_delta"), - index=0, - type="content_block_delta", - ), - ContentBlockStopEvent(type="content_block_stop", index=0), - MessageDeltaEvent( - delta=Delta(), - usage=MessageDeltaUsage(output_tokens=10), - type="message_delta", - ), - ] + streaming_anthropic_response( + text_deltas=("Hi", "!", " I'm Claude!"), + stop_reason=None, + ) ) ) ) @@ -7632,14 +7521,17 @@ async def test_streaming_create_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -7696,14 +7588,17 @@ async def test_streaming_create_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["attributes"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["attributes"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["attributes"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["attributes"] assert span["attributes"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["attributes"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 @@ -7756,14 +7651,17 @@ async def test_streaming_create_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" - assert span["data"][SPANDATA.GEN_AI_RESPONSE_TEXT] == json.dumps( - ["Hi! I'm Claude!"] - ) + assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ + { + "role": "assistant", + "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["data"] assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] - assert SPANDATA.GEN_AI_RESPONSE_TEXT not in span["data"] + assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span["data"] assert span["data"][SPANDATA.GEN_AI_USAGE_INPUT_TOKENS] == 10 assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 From 28d4c31dd0849d12d6cf3943e85dad82248211c2 Mon Sep 17 00:00:00 2001 From: Alex Alderman Webb Date: Thu, 5 Nov 2026 14:20:44 +0000 Subject: [PATCH 03/10] feat(anthropic): Record reasoning parts in streaming output (#8193) Accumulate thinking output in the same format as the nonstreaming path so `_set_output_data()` can format the thinking parts as reasoning parts that the product understands. --- sentry_sdk/integrations/anthropic.py | 42 +- .../integrations/anthropic/test_anthropic.py | 581 +++++++++++++----- 2 files changed, 458 insertions(+), 165 deletions(-) diff --git a/sentry_sdk/integrations/anthropic.py b/sentry_sdk/integrations/anthropic.py index 33ebbbdc5e..6af004f209 100644 --- a/sentry_sdk/integrations/anthropic.py +++ b/sentry_sdk/integrations/anthropic.py @@ -64,6 +64,12 @@ except ImportError: raise DidNotEnable("Anthropic not installed") +try: + from anthropic.types import ThinkingBlock, ThinkingDelta +except ImportError: + ThinkingBlock = None # type: ignore[assignment,misc] + ThinkingDelta = None # type: ignore[assignment,misc] + if TYPE_CHECKING: from typing import ( Any, @@ -93,7 +99,7 @@ class _PatchedRawMessageStream(Stream[Any]): _model: Optional[ModelParam] _usage: "_RecordedUsage" - _content_blocks: list[Any] + _content_blocks: "list[dict[str, Any]]" _response_id: Optional[str] _finish_reason: Optional[str] @@ -103,7 +109,7 @@ class _PatchedMessageStream(MessageStream): _model: Optional[ModelParam] _usage: "_RecordedUsage" - _content_blocks: list[Any] + _content_blocks: "list[dict[str, Any]]" _response_id: Optional[str] _finish_reason: Optional[str] @@ -113,7 +119,7 @@ class _PatchedRawAsyncMessageStream(AsyncStream[Any]): _model: Optional[ModelParam] _usage: "_RecordedUsage" - _content_blocks: list[Any] + _content_blocks: "list[dict[str, Any]]" _response_id: Optional[str] _finish_reason: Optional[str] @@ -123,7 +129,7 @@ class _PatchedAsyncMessageStream(AsyncMessageStream): _model: Optional[ModelParam] _usage: "_RecordedUsage" - _content_blocks: list[Any] + _content_blocks: "list[dict[str, Any]]" _response_id: Optional[str] _finish_reason: Optional[str] @@ -365,22 +371,32 @@ def _collect_ai_data( event: "MessageStreamEvent", model: "str | None", usage: "_RecordedUsage", - content_blocks: "list[str]", + content_blocks: "list[dict[str, Any]]", response_id: "str | None" = None, finish_reason: "str | None" = None, -) -> "tuple[str | None, _RecordedUsage, list[str], str | None, str | None]": +) -> "tuple[str | None, _RecordedUsage, list[dict[str, Any]], str | None, str | None]": """ Collect model information, token usage, and collect content blocks from the AI streaming response. """ with capture_internal_exceptions(): if hasattr(event, "type"): if event.type == "content_block_start": - pass + if ThinkingBlock is not None and isinstance( + event.content_block, ThinkingBlock + ): + content_blocks.append({"type": "thinking", "thinking": ""}) + elif event.content_block.type in ("text", "tool_use"): + content_blocks.append({"type": "text", "text": ""}) + else: + content_blocks.append({"type": event.content_block.type}) elif event.type == "content_block_delta": - if hasattr(event.delta, "text"): - content_blocks.append(event.delta.text) + block = content_blocks[event.index] + if ThinkingDelta is not None and isinstance(event.delta, ThinkingDelta): + block["thinking"] += event.delta.thinking + elif hasattr(event.delta, "text"): + block["text"] += event.delta.text elif hasattr(event.delta, "partial_json"): - content_blocks.append(event.delta.partial_json) + block["text"] += event.delta.partial_json elif event.type == "content_block_stop": pass @@ -707,7 +723,7 @@ def _set_output_data( output_tokens: "int | None", cache_read_input_tokens: "int | None", cache_write_input_tokens: "int | None", - content_blocks: "list[Any]", + content_blocks: "list[dict[str, Any]]", response_id: "str | None" = None, finish_reason: "str | None" = None, ) -> None: @@ -1042,7 +1058,7 @@ def _set_streaming_output_data( integration: "AnthropicIntegration", model: "Optional[str]", usage: "_RecordedUsage", - content_blocks: "list[str]", + content_blocks: "list[dict[str, Any]]", response_id: "Optional[str]", finish_reason: "Optional[str]", ) -> None: @@ -1065,7 +1081,7 @@ def _set_streaming_output_data( output_tokens=usage.output_tokens, cache_read_input_tokens=usage.cache_read_input_tokens, cache_write_input_tokens=usage.cache_write_input_tokens, - content_blocks=[{"text": "".join(content_blocks), "type": "text"}], + content_blocks=content_blocks, response_id=response_id, finish_reason=finish_reason, ) diff --git a/tests/integrations/anthropic/test_anthropic.py b/tests/integrations/anthropic/test_anthropic.py index 81062e90d1..2e6fe7c0dc 100644 --- a/tests/integrations/anthropic/test_anthropic.py +++ b/tests/integrations/anthropic/test_anthropic.py @@ -24,9 +24,10 @@ async def __call__(self, *args, **kwargs): from anthropic.types.message_start_event import MessageStartEvent try: - from anthropic.types import ThinkingBlock + from anthropic.types import ThinkingBlock, ThinkingDelta except ImportError: ThinkingBlock = None + ThinkingDelta = None try: @@ -158,6 +159,29 @@ def data_collection_tool_use_message(): @pytest.fixture def streaming_anthropic_response(): def inner(text_deltas, stop_reason): + thinking_events = [] + if ThinkingBlock is not None: + thinking_events = [ + ContentBlockStartEvent( + type="content_block_start", + index=0, + content_block=ThinkingBlock( + type="thinking", + thinking="", + signature="sig123", + ), + ), + ContentBlockDeltaEvent( + delta=ThinkingDelta( + thinking="Let me reason step by step...", + type="thinking_delta", + ), + index=0, + type="content_block_delta", + ), + ContentBlockStopEvent(type="content_block_stop", index=0), + ] + text_index = 1 if ThinkingBlock is not None else 0 return [ MessageStartEvent( message=Message( @@ -171,20 +195,21 @@ def inner(text_deltas, stop_reason): ), type="message_start", ), + *thinking_events, ContentBlockStartEvent( type="content_block_start", - index=0, + index=text_index, content_block=TextBlock(type="text", text=""), ), *[ ContentBlockDeltaEvent( delta=TextDelta(text=text, type="text_delta"), - index=0, + index=text_index, type="content_block_delta", ) for text in text_deltas ], - ContentBlockStopEvent(type="content_block_stop", index=0), + ContentBlockStopEvent(type="content_block_stop", index=text_index), MessageDeltaEvent( delta=Delta(stop_reason=stop_reason), usage=MessageDeltaUsage(output_tokens=10), @@ -195,6 +220,50 @@ def inner(text_deltas, stop_reason): return inner +@pytest.fixture +def streaming_anthropic_text_response(): + return [ + MessageStartEvent( + message=Message( + id="msg_01XFDUDYJgAACzvnptvVoYEL", + model="model", + role="assistant", + content=[TextBlock(type="text", text="")], + type="message", + stop_reason="end_turn", + usage=Usage(input_tokens=10, output_tokens=20), + ), + type="message_start", + ), + ContentBlockStartEvent( + type="content_block_start", + index=0, + content_block=TextBlock(type="text", text=""), + ), + ContentBlockDeltaEvent( + delta=TextDelta(text="Hi", type="text_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=TextDelta(text="!", type="text_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockDeltaEvent( + delta=TextDelta(text=" I'm Claude!", type="text_delta"), + index=0, + type="content_block_delta", + ), + ContentBlockStopEvent(type="content_block_stop", index=0), + MessageDeltaEvent( + delta=Delta(stop_reason="max_tokens"), + usage=MessageDeltaUsage(output_tokens=10), + type="message_delta", + ), + ] + + @pytest.fixture def streaming_anthropic_error_response(): return [ @@ -1358,11 +1427,19 @@ def test_streaming_create_message( "content": "Hello, Claude", }, ] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -1421,11 +1498,19 @@ def test_streaming_create_message( "content": "Hello, Claude", }, ] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -1477,11 +1562,19 @@ def test_streaming_create_message( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -1746,11 +1839,19 @@ def test_streaming_create_message_data_collection_outputs( assert span_data[SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 if outputs_collected: + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span_data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span_data @@ -1766,17 +1867,12 @@ def test_streaming_create_message_close( server_side_event_chunks, stream_gen_ai_spans, span_streaming, - streaming_anthropic_response, + streaming_anthropic_text_response, ): client = Anthropic(api_key="z") response = get_model_response( - server_side_event_chunks( - streaming_anthropic_response( - text_deltas=("Hi", "!", " I'm Claude!"), - stop_reason="max_tokens", - ) - ) + server_side_event_chunks(streaming_anthropic_text_response) ) sentry_init( @@ -2213,11 +2309,19 @@ def test_stream_messages( "content": "Hello, Claude", }, ] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -2276,11 +2380,19 @@ def test_stream_messages( "content": "Hello, Claude", }, ] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -2332,11 +2444,19 @@ def test_stream_messages( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: assert SPANDATA.GEN_AI_REQUEST_MESSAGES not in span["data"] @@ -2479,11 +2599,19 @@ def test_stream_messages_data_collection_outputs( assert span_data[SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 if outputs_collected: + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span_data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span_data @@ -2499,17 +2627,12 @@ def test_stream_messages_close( server_side_event_chunks, stream_gen_ai_spans, span_streaming, - streaming_anthropic_response, + streaming_anthropic_text_response, ): client = Anthropic(api_key="z") response = get_model_response( - server_side_event_chunks( - streaming_anthropic_response( - text_deltas=("Hi", "!", " I'm Claude!"), - stop_reason="max_tokens", - ) - ) + server_side_event_chunks(streaming_anthropic_text_response) ) sentry_init( @@ -2964,11 +3087,19 @@ async def test_streaming_create_message_async( "content": "Hello, Claude", }, ] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -3026,11 +3157,19 @@ async def test_streaming_create_message_async( "content": "Hello, Claude", }, ] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -3083,11 +3222,19 @@ async def test_streaming_create_message_async( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -3234,11 +3381,19 @@ async def test_streaming_create_message_data_collection_outputs_async( assert span_data[SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 if outputs_collected: + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span_data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span_data @@ -3256,19 +3411,12 @@ async def test_streaming_create_message_async_close( server_side_event_chunks, stream_gen_ai_spans, span_streaming, - streaming_anthropic_response, + streaming_anthropic_text_response, ): client = AsyncAnthropic(api_key="z") response = get_model_response( - async_iterator( - server_side_event_chunks( - streaming_anthropic_response( - text_deltas=("Hi", "!", " I'm Claude!"), - stop_reason="max_tokens", - ) - ) - ) + async_iterator(server_side_event_chunks(streaming_anthropic_text_response)) ) sentry_init( @@ -3710,11 +3858,19 @@ async def test_stream_message_async( "content": "Hello, Claude", }, ] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -3770,11 +3926,19 @@ async def test_stream_message_async( "content": "Hello, Claude", }, ] + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -3824,11 +3988,19 @@ async def test_stream_message_async( span["data"][SPANDATA.GEN_AI_REQUEST_MESSAGES] == '[{"role": "user", "content": "Hello, Claude"}]' ) + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -3973,11 +4145,19 @@ async def test_stream_messages_data_collection_outputs_async( assert span_data[SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 10 if outputs_collected: + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span_data[SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: assert SPANDATA.GEN_AI_OUTPUT_MESSAGES not in span_data @@ -4179,19 +4359,12 @@ async def test_stream_messages_async_close( server_side_event_chunks, stream_gen_ai_spans, span_streaming, - streaming_anthropic_response, + streaming_anthropic_text_response, ): client = AsyncAnthropic(api_key="z") response = get_model_response( - async_iterator( - server_side_event_chunks( - streaming_anthropic_response( - text_deltas=("Hi", "!", " I'm Claude!"), - stop_reason="max_tokens", - ) - ) - ) + async_iterator(server_side_event_chunks(streaming_anthropic_text_response)) ) sentry_init( @@ -5909,7 +6082,14 @@ async def test_span_origin_async( ) @pytest.mark.parametrize("span_streaming", [True, False]) def test_collect_ai_data_with_input_json_delta(span_streaming): - event = ContentBlockDeltaEvent( + start_event = ContentBlockStartEvent( + type="content_block_start", + index=0, + content_block=ToolUseBlock( + type="tool_use", id="toolu_123", name="get_weather", input={} + ), + ) + delta_event = ContentBlockDeltaEvent( delta=InputJSONDelta(partial_json="test", type="input_json_delta"), index=0, type="content_block_delta", @@ -5920,15 +6100,16 @@ def test_collect_ai_data_with_input_json_delta(span_streaming): usage.output_tokens = 20 usage.input_tokens = 10 - content_blocks = [] + content_blocks: list = [] + _collect_ai_data(start_event, model, usage, content_blocks) model, new_usage, new_content_blocks, response_id, finish_reason = _collect_ai_data( - event, model, usage, content_blocks + delta_event, model, usage, content_blocks ) assert model is None assert new_usage.input_tokens == usage.input_tokens assert new_usage.output_tokens == usage.output_tokens - assert new_content_blocks == ["test"] + assert new_content_blocks == [{"type": "text", "text": "test"}] assert response_id is None assert finish_reason is None @@ -6793,11 +6974,19 @@ def test_streaming_create_message_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -6859,11 +7048,19 @@ def test_streaming_create_message_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -6924,11 +7121,19 @@ def test_streaming_create_message_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -7037,11 +7242,19 @@ def test_stream_messages_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] @@ -7098,11 +7311,19 @@ def test_stream_messages_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] @@ -7157,11 +7378,19 @@ def test_stream_messages_with_system_prompt( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["data"] @@ -7275,11 +7504,19 @@ async def test_stream_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] @@ -7338,11 +7575,19 @@ async def test_stream_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in span["attributes"] @@ -7399,11 +7644,19 @@ async def test_stream_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -7521,11 +7774,19 @@ async def test_streaming_create_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -7588,11 +7849,19 @@ async def test_streaming_create_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["attributes"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: @@ -7651,11 +7920,19 @@ async def test_streaming_create_message_with_system_prompt_async( assert len(stored_messages) == 1 assert stored_messages[0]["role"] == "user" assert stored_messages[0]["content"] == "Hello, Claude" + expected_parts = ( + [ + { + "type": "reasoning", + "content": "Let me reason step by step...", + }, + {"type": "text", "content": "Hi! I'm Claude!"}, + ] + if ThinkingBlock is not None + else [{"type": "text", "content": "Hi! I'm Claude!"}] + ) assert json.loads(span["data"][SPANDATA.GEN_AI_OUTPUT_MESSAGES]) == [ - { - "role": "assistant", - "parts": [{"type": "text", "content": "Hi! I'm Claude!"}], - } + {"role": "assistant", "parts": expected_parts} ] else: From 14c7fd5338e455112bbdf4d75cbc0864c7e97d31 Mon Sep 17 00:00:00 2001 From: alexander-alderman-webb <224035503+alexander-alderman-webb@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:22:06 +0000 Subject: [PATCH 04/10] release: 2.74.0 --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++++++++++++ docs/conf.py | 2 +- sentry_sdk/consts.py | 2 +- setup.py | 2 +- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65766a8809..6ef8ed800b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,57 @@ # Changelog +## 2.74.0 + +### New Features ✨ + +#### Anthropic + +- Record reasoning parts in streaming output by @alexander-alderman-webb in [#8193](https://github.com/getsentry/sentry-python/pull/8193) +- Record reasoning parts in nonstreaming output by @alexander-alderman-webb in [#8192](https://github.com/getsentry/sentry-python/pull/8192) + +#### Other + +- (typesafe) Handle inline images by @alexander-alderman-webb in [#8164](https://github.com/getsentry/sentry-python/pull/8164) + +### Bug Fixes 🐛 + +#### Anthropic + +- Preserve cached `_iterator` property by @alexander-alderman-webb in [#8163](https://github.com/getsentry/sentry-python/pull/8163) +- Stop unpacking output arrays by @alexander-alderman-webb in [#8160](https://github.com/getsentry/sentry-python/pull/8160) + +#### Other + +- (aiohttp) Leave existing `baggage` header alone if it has existin… by @pabloDeputter in [#8116](https://github.com/getsentry/sentry-python/pull/8116) +- (boto3) Adapt to botocore no longer signing baggage by @sentrivana in [#8113](https://github.com/getsentry/sentry-python/pull/8113) +- (ci) Restrict pytest because of incompatibilities by @sentrivana in [#8140](https://github.com/getsentry/sentry-python/pull/8140) +- (falcon) Handle integer HTTP statuses by @jap and @sentrivana in [#8139](https://github.com/getsentry/sentry-python/pull/8139) +- (sampling) Respect incoming sample rate if not explicitly overridden by @sentrivana in [#8185](https://github.com/getsentry/sentry-python/pull/8185) +- (tracing) Drop trailing comma when serializing baggage without third-party items by @alphasafal and @pabloDeputter in [#8119](https://github.com/getsentry/sentry-python/pull/8119) +- (wsgi) Stop overwriting `server.address` by @alexander-alderman-webb in [#8171](https://github.com/getsentry/sentry-python/pull/8171) + +### Internal Changes 🔧 + +#### Deps + +- Lock file maintenance by @renovate in [#8055](https://github.com/getsentry/sentry-python/pull/8055) +- Bump grpcio to 1.84.1 by @geoffg-sentry with @sentry-junior in [#8136](https://github.com/getsentry/sentry-python/pull/8136) +- Bump uv to 0.13.5 by @geoffg-sentry with @sentry-junior in [#8135](https://github.com/getsentry/sentry-python/pull/8135) +- Bump brotli to 1.2.1 by @geoffg-sentry with @sentry-junior in [#8122](https://github.com/getsentry/sentry-python/pull/8122) + +#### Other + +- (mcp) Remove unused select_mcp_transactions fixture by @sentrivana with @sentry-junior in [#8150](https://github.com/getsentry/sentry-python/pull/8150) +- (release) Bump craft to 3.9.0 and update config by @BYK with @sentry-junior in [#8181](https://github.com/getsentry/sentry-python/pull/8181) +- (sessions) Remove unused TERMINAL_SESSION_STATES constant by @sentrivana with @sentry-junior in [#8151](https://github.com/getsentry/sentry-python/pull/8151) +- (starlette) Remove unused \_serialize_request_body_data helper by @sentry-junior in [#8146](https://github.com/getsentry/sentry-python/pull/8146) +- (toxgen) Remove unused `get_file_hash()` and `get_last_updated()` helpers by @sentry-junior in [#8173](https://github.com/getsentry/sentry-python/pull/8173) +- (tracing) Remove unused base64_stripped regex by @sentry-junior in [#8148](https://github.com/getsentry/sentry-python/pull/8148) +- (utils) Remove unused to_timestamp helper and epoch constant by @sentry-junior in [#8147](https://github.com/getsentry/sentry-python/pull/8147) +- 🤖 Update test matrix with new releases (11/02) by @sentrivana with @github-actions in [#8172](https://github.com/getsentry/sentry-python/pull/8172) +- Upgrade `sphinx` and remove `packaging` upper bound by @alexander-alderman-webb in [#8183](https://github.com/getsentry/sentry-python/pull/8183) +- Update test matrix with new releases (27/10) by @alexander-alderman-webb in [#8126](https://github.com/getsentry/sentry-python/pull/8126) + ## 2.73.0 ### New Features ✨ diff --git a/docs/conf.py b/docs/conf.py index 4c31db873a..67a64d056c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,7 +17,7 @@ copyright = "2019-{}, Sentry Team and Contributors".format(datetime.now().year) author = "Sentry Team and Contributors" -release = "2.73.0" +release = "2.74.0" version = ".".join(release.split(".")[:2]) # The short X.Y version. diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index be2660433e..446a95b8b6 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1924,4 +1924,4 @@ def _get_default_options() -> "dict[str, Any]": del _get_default_options -VERSION = "2.73.0" +VERSION = "2.74.0" diff --git a/setup.py b/setup.py index 8a066a2506..18c0c2d6c8 100644 --- a/setup.py +++ b/setup.py @@ -22,7 +22,7 @@ def get_file_text(file_name): setup( name="sentry-sdk", - version="2.73.0", + version="2.74.0", author="Sentry Team and Contributors", author_email="hello@sentry.io", url="https://github.com/getsentry/sentry-python", From 17dac06a8f66b1444a7280969e153a4039903a51 Mon Sep 17 00:00:00 2001 From: Michi Hoffmann Date: Fri, 6 Nov 2026 09:04:29 +0100 Subject: [PATCH 05/10] docs: Point Sentry Conventions links to conventions.sentry.dev (#8200) Migrate to https://conventions.sentry.dev/ Co-authored-by: Claude Opus 5.5 --- sentry_sdk/traces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/traces.py b/sentry_sdk/traces.py index d9f0d6e596..0d317be083 100644 --- a/sentry_sdk/traces.py +++ b/sentry_sdk/traces.py @@ -77,7 +77,7 @@ def __str__(self) -> str: # The source of a segment name, see -# https://getsentry.github.io/sentry-conventions/attributes/sentry/#sentry-segment-name-source +# https://conventions.sentry.dev/attributes/sentry/#sentry-segment-name-source class SegmentNameSource(str, Enum): COMPONENT = "component" CUSTOM = "custom" From 5a099b8d5ce1f5b2f0e4f7d7fe3e6459d5b03245 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 6 Nov 2026 09:25:07 +0100 Subject: [PATCH 06/10] chore(warden): Enable incremental review (#8199) Turns on Warden incremental review for `find-bugs`, `code-review`, `skill-scanner`. On each push, these skills now skip hunks that an earlier clean run on the same PR already reviewed, instead of re-reviewing the whole PR. Other skills in this repo keep full review. Warden falls back to a full review when there is no usable baseline: first push, force push, config or skill change, or an earlier run that requested changes or had failures. This needs the org base config to set `incrementalApp = "sentry-warden"` (getsentry/.github#223); until that lands, Warden warns and keeps doing full reviews. via **Jeffrey Hung**. -- [View Session](https://junior-prod.sentry.dev/conversations/slack%3AD0AH1TP6CHJ%3A1793906366.333339) [[Sentry]](https://sentry.sentry.io/explore/conversations/slack%3AD0AH1TP6CHJ%3A1793906366.333339/?project=4510944073809921) Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Jeffrey Hung --- warden.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/warden.toml b/warden.toml index 8b1ff4ea9d..39b3824116 100644 --- a/warden.toml +++ b/warden.toml @@ -32,6 +32,7 @@ reportOn = "medium" [[skills]] name = "code-review" +incremental = true [[skills.triggers]] type = "pull_request" @@ -39,6 +40,7 @@ actions = ["opened", "synchronize", "reopened"] [[skills]] name = "find-bugs" +incremental = true [[skills.triggers]] type = "pull_request" @@ -46,6 +48,7 @@ actions = ["opened", "synchronize", "reopened"] [[skills]] name = "skill-scanner" +incremental = true [[skills.triggers]] type = "pull_request" From d565fed3932d83a04731cecc0a7910a916888de3 Mon Sep 17 00:00:00 2001 From: Dan Fuller Date: Fri, 6 Nov 2026 05:56:07 -0500 Subject: [PATCH 07/10] feat(crons): Mark @monitor jobs so scheduler integrations skip them (#8198) ### Description Marks functions wrapped with `@sentry_sdk.monitor`, so scheduler integrations can skip jobs that already send their own check-ins. The arq integration now does this, so those jobs don't report twice. Also moves arq's time zone lookup into `sentry_sdk/crons/utils.py` so other cron integrations can share it, and stops sending names that aren't IANA zones (like tzlocal's `local`), which Sentry would reject. This is the base for https://github.com/getsentry/sentry-python/pull/7840 (APScheduler), which uses both. ### Testing New arq test for a `@sentry_sdk.monitor` cron job. The existing arq time zone tests cover the helper. --- sentry_sdk/crons/decorator.py | 2 ++ sentry_sdk/crons/utils.py | 26 ++++++++++++++++++++++++ sentry_sdk/integrations/arq.py | 25 ++++++++--------------- tests/integrations/arq/test_arq_crons.py | 20 ++++++++++++++++++ 4 files changed, 57 insertions(+), 16 deletions(-) diff --git a/sentry_sdk/crons/decorator.py b/sentry_sdk/crons/decorator.py index b13d350e15..fd4677709d 100644 --- a/sentry_sdk/crons/decorator.py +++ b/sentry_sdk/crons/decorator.py @@ -126,6 +126,7 @@ async def inner(*args: "P.args", **kwargs: "P.kwargs") -> "R": with self: return await fn(*args, **kwargs) + inner._sentry_monitored = True # type: ignore[attr-defined] return inner def _sync_wrapper(self, fn: "Callable[P, R]") -> "Callable[P, R]": @@ -134,4 +135,5 @@ def inner(*args: "P.args", **kwargs: "P.kwargs") -> "R": with self: return fn(*args, **kwargs) + inner._sentry_monitored = True # type: ignore[attr-defined] return inner diff --git a/sentry_sdk/crons/utils.py b/sentry_sdk/crons/utils.py index d5fa55e500..01a70c1c1b 100644 --- a/sentry_sdk/crons/utils.py +++ b/sentry_sdk/crons/utils.py @@ -1,6 +1,9 @@ +import re +from datetime import timedelta, timezone from typing import TYPE_CHECKING if TYPE_CHECKING: + from datetime import tzinfo from typing import Optional, Tuple from sentry_sdk._types import MonitorConfigSchedule, MonitorConfigScheduleUnit @@ -27,3 +30,26 @@ def _get_interval_schedule(seconds: float) -> "Optional[MonitorConfigSchedule]": "unit": unit, } return None + + +# IANA zone names start with an uppercase letter. This rejects names like +# "local" or "localtime" that tzlocal can give a zone. +_IANA_TIMEZONE_NAME = re.compile(r"[A-Z][A-Za-z0-9._+-]*(?:/[A-Za-z0-9._+-]+)*") + + +def _get_timezone_name(tz: "tzinfo") -> "Optional[str]": + """ + Return the IANA name of the timezone, or None if it has none. + """ + name = getattr(tz, "key", None) or getattr(tz, "zone", None) + if isinstance(name, str): + return name if _IANA_TIMEZONE_NAME.fullmatch(name) else None + + if isinstance(tz, timezone): + offset = tz.utcoffset(None) + if offset % timedelta(hours=1): + return None + hours = offset // timedelta(hours=1) + return "Etc/GMT%+d" % -hours if hours else "UTC" + + return None diff --git a/sentry_sdk/integrations/arq.py b/sentry_sdk/integrations/arq.py index 623dda3258..502fe0355d 100644 --- a/sentry_sdk/integrations/arq.py +++ b/sentry_sdk/integrations/arq.py @@ -1,10 +1,11 @@ import sys import time -from datetime import timedelta, timezone +from functools import wraps import sentry_sdk from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS from sentry_sdk.crons import monitor +from sentry_sdk.crons.utils import _get_timezone_name from sentry_sdk.integrations import DidNotEnable, Integration, _check_minimum_version from sentry_sdk.integrations.logging import ignore_logger from sentry_sdk.scope import should_send_default_pii @@ -221,6 +222,7 @@ def event_processor(event: "Event", hint: "Hint") -> "Optional[Event]": def _wrap_coroutine(name: str, coroutine: "WorkerCoroutine") -> "WorkerCoroutine": + @wraps(coroutine) async def _sentry_coroutine( ctx: "Dict[Any, Any]", *args: "Any", **kwargs: "Any" ) -> "Any": @@ -326,6 +328,9 @@ def _monitor_cron_job( return cron_job._sentry_monitored = True + if getattr(cron_job.coroutine, "_sentry_monitored", False): + return + # arq names cron jobs "cron:" by default. monitor_slug = cron_job.name if monitor_slug.startswith("cron:"): @@ -393,7 +398,7 @@ def _get_crontab(cron_job: "CronJob") -> "Optional[str]": return " ".join(fields) # type: ignore[arg-type] -def _get_timezone_name(worker: "Worker") -> "Optional[str]": +def _get_worker_timezone_name(worker: "Worker") -> "Optional[str]": tz = getattr(worker, "timezone", None) if tz is None: # Before arq 0.25, cron jobs ran in naive local time. @@ -401,27 +406,15 @@ def _get_timezone_name(worker: "Worker") -> "Optional[str]": return "UTC" return None - # zoneinfo and pytz - name = getattr(tz, "key", None) or getattr(tz, "zone", None) - if isinstance(name, str): - return name - # arq defaults to the system's fixed UTC offset at startup. - if isinstance(tz, timezone): - offset = tz.utcoffset(None) - if offset % timedelta(hours=1): - return None - hours = offset // timedelta(hours=1) - return "Etc/GMT%+d" % -hours if hours else "UTC" - - return None + return _get_timezone_name(tz) def _get_monitor_config( cron_job: "CronJob", worker: "Worker" ) -> "Optional[MonitorConfig]": crontab = _get_crontab(cron_job) - timezone_name = _get_timezone_name(worker) + timezone_name = _get_worker_timezone_name(worker) if crontab is None or timezone_name is None: return None return { diff --git a/tests/integrations/arq/test_arq_crons.py b/tests/integrations/arq/test_arq_crons.py index 3087e6e594..88686e02b1 100644 --- a/tests/integrations/arq/test_arq_crons.py +++ b/tests/integrations/arq/test_arq_crons.py @@ -7,6 +7,7 @@ from arq.connections import ArqRedis from fakeredis.aioredis import FakeRedis +import sentry_sdk from sentry_sdk.integrations.arq import ArqIntegration, _get_monitor_config try: @@ -183,6 +184,25 @@ async def test_cron_job_monitored_once(sentry_init, capture_envelopes): assert statuses[:4] == ["in_progress", "ok", "in_progress", "ok"] +@sentry_sdk.monitor(monitor_slug="decorated") +async def decorated_job(ctx): + pass + + +@pytest.mark.asyncio +async def test_monitor_decorated_cron_job_reports_once(sentry_init, capture_envelopes): + sentry_init(integrations=[ArqIntegration(monitor_cron_jobs=True)]) + envelopes = capture_envelopes() + + await _run_cron_jobs( + cron(decorated_job, run_at_startup=True), timezone=timezone.utc + ) + + check_ins = _check_ins(envelopes) + assert {c["monitor_slug"] for c in check_ins} == {"decorated"} + assert [c["status"] for c in check_ins[:2]] == ["in_progress", "ok"] + + class _Worker: def __init__(self, timezone): self.timezone = timezone From 3b49fafa3d9d782254188b3ee42e9502be3a8179 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:02:08 +0100 Subject: [PATCH 08/10] ref(profiling): Remove unused ContinuousScheduler.pause stub (#8224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description Removes `ContinuousScheduler.pause()` from `sentry_sdk/profiler/continuous_profiler.py` (3 lines). It's a stub that only raises `NotImplementedError`, and nothing implements it or calls it. #2830 (Jun 2024, "Introduce continuous profiling mode") added it next to `ensure_running()` and `teardown()`. Unlike those two, no subclass ever overrode it, and the SDK never called it. The stub makes it look like the continuous profiler can be paused, but it can't. **Evidence** - `git grep -nw pause` on `master` finds only the definition. Nothing in `sentry_sdk/`, `tests/`, `scripts/` or `docs/` refers to it. - `ThreadContinuousScheduler` and `GeventContinuousScheduler`, the only subclasses, override `ensure_running()` and `teardown()` but not `pause()`. - `git log -S "def pause"` finds only #2830. `git log -S ".pause("` across `sentry_sdk` and `tests` finds no commits, so it has never had a caller. - `ContinuousScheduler` isn't exported from `sentry_sdk.profiler` (`__all__` lists only the transaction-profiler schedulers) and isn't documented. - No open PR or issue mentions it. #7275 also touches `continuous_profiler.py`, but in different hunks (`ThreadContinuousScheduler.__init__`). **Why it's safe** - `ContinuousScheduler` is an internal class. The method is unreachable, so behavior doesn't change, and no public API or documented behavior is affected. **Checks** - `uv run ruff check` / `ruff format --check` on `sentry_sdk/profiler`: clean - `uv run --group typing mypy sentry_sdk`: no issues - `TESTPATH=tests/profiler uv run tox -e py3.14-common`: 106 passed, 69 skipped. The skipped tests are the gevent cases, which need gevent installed. #### Issues None. Found by the weekly sentry-python removal scout. via [automation `sched_65eed06cc199036c2b13411096f412d4`](https://junior-prod.sentry.dev/automations/sched_65eed06cc199036c2b13411096f412d4) · Run ID `sched_65eed06cc199036c2b13411096f412d4:1794211200000`. -- [View Session](https://junior-prod.sentry.dev/conversations/agent-dispatch%3Adispatch_0096be51ec4ca0f294d1b5786520632b) [[Sentry]](https://sentry.sentry.io/explore/conversations/agent-dispatch%3Adispatch_0096be51ec4ca0f294d1b5786520632b/?project=4510944073809921) Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> --- sentry_sdk/profiler/continuous_profiler.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/sentry_sdk/profiler/continuous_profiler.py b/sentry_sdk/profiler/continuous_profiler.py index ed525f52bd..f33a95c4b9 100644 --- a/sentry_sdk/profiler/continuous_profiler.py +++ b/sentry_sdk/profiler/continuous_profiler.py @@ -291,9 +291,6 @@ def ensure_running(self) -> None: def teardown(self) -> None: raise NotImplementedError - def pause(self) -> None: - raise NotImplementedError - def reset_buffer(self) -> None: self.buffer = ProfileBuffer( self.options, self.sdk_info, PROFILE_BUFFER_SECONDS, self.capture_func From 05c32d32d47443e59af302c76d57dd499eb05d4f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:14:15 +0100 Subject: [PATCH 09/10] chore(warden): Remove find-bugs, already covered by code-review (#8204) find-bugs overlaps code-review, so this consolidates Warden on one bug-finding skill. This repo already runs code-review on the same PR triggers, so this only drops the duplicate skill. via @dcramer. -- [View Session](https://junior-prod.sentry.dev/conversations/agent%3A002d98fb2946503b1e169e66abf9b139) [[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A002d98fb2946503b1e169e66abf9b139/?project=4510944073809921) Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: David Cramer <23610+dcramer@users.noreply.github.com> --- warden.toml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/warden.toml b/warden.toml index 39b3824116..43cc01fa3d 100644 --- a/warden.toml +++ b/warden.toml @@ -38,14 +38,6 @@ incremental = true type = "pull_request" actions = ["opened", "synchronize", "reopened"] -[[skills]] -name = "find-bugs" -incremental = true - -[[skills.triggers]] -type = "pull_request" -actions = ["opened", "synchronize", "reopened"] - [[skills]] name = "skill-scanner" incremental = true From 4cf1ee5b20d35b2a391afaeb7007d781002f2b5b Mon Sep 17 00:00:00 2001 From: Adityaaa <144815351+Shellghostt@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:28:33 +0530 Subject: [PATCH 10/10] fix(utils): look up package versions without scanning all installed packages (#8215) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Description `package_version()` in `sentry_sdk/utils.py` rebuilt the full installed-package mapping on every call: `_get_installed_modules()` iterates `importlib.metadata.distributions()` (parsing every installed distribution's metadata), and `package_version` additionally re-normalized that whole mapping per call. Integrations call `package_version` during `sentry_sdk.init()` to check minimum versions, so in environments with a few hundred installed packages this scan measurably slowed SDK startup. This change makes `package_version` look up the single requested distribution with `importlib.metadata.version()` instead: - Top-level guarded `from importlib import metadata as importlib_metadata` (no inline imports, per the repo guidelines); `PackageNotFoundError` maps to `None`, matching the previous behaviour for missing packages. - The existing `_get_installed_modules()` scan remains as the fallback for environments without `importlib.metadata` (Python < 3.8), so supported old interpreters keep working. - Name handling is unchanged: the requested name is PEP 503-normalized first, which is also how `importlib.metadata.version()` resolves distribution names. Tests in `tests/test_utils.py`: - `test_package_version_installed_package` asserts an installed package (`pytest`) resolves to the same parsed version as `importlib.metadata`. - `test_package_version_does_not_scan_all_installed_packages` monkeypatches `_get_installed_modules` to fail if invoked (the only way to assert the scan no longer happens) and asserts both hit and miss lookups still work. Validation: - `TESTPATH=tests/test_utils.py uv run tox -e py3.12-common -- -k package_version` — 3 passed. - `uv run ruff check` / `ruff format --check` on the changed files — clean. - `uv run --group typing mypy sentry_sdk` — no issues in 203 files. #### Issues * resolves: #8190 #### Reminders - Tests added and linted with `uv run ruff` as requested. - PR title uses conventional-commit style. Co-authored-by: Adityaaa <144815351+addydude@users.noreply.github.com> --- sentry_sdk/utils.py | 13 +++++++++++++ tests/test_utils.py | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index b9be3d8936..3f9c541ff4 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -28,6 +28,11 @@ # Python 3.10 and below BaseExceptionGroup = None # type: ignore +try: + from importlib import metadata as importlib_metadata +except ImportError: + importlib_metadata = None # type: ignore[assignment] + from typing import TYPE_CHECKING import sentry_sdk @@ -1841,6 +1846,14 @@ def package_version(package: str) -> "Optional[Tuple[int, ...]]": _replace_hyphens_dots_and_underscores_with_dashes(package) ) + if importlib_metadata is not None: + # Look up the single distribution instead of scanning (and parsing + # the metadata of) every installed package on each call. + try: + return parse_version(importlib_metadata.version(normalized_package)) + except importlib_metadata.PackageNotFoundError: + return None + installed_packages = { _replace_hyphens_dots_and_underscores_with_dashes(module): v for module, v in _get_installed_modules().items() diff --git a/tests/test_utils.py b/tests/test_utils.py index 77def0eb6e..0bea9a6705 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1412,6 +1412,24 @@ def test_package_version_is_none(): assert package_version("non_existent_package") is None +@pytest.mark.skipif(sys.version_info < (3, 8), reason="requires importlib.metadata") +def test_package_version_installed_package(): + from importlib import metadata + + assert package_version("pytest") == parse_version(metadata.version("pytest")) + + +@pytest.mark.skipif(sys.version_info < (3, 8), reason="requires importlib.metadata") +def test_package_version_does_not_scan_all_installed_packages(monkeypatch): + def _fail(): + raise AssertionError("package_version must not scan all installed packages") + + monkeypatch.setattr("sentry_sdk.utils._get_installed_modules", _fail) + + assert package_version("pytest") is not None + assert package_version("non_existent_package") is None + + @pytest.mark.parametrize( "kwargs, expected", [