From e20944de270f1c5aa8d9f342a7dedb3d8bf38af4 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 21 Jan 2026 13:56:09 +0100 Subject: [PATCH 1/9] feat(pydantic-ai): Set system instruction attribute --- sentry_sdk/consts.py | 6 ++++ .../pydantic_ai/spans/ai_client.py | 31 ++++++++++--------- .../pydantic_ai/test_pydantic_ai.py | 25 +++++++-------- 3 files changed, 34 insertions(+), 28 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 93fca6ba3e..4b61a317fb 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -542,6 +542,12 @@ class SPANDATA: Example: 2048 """ + GEN_AI_SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions" + """ + The system instructions passed to the model. + Example: [{"type": "text", "text": "You are a helpful assistant."},{"type": "text", "text": "Be concise and clear."}] + """ + GEN_AI_REQUEST_MESSAGES = "gen_ai.request.messages" """ The messages passed to the model. The "content" can be a string or an array of objects. diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 00c8c934e8..867de8e746 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -48,6 +48,21 @@ BinaryContent = None +def _set_system_instruction(span: "sentry_sdk.tracing.Span", messages: "Any"): + print("messages", messages) + for msg in messages: + for part in msg.parts: + if isinstance(part, SystemPromptPart): + system_prompt = part.content + set_data_normalized( + span, + SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, + system_prompt, + unpack=False, + ) + return + + def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: """Set input messages data on a span.""" if not _should_send_prompts(): @@ -58,19 +73,6 @@ def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> Non try: formatted_messages = [] - system_prompt = None - - # Extract system prompt from any ModelRequest with instructions - for msg in messages: - if hasattr(msg, "instructions") and msg.instructions: - system_prompt = msg.instructions - break - - # Add system prompt as first message if present - if system_prompt: - formatted_messages.append( - {"role": "system", "content": [{"type": "text", "text": system_prompt}]} - ) for msg in messages: if hasattr(msg, "parts"): @@ -78,7 +80,7 @@ def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> Non role = "user" # Use isinstance checks with proper base classes if SystemPromptPart and isinstance(part, SystemPromptPart): - role = "system" + continue elif ( (TextPart and isinstance(part, TextPart)) or (ThinkingPart and isinstance(part, ThinkingPart)) @@ -235,6 +237,7 @@ def ai_client_span( # Set input messages (full conversation history) if messages: + _set_system_instruction(span, messages) _set_input_messages(span, messages) return span diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 575eae35cc..4dea8e9897 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -514,7 +514,7 @@ async def test_model_settings(sentry_init, capture_events, test_agent_with_setti @pytest.mark.asyncio -async def test_system_prompt_in_messages(sentry_init, capture_events): +async def test_system_prompt_attribute(sentry_init, capture_events): """ Test that system prompts are included as the first message. """ @@ -542,12 +542,8 @@ async def test_system_prompt_in_messages(sentry_init, capture_events): assert len(chat_spans) >= 1 chat_span = chat_spans[0] - messages_str = chat_span["data"]["gen_ai.request.messages"] - - # Messages is serialized as a string - # Should contain system role and helpful assistant text - assert "system" in messages_str - assert "helpful assistant" in messages_str + system_instruction = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] + assert "helpful assistant" in system_instruction @pytest.mark.asyncio @@ -1211,14 +1207,15 @@ async def test_invoke_agent_with_instructions(sentry_init, capture_events): await agent.run("Test input") (transaction,) = events + spans = transaction["spans"] - # Check that the invoke_agent transaction has messages data - if "gen_ai.request.messages" in transaction["contexts"]["trace"]["data"]: - messages_str = transaction["contexts"]["trace"]["data"][ - "gen_ai.request.messages" - ] - # Should contain both instructions and system prompts - assert "Instruction" in messages_str or "System prompt" in messages_str + # The transaction IS the invoke_agent span, check for messages in chat spans instead + chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"] + assert len(chat_spans) >= 1 + + chat_span = chat_spans[0] + system_instruction = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] + assert "System prompt" in system_instruction @pytest.mark.asyncio From ac472bc79794f2559edc80b825ab7b55d076f53a Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 21 Jan 2026 14:11:50 +0100 Subject: [PATCH 2/9] add return type --- sentry_sdk/integrations/pydantic_ai/spans/ai_client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 867de8e746..3625cb47a1 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -48,8 +48,7 @@ BinaryContent = None -def _set_system_instruction(span: "sentry_sdk.tracing.Span", messages: "Any"): - print("messages", messages) +def _set_system_instruction(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: for msg in messages: for part in msg.parts: if isinstance(part, SystemPromptPart): From 4a7af4b8d11cdb065414127b8959dadd79e7772d Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Wed, 21 Jan 2026 14:12:38 +0100 Subject: [PATCH 3/9] stricter if check --- sentry_sdk/integrations/pydantic_ai/spans/ai_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 3625cb47a1..7a844c1aa2 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -51,7 +51,7 @@ def _set_system_instruction(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: for msg in messages: for part in msg.parts: - if isinstance(part, SystemPromptPart): + if SystemPromptPart and isinstance(part, SystemPromptPart): system_prompt = part.content set_data_normalized( span, From 77d1776543cb7b9bd8fab6c33ad2abb1663023e6 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 22 Jan 2026 08:48:05 +0100 Subject: [PATCH 4/9] . --- sentry_sdk/_types.py | 4 ++ .../pydantic_ai/spans/ai_client.py | 43 ++++++++++----- .../pydantic_ai/test_pydantic_ai.py | 53 +++++++++++++++---- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/sentry_sdk/_types.py b/sentry_sdk/_types.py index 7043bbc2ee..ecb8abcd10 100644 --- a/sentry_sdk/_types.py +++ b/sentry_sdk/_types.py @@ -359,3 +359,7 @@ class SDKInfo(TypedDict): ) HttpStatusCodeRange = Union[int, Container[int]] + + class TextPart(TypedDict): + type: Literal["text"] + content: str diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 7a844c1aa2..49e549b066 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -26,6 +26,8 @@ if TYPE_CHECKING: from typing import Any, List, Dict from pydantic_ai.usage import RequestUsage # type: ignore + from pydantic_ai.messages import ModelMessage # type: ignore + from sentry_sdk._types import TextPart as SentryTextPart try: from pydantic_ai.messages import ( # type: ignore @@ -48,18 +50,28 @@ BinaryContent = None -def _set_system_instruction(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: +def _transform_system_instructions( + system_instructions: "list[SystemPromptPart]", +) -> "list[SentryTextPart]": + return [ + { + "type": "text", + "content": instruction.content, + } + for instruction in system_instructions + ] + + +def _get_system_instructions(messages: "list[ModelMessage]") -> "list[ModelMessage]": + system_instructions = [] + for msg in messages: - for part in msg.parts: - if SystemPromptPart and isinstance(part, SystemPromptPart): - system_prompt = part.content - set_data_normalized( - span, - SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, - system_prompt, - unpack=False, - ) - return + if hasattr(msg, "parts"): + for part in msg.parts: + if SystemPromptPart and isinstance(part, SystemPromptPart): + system_instructions.append(part) + + return system_instructions def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: @@ -70,6 +82,14 @@ def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> Non if not messages: return + system_instructions = _get_system_instructions(messages) + set_data_normalized( + span, + SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, + _transform_system_instructions(system_instructions), + unpack=False, + ) + try: formatted_messages = [] @@ -236,7 +256,6 @@ def ai_client_span( # Set input messages (full conversation history) if messages: - _set_system_instruction(span, messages) _set_input_messages(span, messages) return span diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 4dea8e9897..9333010b1c 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -514,7 +514,18 @@ async def test_model_settings(sentry_init, capture_events, test_agent_with_setti @pytest.mark.asyncio -async def test_system_prompt_attribute(sentry_init, capture_events): +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], +) +async def test_system_prompt_attribute( + sentry_init, capture_events, send_default_pii, include_prompts +): """ Test that system prompts are included as the first message. """ @@ -525,9 +536,9 @@ async def test_system_prompt_attribute(sentry_init, capture_events): ) sentry_init( - integrations=[PydanticAIIntegration()], + integrations=[PydanticAIIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, - send_default_pii=True, + send_default_pii=send_default_pii, ) events = capture_events() @@ -542,8 +553,15 @@ async def test_system_prompt_attribute(sentry_init, capture_events): assert len(chat_spans) >= 1 chat_span = chat_spans[0] - system_instruction = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] - assert "helpful assistant" in system_instruction + + if send_default_pii and include_prompts: + system_instruction = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] + assert ( + system_instruction + == '[{"type": "text", "content": "You are a helpful assistant specialized in testing."}]' + ) + else: + assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"] @pytest.mark.asyncio @@ -1180,7 +1198,18 @@ async def test_invoke_agent_with_list_user_prompt(sentry_init, capture_events): @pytest.mark.asyncio -async def test_invoke_agent_with_instructions(sentry_init, capture_events): +@pytest.mark.parametrize( + "send_default_pii, include_prompts", + [ + (True, True), + (True, False), + (False, True), + (False, False), + ], +) +async def test_invoke_agent_with_instructions( + sentry_init, capture_events, send_default_pii, include_prompts +): """ Test that invoke_agent span handles instructions correctly. """ @@ -1197,9 +1226,9 @@ async def test_invoke_agent_with_instructions(sentry_init, capture_events): agent._system_prompts = ["System prompt"] sentry_init( - integrations=[PydanticAIIntegration()], + integrations=[PydanticAIIntegration(include_prompts=include_prompts)], traces_sample_rate=1.0, - send_default_pii=True, + send_default_pii=send_default_pii, ) events = capture_events() @@ -1214,8 +1243,12 @@ async def test_invoke_agent_with_instructions(sentry_init, capture_events): assert len(chat_spans) >= 1 chat_span = chat_spans[0] - system_instruction = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] - assert "System prompt" in system_instruction + + if send_default_pii and include_prompts: + system_instruction = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] + assert system_instruction == '[{"type": "text", "content": "System prompt"}]' + else: + assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"] @pytest.mark.asyncio From 6f4022b3551ddd8537582c1880d250b45549d012 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 22 Jan 2026 08:49:33 +0100 Subject: [PATCH 5/9] check for empty list --- .../integrations/pydantic_ai/spans/ai_client.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 49e549b066..302a88773b 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -83,12 +83,13 @@ def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> Non return system_instructions = _get_system_instructions(messages) - set_data_normalized( - span, - SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, - _transform_system_instructions(system_instructions), - unpack=False, - ) + if len(system_instructions) > 0: + set_data_normalized( + span, + SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, + _transform_system_instructions(system_instructions), + unpack=False, + ) try: formatted_messages = [] From 496caee7f08e2fed2aa93267a81db1e2b9892d79 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 22 Jan 2026 08:52:37 +0100 Subject: [PATCH 6/9] remove type ignore --- sentry_sdk/integrations/pydantic_ai/spans/ai_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 302a88773b..29bfcb0705 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -30,7 +30,7 @@ from sentry_sdk._types import TextPart as SentryTextPart try: - from pydantic_ai.messages import ( # type: ignore + from pydantic_ai.messages import ( BaseToolCallPart, BaseToolReturnPart, SystemPromptPart, From 532264f0da5ae993a40b5f891a528b21b99e5dd1 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 22 Jan 2026 08:54:59 +0100 Subject: [PATCH 7/9] . --- .../pydantic_ai/test_pydantic_ai.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 9333010b1c..46c396964e 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -555,11 +555,13 @@ async def test_system_prompt_attribute( chat_span = chat_spans[0] if send_default_pii and include_prompts: - system_instruction = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] - assert ( - system_instruction - == '[{"type": "text", "content": "You are a helpful assistant specialized in testing."}]' - ) + system_instructions = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] + assert json.loads(system_instructions) == [ + { + "type": "text", + "content": "You are a helpful assistant specialized in testing.", + } + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"] @@ -1245,8 +1247,10 @@ async def test_invoke_agent_with_instructions( chat_span = chat_spans[0] if send_default_pii and include_prompts: - system_instruction = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] - assert system_instruction == '[{"type": "text", "content": "System prompt"}]' + system_instructions = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] + assert json.loads(system_instructions) == [ + {"type": "text", "content": "System prompt"} + ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"] From 5aa6136b05a1dd7560151fcc93d6d7e38b5fb764 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 22 Jan 2026 09:18:52 +0100 Subject: [PATCH 8/9] fix type --- sentry_sdk/integrations/pydantic_ai/spans/ai_client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 29bfcb0705..2a0336c82e 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -26,7 +26,7 @@ if TYPE_CHECKING: from typing import Any, List, Dict from pydantic_ai.usage import RequestUsage # type: ignore - from pydantic_ai.messages import ModelMessage # type: ignore + from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore from sentry_sdk._types import TextPart as SentryTextPart try: @@ -62,7 +62,9 @@ def _transform_system_instructions( ] -def _get_system_instructions(messages: "list[ModelMessage]") -> "list[ModelMessage]": +def _get_system_instructions( + messages: "list[ModelMessage]", +) -> "list[SystemPromptPart]": system_instructions = [] for msg in messages: From ca948516bb77987431596979f17cd4fd5abeedb2 Mon Sep 17 00:00:00 2001 From: Alexander Alderman Webb Date: Thu, 22 Jan 2026 09:35:48 +0100 Subject: [PATCH 9/9] . --- .../pydantic_ai/spans/ai_client.py | 31 +++++++++++++------ .../pydantic_ai/test_pydantic_ai.py | 3 +- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py index 2a0336c82e..bf61754ca5 100644 --- a/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py +++ b/sentry_sdk/integrations/pydantic_ai/spans/ai_client.py @@ -51,29 +51,40 @@ def _transform_system_instructions( - system_instructions: "list[SystemPromptPart]", + permanent_instructions: "list[SystemPromptPart]", + current_instructions: "list[str]", ) -> "list[SentryTextPart]": return [ { "type": "text", "content": instruction.content, } - for instruction in system_instructions + for instruction in permanent_instructions + ] + [ + { + "type": "text", + "content": instruction, + } + for instruction in current_instructions ] def _get_system_instructions( messages: "list[ModelMessage]", -) -> "list[SystemPromptPart]": - system_instructions = [] +) -> "tuple[list[SystemPromptPart], list[str]]": + permanent_instructions = [] + current_instructions = [] for msg in messages: if hasattr(msg, "parts"): for part in msg.parts: if SystemPromptPart and isinstance(part, SystemPromptPart): - system_instructions.append(part) + permanent_instructions.append(part) + + if hasattr(msg, "instructions") and msg.instructions is not None: + current_instructions.append(msg.instructions) - return system_instructions + return permanent_instructions, current_instructions def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None: @@ -84,12 +95,14 @@ def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> Non if not messages: return - system_instructions = _get_system_instructions(messages) - if len(system_instructions) > 0: + permanent_instructions, current_instructions = _get_system_instructions(messages) + if len(permanent_instructions) > 0 or len(current_instructions) > 0: set_data_normalized( span, SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS, - _transform_system_instructions(system_instructions), + _transform_system_instructions( + permanent_instructions, current_instructions + ), unpack=False, ) diff --git a/tests/integrations/pydantic_ai/test_pydantic_ai.py b/tests/integrations/pydantic_ai/test_pydantic_ai.py index 46c396964e..f315909ea1 100644 --- a/tests/integrations/pydantic_ai/test_pydantic_ai.py +++ b/tests/integrations/pydantic_ai/test_pydantic_ai.py @@ -1249,7 +1249,8 @@ async def test_invoke_agent_with_instructions( if send_default_pii and include_prompts: system_instructions = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS] assert json.loads(system_instructions) == [ - {"type": "text", "content": "System prompt"} + {"type": "text", "content": "System prompt"}, + {"type": "text", "content": "Instruction 1\nInstruction 2"}, ] else: assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"]