|
1 | 1 | import asyncio |
| 2 | +import json |
2 | 3 | import pytest |
| 4 | +from unittest.mock import MagicMock |
3 | 5 |
|
4 | 6 | from typing import Annotated |
5 | 7 | from pydantic import Field |
6 | 8 |
|
| 9 | +import sentry_sdk |
7 | 10 | from sentry_sdk.integrations.pydantic_ai import PydanticAIIntegration |
| 11 | +from sentry_sdk.integrations.pydantic_ai.spans.ai_client import _set_input_messages |
8 | 12 |
|
9 | 13 | from pydantic_ai import Agent |
| 14 | +from pydantic_ai.messages import BinaryContent, UserPromptPart |
10 | 15 | from pydantic_ai.models.test import TestModel |
11 | 16 | from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior |
12 | 17 |
|
@@ -2604,3 +2609,128 @@ async def test_ai_client_span_gets_agent_from_scope(sentry_init, capture_events) |
2604 | 2609 |
|
2605 | 2610 | # Should not crash |
2606 | 2611 | assert transaction is not None |
| 2612 | + |
| 2613 | + |
| 2614 | +def _get_messages_from_span(span_data): |
| 2615 | + """Helper to extract and parse messages from span data.""" |
| 2616 | + messages_data = span_data["gen_ai.request.messages"] |
| 2617 | + return ( |
| 2618 | + json.loads(messages_data) if isinstance(messages_data, str) else messages_data |
| 2619 | + ) |
| 2620 | + |
| 2621 | + |
| 2622 | +def _find_binary_content(messages_data, expected_modality, expected_mime_type): |
| 2623 | + """Helper to find and verify binary content in messages.""" |
| 2624 | + for msg in messages_data: |
| 2625 | + if "content" not in msg: |
| 2626 | + continue |
| 2627 | + for content_item in msg["content"]: |
| 2628 | + if content_item.get("type") == "blob": |
| 2629 | + assert content_item["modality"] == expected_modality |
| 2630 | + assert content_item["mime_type"] == expected_mime_type |
| 2631 | + assert "content" in content_item |
| 2632 | + content_str = str(content_item["content"]) |
| 2633 | + assert ( |
| 2634 | + f"data:{expected_mime_type};base64," in content_str |
| 2635 | + or "[Filtered]" in content_str |
| 2636 | + ) |
| 2637 | + return True |
| 2638 | + return False |
| 2639 | + |
| 2640 | + |
| 2641 | +@pytest.mark.asyncio |
| 2642 | +async def test_binary_content_encoding_image(sentry_init, capture_events): |
| 2643 | + """Test that BinaryContent with image data is properly encoded in messages.""" |
| 2644 | + sentry_init( |
| 2645 | + integrations=[PydanticAIIntegration()], |
| 2646 | + traces_sample_rate=1.0, |
| 2647 | + send_default_pii=True, |
| 2648 | + ) |
| 2649 | + |
| 2650 | + events = capture_events() |
| 2651 | + |
| 2652 | + with sentry_sdk.start_transaction(op="test", name="test"): |
| 2653 | + span = sentry_sdk.start_span(op="test_span") |
| 2654 | + binary_content = BinaryContent( |
| 2655 | + data=b"fake_image_data_12345", media_type="image/png" |
| 2656 | + ) |
| 2657 | + user_part = UserPromptPart(content=["Look at this image:", binary_content]) |
| 2658 | + mock_msg = MagicMock() |
| 2659 | + mock_msg.parts = [user_part] |
| 2660 | + mock_msg.instructions = None |
| 2661 | + |
| 2662 | + _set_input_messages(span, [mock_msg]) |
| 2663 | + span.finish() |
| 2664 | + |
| 2665 | + (event,) = events |
| 2666 | + span_data = event["spans"][0]["data"] |
| 2667 | + messages_data = _get_messages_from_span(span_data) |
| 2668 | + assert _find_binary_content(messages_data, "image", "image/png") |
| 2669 | + |
| 2670 | + |
| 2671 | +@pytest.mark.asyncio |
| 2672 | +async def test_binary_content_encoding_mixed_content(sentry_init, capture_events): |
| 2673 | + """Test that BinaryContent mixed with text content is properly handled.""" |
| 2674 | + sentry_init( |
| 2675 | + integrations=[PydanticAIIntegration()], |
| 2676 | + traces_sample_rate=1.0, |
| 2677 | + send_default_pii=True, |
| 2678 | + ) |
| 2679 | + |
| 2680 | + events = capture_events() |
| 2681 | + |
| 2682 | + with sentry_sdk.start_transaction(op="test", name="test"): |
| 2683 | + span = sentry_sdk.start_span(op="test_span") |
| 2684 | + binary_content = BinaryContent( |
| 2685 | + data=b"fake_image_bytes", media_type="image/jpeg" |
| 2686 | + ) |
| 2687 | + user_part = UserPromptPart( |
| 2688 | + content=["Here is an image:", binary_content, "What do you see?"] |
| 2689 | + ) |
| 2690 | + mock_msg = MagicMock() |
| 2691 | + mock_msg.parts = [user_part] |
| 2692 | + mock_msg.instructions = None |
| 2693 | + |
| 2694 | + _set_input_messages(span, [mock_msg]) |
| 2695 | + span.finish() |
| 2696 | + |
| 2697 | + (event,) = events |
| 2698 | + span_data = event["spans"][0]["data"] |
| 2699 | + messages_data = _get_messages_from_span(span_data) |
| 2700 | + |
| 2701 | + # Verify both text and binary content are present |
| 2702 | + found_text = any( |
| 2703 | + content_item.get("type") == "text" |
| 2704 | + for msg in messages_data |
| 2705 | + if "content" in msg |
| 2706 | + for content_item in msg["content"] |
| 2707 | + ) |
| 2708 | + assert found_text, "Text content should be found" |
| 2709 | + assert _find_binary_content(messages_data, "image", "image/jpeg") |
| 2710 | + |
| 2711 | + |
| 2712 | +@pytest.mark.asyncio |
| 2713 | +async def test_binary_content_in_agent_run(sentry_init, capture_events): |
| 2714 | + """Test that BinaryContent in actual agent run is properly captured in spans.""" |
| 2715 | + agent = Agent("test", name="test_binary_agent") |
| 2716 | + |
| 2717 | + sentry_init( |
| 2718 | + integrations=[PydanticAIIntegration()], |
| 2719 | + traces_sample_rate=1.0, |
| 2720 | + send_default_pii=True, |
| 2721 | + ) |
| 2722 | + |
| 2723 | + events = capture_events() |
| 2724 | + binary_content = BinaryContent( |
| 2725 | + data=b"fake_image_data_for_testing", media_type="image/png" |
| 2726 | + ) |
| 2727 | + await agent.run(["Analyze this image:", binary_content]) |
| 2728 | + |
| 2729 | + (transaction,) = events |
| 2730 | + chat_spans = [s for s in transaction["spans"] if s["op"] == "gen_ai.chat"] |
| 2731 | + assert len(chat_spans) >= 1 |
| 2732 | + |
| 2733 | + chat_span = chat_spans[0] |
| 2734 | + if "gen_ai.request.messages" in chat_span["data"]: |
| 2735 | + messages_str = str(chat_span["data"]["gen_ai.request.messages"]) |
| 2736 | + assert any(keyword in messages_str for keyword in ["blob", "image", "base64"]) |
0 commit comments