Skip to content

Commit 20febec

Browse files
ericapisaniclaude
andcommitted
feat(anthropic): Set gen_ai.response.finish_reasons on span
Extract stop_reason from Anthropic responses and set it as the gen_ai.response.finish_reasons span attribute. For non-streaming responses this comes from result.stop_reason, and for streaming responses from event.delta.stop_reason in message_delta events. Refs PY-2136 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b1ddc5d commit 20febec

File tree

2 files changed

+131
-0
lines changed

2 files changed

+131
-0
lines changed

sentry_sdk/integrations/anthropic.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class _RecordedUsage:
5555
input_tokens: int = 0
5656
cache_write_input_tokens: "Optional[int]" = 0
5757
cache_read_input_tokens: "Optional[int]" = 0
58+
finish_reason: "Optional[str]" = None
5859

5960

6061
class AnthropicIntegration(Integration):
@@ -186,6 +187,10 @@ def _collect_ai_data(
186187
usage.cache_read_input_tokens = cache_read_input_tokens
187188
# TODO: Record event.usage.server_tool_use
188189

190+
stop_reason = getattr(event.delta, "stop_reason", None)
191+
if stop_reason is not None:
192+
usage.finish_reason = stop_reason
193+
189194
return (
190195
model,
191196
usage,
@@ -348,10 +353,13 @@ def _set_output_data(
348353
cache_write_input_tokens: "int | None",
349354
content_blocks: "list[Any]",
350355
finish_span: bool = False,
356+
finish_reason: "str | None" = None,
351357
) -> None:
352358
"""
353359
Set output data for the span based on the AI response."""
354360
span.set_data(SPANDATA.GEN_AI_RESPONSE_MODEL, model)
361+
if finish_reason is not None:
362+
span.set_data(SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS, finish_reason)
355363
if should_send_default_pii() and integration.include_prompts:
356364
output_messages: "dict[str, list[Any]]" = {
357365
"response": [],
@@ -443,6 +451,7 @@ def _sentry_patched_create_common(f: "Any", *args: "Any", **kwargs: "Any") -> "A
443451
cache_write_input_tokens=cache_write_input_tokens,
444452
content_blocks=content_blocks,
445453
finish_span=True,
454+
finish_reason=getattr(result, "stop_reason", None),
446455
)
447456

448457
# Streaming response
@@ -485,6 +494,7 @@ def new_iterator() -> "Iterator[MessageStreamEvent]":
485494
cache_write_input_tokens=usage.cache_write_input_tokens,
486495
content_blocks=[{"text": "".join(content_blocks), "type": "text"}],
487496
finish_span=True,
497+
finish_reason=usage.finish_reason,
488498
)
489499

490500
async def new_iterator_async() -> "AsyncIterator[MessageStreamEvent]":
@@ -523,6 +533,7 @@ async def new_iterator_async() -> "AsyncIterator[MessageStreamEvent]":
523533
cache_write_input_tokens=usage.cache_write_input_tokens,
524534
content_blocks=[{"text": "".join(content_blocks), "type": "text"}],
525535
finish_span=True,
536+
finish_reason=usage.finish_reason,
526537
)
527538

528539
if str(type(result._iterator)) == "<class 'async_generator'>":

tests/integrations/anthropic/test_anthropic.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ async def __call__(self, *args, **kwargs):
6464
content=[TextBlock(type="text", text="Hi, I'm Claude.")],
6565
type="message",
6666
usage=Usage(input_tokens=10, output_tokens=20),
67+
stop_reason="end_turn",
6768
)
6869

6970

@@ -134,6 +135,7 @@ def test_nonstreaming_create_message(
134135
assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20
135136
assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30
136137
assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is False
138+
assert span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == "end_turn"
137139

138140

139141
@pytest.mark.asyncio
@@ -204,6 +206,122 @@ async def test_nonstreaming_create_message_async(
204206
assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 20
205207
assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 30
206208
assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is False
209+
assert span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == "end_turn"
210+
211+
212+
def test_streaming_create_message_with_finish_reason(sentry_init, capture_events):
213+
client = Anthropic(api_key="z")
214+
returned_stream = Stream(cast_to=None, response=None, client=client)
215+
returned_stream._iterator = [
216+
MessageStartEvent(
217+
message=EXAMPLE_MESSAGE,
218+
type="message_start",
219+
),
220+
ContentBlockStartEvent(
221+
type="content_block_start",
222+
index=0,
223+
content_block=TextBlock(type="text", text=""),
224+
),
225+
ContentBlockDeltaEvent(
226+
delta=TextDelta(text="Hi!", type="text_delta"),
227+
index=0,
228+
type="content_block_delta",
229+
),
230+
ContentBlockStopEvent(type="content_block_stop", index=0),
231+
MessageDeltaEvent(
232+
delta=Delta(stop_reason="end_turn"),
233+
usage=MessageDeltaUsage(output_tokens=10),
234+
type="message_delta",
235+
),
236+
]
237+
238+
sentry_init(
239+
integrations=[AnthropicIntegration(include_prompts=True)],
240+
traces_sample_rate=1.0,
241+
send_default_pii=True,
242+
)
243+
events = capture_events()
244+
client.messages._post = mock.Mock(return_value=returned_stream)
245+
246+
messages = [
247+
{
248+
"role": "user",
249+
"content": "Hello, Claude",
250+
}
251+
]
252+
253+
with start_transaction(name="anthropic"):
254+
message = client.messages.create(
255+
max_tokens=1024, messages=messages, model="model", stream=True
256+
)
257+
for _ in message:
258+
pass
259+
260+
assert len(events) == 1
261+
(event,) = events
262+
(span,) = event["spans"]
263+
264+
assert span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == "end_turn"
265+
266+
267+
@pytest.mark.asyncio
268+
async def test_streaming_create_message_with_finish_reason_async(
269+
sentry_init, capture_events, async_iterator
270+
):
271+
client = AsyncAnthropic(api_key="z")
272+
returned_stream = AsyncStream(cast_to=None, response=None, client=client)
273+
returned_stream._iterator = async_iterator(
274+
[
275+
MessageStartEvent(
276+
message=EXAMPLE_MESSAGE,
277+
type="message_start",
278+
),
279+
ContentBlockStartEvent(
280+
type="content_block_start",
281+
index=0,
282+
content_block=TextBlock(type="text", text=""),
283+
),
284+
ContentBlockDeltaEvent(
285+
delta=TextDelta(text="Hi!", type="text_delta"),
286+
index=0,
287+
type="content_block_delta",
288+
),
289+
ContentBlockStopEvent(type="content_block_stop", index=0),
290+
MessageDeltaEvent(
291+
delta=Delta(stop_reason="end_turn"),
292+
usage=MessageDeltaUsage(output_tokens=10),
293+
type="message_delta",
294+
),
295+
]
296+
)
297+
298+
sentry_init(
299+
integrations=[AnthropicIntegration(include_prompts=True)],
300+
traces_sample_rate=1.0,
301+
send_default_pii=True,
302+
)
303+
events = capture_events()
304+
client.messages._post = AsyncMock(return_value=returned_stream)
305+
306+
messages = [
307+
{
308+
"role": "user",
309+
"content": "Hello, Claude",
310+
}
311+
]
312+
313+
with start_transaction(name="anthropic"):
314+
message = await client.messages.create(
315+
max_tokens=1024, messages=messages, model="model", stream=True
316+
)
317+
async for _ in message:
318+
pass
319+
320+
assert len(events) == 1
321+
(event,) = events
322+
(span,) = event["spans"]
323+
324+
assert span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == "end_turn"
207325

208326

209327
@pytest.mark.parametrize(
@@ -545,6 +663,7 @@ def test_streaming_create_message_with_input_json_delta(
545663
assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41
546664
assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 407
547665
assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True
666+
assert span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == "tool_use"
548667

549668

550669
@pytest.mark.asyncio
@@ -687,6 +806,7 @@ async def test_streaming_create_message_with_input_json_delta_async(
687806
assert span["data"][SPANDATA.GEN_AI_USAGE_OUTPUT_TOKENS] == 41
688807
assert span["data"][SPANDATA.GEN_AI_USAGE_TOTAL_TOKENS] == 407
689808
assert span["data"][SPANDATA.GEN_AI_RESPONSE_STREAMING] is True
809+
assert span["data"][SPANDATA.GEN_AI_RESPONSE_FINISH_REASONS] == "tool_use"
690810

691811

692812
def test_exception_message_create(sentry_init, capture_events):

0 commit comments

Comments
 (0)