Skip to content

Commit a6170fc

Browse files
feat(pydantic-ai): Set system instruction attribute (#5356)
Set the system instruction attribute on `ai_chat` spans in the `PydanticAIIntegration`. Extract the instructions from `ModelRequest` messages in the message history, including the `instructions` field, if present, and parts of type `SystemPromptPart`.
1 parent 2f7838e commit a6170fc

File tree

2 files changed

+108
-32
lines changed

2 files changed

+108
-32
lines changed

sentry_sdk/integrations/pydantic_ai/spans/ai_client.py

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@
2626
if TYPE_CHECKING:
2727
from typing import Any, List, Dict
2828
from pydantic_ai.usage import RequestUsage # type: ignore
29+
from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore
30+
from sentry_sdk._types import TextPart as SentryTextPart
2931

3032
try:
31-
from pydantic_ai.messages import ( # type: ignore
33+
from pydantic_ai.messages import (
3234
BaseToolCallPart,
3335
BaseToolReturnPart,
3436
SystemPromptPart,
@@ -48,6 +50,47 @@
4850
BinaryContent = None
4951

5052

53+
def _transform_system_instructions(
54+
permanent_instructions: "list[SystemPromptPart]",
55+
current_instructions: "list[str]",
56+
) -> "list[SentryTextPart]":
57+
text_parts: "list[SentryTextPart]" = [
58+
{
59+
"type": "text",
60+
"content": instruction.content,
61+
}
62+
for instruction in permanent_instructions
63+
]
64+
65+
text_parts.extend(
66+
{
67+
"type": "text",
68+
"content": instruction,
69+
}
70+
for instruction in current_instructions
71+
)
72+
73+
return text_parts
74+
75+
76+
def _get_system_instructions(
77+
messages: "list[ModelMessage]",
78+
) -> "tuple[list[SystemPromptPart], list[str]]":
79+
permanent_instructions = []
80+
current_instructions = []
81+
82+
for msg in messages:
83+
if hasattr(msg, "parts"):
84+
for part in msg.parts:
85+
if SystemPromptPart and isinstance(part, SystemPromptPart):
86+
permanent_instructions.append(part)
87+
88+
if hasattr(msg, "instructions") and msg.instructions is not None:
89+
current_instructions.append(msg.instructions)
90+
91+
return permanent_instructions, current_instructions
92+
93+
5194
def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None:
5295
"""Set input messages data on a span."""
5396
if not _should_send_prompts():
@@ -56,29 +99,27 @@ def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> Non
5699
if not messages:
57100
return
58101

102+
permanent_instructions, current_instructions = _get_system_instructions(messages)
103+
if len(permanent_instructions) > 0 or len(current_instructions) > 0:
104+
set_data_normalized(
105+
span,
106+
SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS,
107+
_transform_system_instructions(
108+
permanent_instructions, current_instructions
109+
),
110+
unpack=False,
111+
)
112+
59113
try:
60114
formatted_messages = []
61-
system_prompt = None
62-
63-
# Extract system prompt from any ModelRequest with instructions
64-
for msg in messages:
65-
if hasattr(msg, "instructions") and msg.instructions:
66-
system_prompt = msg.instructions
67-
break
68-
69-
# Add system prompt as first message if present
70-
if system_prompt:
71-
formatted_messages.append(
72-
{"role": "system", "content": [{"type": "text", "text": system_prompt}]}
73-
)
74115

75116
for msg in messages:
76117
if hasattr(msg, "parts"):
77118
for part in msg.parts:
78119
role = "user"
79120
# Use isinstance checks with proper base classes
80121
if SystemPromptPart and isinstance(part, SystemPromptPart):
81-
role = "system"
122+
continue
82123
elif (
83124
(TextPart and isinstance(part, TextPart))
84125
or (ThinkingPart and isinstance(part, ThinkingPart))

tests/integrations/pydantic_ai/test_pydantic_ai.py

Lines changed: 52 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,18 @@ async def test_model_settings(sentry_init, capture_events, test_agent_with_setti
514514

515515

516516
@pytest.mark.asyncio
517-
async def test_system_prompt_in_messages(sentry_init, capture_events):
517+
@pytest.mark.parametrize(
518+
"send_default_pii, include_prompts",
519+
[
520+
(True, True),
521+
(True, False),
522+
(False, True),
523+
(False, False),
524+
],
525+
)
526+
async def test_system_prompt_attribute(
527+
sentry_init, capture_events, send_default_pii, include_prompts
528+
):
518529
"""
519530
Test that system prompts are included as the first message.
520531
"""
@@ -525,9 +536,9 @@ async def test_system_prompt_in_messages(sentry_init, capture_events):
525536
)
526537

527538
sentry_init(
528-
integrations=[PydanticAIIntegration()],
539+
integrations=[PydanticAIIntegration(include_prompts=include_prompts)],
529540
traces_sample_rate=1.0,
530-
send_default_pii=True,
541+
send_default_pii=send_default_pii,
531542
)
532543

533544
events = capture_events()
@@ -542,12 +553,17 @@ async def test_system_prompt_in_messages(sentry_init, capture_events):
542553
assert len(chat_spans) >= 1
543554

544555
chat_span = chat_spans[0]
545-
messages_str = chat_span["data"]["gen_ai.request.messages"]
546556

547-
# Messages is serialized as a string
548-
# Should contain system role and helpful assistant text
549-
assert "system" in messages_str
550-
assert "helpful assistant" in messages_str
557+
if send_default_pii and include_prompts:
558+
system_instructions = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS]
559+
assert json.loads(system_instructions) == [
560+
{
561+
"type": "text",
562+
"content": "You are a helpful assistant specialized in testing.",
563+
}
564+
]
565+
else:
566+
assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"]
551567

552568

553569
@pytest.mark.asyncio
@@ -1184,7 +1200,18 @@ async def test_invoke_agent_with_list_user_prompt(sentry_init, capture_events):
11841200

11851201

11861202
@pytest.mark.asyncio
1187-
async def test_invoke_agent_with_instructions(sentry_init, capture_events):
1203+
@pytest.mark.parametrize(
1204+
"send_default_pii, include_prompts",
1205+
[
1206+
(True, True),
1207+
(True, False),
1208+
(False, True),
1209+
(False, False),
1210+
],
1211+
)
1212+
async def test_invoke_agent_with_instructions(
1213+
sentry_init, capture_events, send_default_pii, include_prompts
1214+
):
11881215
"""
11891216
Test that invoke_agent span handles instructions correctly.
11901217
"""
@@ -1201,24 +1228,32 @@ async def test_invoke_agent_with_instructions(sentry_init, capture_events):
12011228
agent._system_prompts = ["System prompt"]
12021229

12031230
sentry_init(
1204-
integrations=[PydanticAIIntegration()],
1231+
integrations=[PydanticAIIntegration(include_prompts=include_prompts)],
12051232
traces_sample_rate=1.0,
1206-
send_default_pii=True,
1233+
send_default_pii=send_default_pii,
12071234
)
12081235

12091236
events = capture_events()
12101237

12111238
await agent.run("Test input")
12121239

12131240
(transaction,) = events
1241+
spans = transaction["spans"]
12141242

1215-
# Check that the invoke_agent transaction has messages data
1216-
if "gen_ai.request.messages" in transaction["contexts"]["trace"]["data"]:
1217-
messages_str = transaction["contexts"]["trace"]["data"][
1218-
"gen_ai.request.messages"
1243+
# The transaction IS the invoke_agent span, check for messages in chat spans instead
1244+
chat_spans = [s for s in spans if s["op"] == "gen_ai.chat"]
1245+
assert len(chat_spans) >= 1
1246+
1247+
chat_span = chat_spans[0]
1248+
1249+
if send_default_pii and include_prompts:
1250+
system_instructions = chat_span["data"][SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS]
1251+
assert json.loads(system_instructions) == [
1252+
{"type": "text", "content": "System prompt"},
1253+
{"type": "text", "content": "Instruction 1\nInstruction 2"},
12191254
]
1220-
# Should contain both instructions and system prompts
1221-
assert "Instruction" in messages_str or "System prompt" in messages_str
1255+
else:
1256+
assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"]
12221257

12231258

12241259
@pytest.mark.asyncio

0 commit comments

Comments
 (0)