Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions sentry_sdk/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,7 @@ class SDKInfo(TypedDict):
)

HttpStatusCodeRange = Union[int, Container[int]]

class TextPart(TypedDict):
type: Literal["text"]
content: str
6 changes: 6 additions & 0 deletions sentry_sdk/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
71 changes: 56 additions & 15 deletions sentry_sdk/integrations/pydantic_ai/spans/ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@
if TYPE_CHECKING:
from typing import Any, List, Dict
from pydantic_ai.usage import RequestUsage # type: ignore
from pydantic_ai.messages import ModelMessage, SystemPromptPart # type: ignore
from sentry_sdk._types import TextPart as SentryTextPart

try:
from pydantic_ai.messages import ( # type: ignore
from pydantic_ai.messages import (
BaseToolCallPart,
BaseToolReturnPart,
SystemPromptPart,
Expand All @@ -48,6 +50,47 @@
BinaryContent = None


def _transform_system_instructions(
permanent_instructions: "list[SystemPromptPart]",
current_instructions: "list[str]",
) -> "list[SentryTextPart]":
text_parts: "list[SentryTextPart]" = [
{
"type": "text",
"content": instruction.content,
}
for instruction in permanent_instructions
]

text_parts.extend(
{
"type": "text",
"content": instruction,
}
for instruction in current_instructions
)

return text_parts


def _get_system_instructions(
messages: "list[ModelMessage]",
) -> "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):
permanent_instructions.append(part)

if hasattr(msg, "instructions") and msg.instructions is not None:
current_instructions.append(msg.instructions)

return permanent_instructions, current_instructions


def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> None:
"""Set input messages data on a span."""
if not _should_send_prompts():
Expand All @@ -56,29 +99,27 @@ def _set_input_messages(span: "sentry_sdk.tracing.Span", messages: "Any") -> Non
if not messages:
return

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(
permanent_instructions, current_instructions
),
unpack=False,
)

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"):
for part in msg.parts:
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))
Expand Down
69 changes: 52 additions & 17 deletions tests/integrations/pydantic_ai/test_pydantic_ai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_in_messages(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.
"""
Expand All @@ -525,9 +536,9 @@ async def test_system_prompt_in_messages(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()
Expand All @@ -542,12 +553,17 @@ 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
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": "You are a helpful assistant specialized in testing.",
}
]
else:
assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"]


@pytest.mark.asyncio
Expand Down Expand Up @@ -1184,7 +1200,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.
"""
Expand All @@ -1201,24 +1228,32 @@ 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()

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"
# 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]

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": "Instruction 1\nInstruction 2"},
]
# Should contain both instructions and system prompts
assert "Instruction" in messages_str or "System prompt" in messages_str
else:
assert SPANDATA.GEN_AI_SYSTEM_INSTRUCTIONS not in chat_span["data"]


@pytest.mark.asyncio
Expand Down
Loading