Skip to content

Commit 401a018

Browse files
Merge branch 'master' into constantinius/fix/integrations/pydantic-ai-report-image-inputs
2 parents 8b977da + 141eaaa commit 401a018

File tree

9 files changed

+1358
-22
lines changed

9 files changed

+1358
-22
lines changed

.github/workflows/changelog-preview.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
- reopened
88
- edited
99
- labeled
10+
- unlabeled
1011
permissions:
1112
contents: write
1213
pull-requests: write

sentry_sdk/_types.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,9 +217,18 @@ class SDKInfo(TypedDict):
217217
Hint = Dict[str, Any]
218218

219219
AttributeValue = (
220-
str | bool | float | int
221-
# TODO: relay support coming soon for
222-
# | list[str] | list[bool] | list[float] | list[int]
220+
str
221+
| bool
222+
| float
223+
| int
224+
| list[str]
225+
| list[bool]
226+
| list[float]
227+
| list[int]
228+
| tuple[str, ...]
229+
| tuple[bool, ...]
230+
| tuple[float, ...]
231+
| tuple[int, ...]
223232
)
224233
Attributes = dict[str, AttributeValue]
225234

@@ -232,11 +241,10 @@ class SDKInfo(TypedDict):
232241
"boolean",
233242
"double",
234243
"integer",
235-
# TODO: relay support coming soon for:
236-
# "string[]",
237-
# "boolean[]",
238-
# "double[]",
239-
# "integer[]",
244+
"string[]",
245+
"boolean[]",
246+
"double[]",
247+
"integer[]",
240248
],
241249
"value": AttributeValue,
242250
},

sentry_sdk/integrations/anthropic.py

Lines changed: 55 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
normalize_message_roles,
1212
truncate_and_annotate_messages,
1313
get_start_span_function,
14+
transform_anthropic_content_part,
1415
)
1516
from sentry_sdk.consts import OP, SPANDATA, SPANSTATUS
1617
from sentry_sdk.integrations import _check_minimum_version, DidNotEnable, Integration
@@ -122,6 +123,27 @@ def _collect_ai_data(
122123
return model, input_tokens, output_tokens, content_blocks
123124

124125

126+
def _transform_anthropic_content_block(
127+
content_block: "dict[str, Any]",
128+
) -> "dict[str, Any]":
129+
"""
130+
Transform an Anthropic content block using the Anthropic-specific transformer,
131+
with special handling for Anthropic's text-type documents.
132+
"""
133+
# Handle Anthropic's text-type documents specially (not covered by shared function)
134+
if content_block.get("type") == "document":
135+
source = content_block.get("source")
136+
if isinstance(source, dict) and source.get("type") == "text":
137+
return {
138+
"type": "text",
139+
"text": source.get("data", ""),
140+
}
141+
142+
# Use Anthropic-specific transformation
143+
result = transform_anthropic_content_part(content_block)
144+
return result if result is not None else content_block
145+
146+
125147
def _set_input_data(
126148
span: "Span", kwargs: "dict[str, Any]", integration: "AnthropicIntegration"
127149
) -> None:
@@ -166,19 +188,41 @@ def _set_input_data(
166188
and "content" in message
167189
and isinstance(message["content"], (list, tuple))
168190
):
191+
transformed_content = []
169192
for item in message["content"]:
170-
if item.get("type") == "tool_result":
171-
normalized_messages.append(
172-
{
173-
"role": GEN_AI_ALLOWED_MESSAGE_ROLES.TOOL,
174-
"content": { # type: ignore[dict-item]
175-
"tool_use_id": item.get("tool_use_id"),
176-
"output": item.get("content"),
177-
},
178-
}
179-
)
193+
# Skip tool_result items - they can contain images/documents
194+
# with nested structures that are difficult to redact properly
195+
if isinstance(item, dict) and item.get("type") == "tool_result":
196+
continue
197+
198+
# Transform content blocks (images, documents, etc.)
199+
transformed_content.append(
200+
_transform_anthropic_content_block(item)
201+
if isinstance(item, dict)
202+
else item
203+
)
204+
205+
# If there are non-tool-result items, add them as a message
206+
if transformed_content:
207+
normalized_messages.append(
208+
{
209+
"role": message.get("role"),
210+
"content": transformed_content,
211+
}
212+
)
180213
else:
181-
normalized_messages.append(message)
214+
# Transform content for non-list messages or assistant messages
215+
transformed_message = message.copy()
216+
if "content" in transformed_message:
217+
content = transformed_message["content"]
218+
if isinstance(content, (list, tuple)):
219+
transformed_message["content"] = [
220+
_transform_anthropic_content_block(item)
221+
if isinstance(item, dict)
222+
else item
223+
for item in content
224+
]
225+
normalized_messages.append(transformed_message)
182226

183227
role_normalized_messages = normalize_message_roles(normalized_messages)
184228
scope = sentry_sdk.get_current_scope()

sentry_sdk/integrations/langchain.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
normalize_message_roles,
1515
set_data_normalized,
1616
truncate_and_annotate_messages,
17+
transform_content_part,
1718
)
1819
from sentry_sdk.consts import OP, SPANDATA
1920
from sentry_sdk.integrations import DidNotEnable, Integration
@@ -117,6 +118,39 @@
117118
}
118119

119120

121+
def _transform_langchain_content_block(
122+
content_block: "Dict[str, Any]",
123+
) -> "Dict[str, Any]":
124+
"""
125+
Transform a LangChain content block using the shared transform_content_part function.
126+
127+
Returns the original content block if transformation is not applicable
128+
(e.g., for text blocks or unrecognized formats).
129+
"""
130+
result = transform_content_part(content_block)
131+
return result if result is not None else content_block
132+
133+
134+
def _transform_langchain_message_content(content: "Any") -> "Any":
135+
"""
136+
Transform LangChain message content, handling both string content and
137+
list of content blocks.
138+
"""
139+
if isinstance(content, str):
140+
return content
141+
142+
if isinstance(content, (list, tuple)):
143+
transformed = []
144+
for block in content:
145+
if isinstance(block, dict):
146+
transformed.append(_transform_langchain_content_block(block))
147+
else:
148+
transformed.append(block)
149+
return transformed
150+
151+
return content
152+
153+
120154
# Contextvar to track agent names in a stack for re-entrant agent support
121155
_agent_stack: "contextvars.ContextVar[Optional[List[Optional[str]]]]" = (
122156
contextvars.ContextVar("langchain_agent_stack", default=None)
@@ -234,7 +268,9 @@ def _handle_error(self, run_id: "UUID", error: "Any") -> None:
234268
del self.span_map[run_id]
235269

236270
def _normalize_langchain_message(self, message: "BaseMessage") -> "Any":
237-
parsed = {"role": message.type, "content": message.content}
271+
# Transform content to handle multimodal data (images, audio, video, files)
272+
transformed_content = _transform_langchain_message_content(message.content)
273+
parsed = {"role": message.type, "content": transformed_content}
238274
parsed.update(message.additional_kwargs)
239275
return parsed
240276

sentry_sdk/utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import math
66
import os
7+
import copy
78
import random
89
import re
910
import subprocess
@@ -2061,6 +2062,25 @@ def format_attribute(val: "Any") -> "AttributeValue":
20612062
if isinstance(val, (bool, int, float, str)):
20622063
return val
20632064

2065+
# Only lists of elements of a single type are supported
2066+
list_types: 'dict[type, Literal["string[]", "integer[]", "double[]", "boolean[]"]]' = {
2067+
str: "string[]",
2068+
int: "integer[]",
2069+
float: "double[]",
2070+
bool: "boolean[]",
2071+
}
2072+
2073+
if isinstance(val, (list, tuple)) and not val:
2074+
return []
2075+
elif isinstance(val, list):
2076+
ty = type(val[0])
2077+
if ty in list_types and all(type(v) is ty for v in val):
2078+
return copy.deepcopy(val)
2079+
elif isinstance(val, tuple):
2080+
ty = type(val[0])
2081+
if ty in list_types and all(type(v) is ty for v in val):
2082+
return list(val)
2083+
20642084
return safe_repr(val)
20652085

20662086

@@ -2075,6 +2095,22 @@ def serialize_attribute(val: "AttributeValue") -> "SerializedAttributeValue":
20752095
if isinstance(val, str):
20762096
return {"value": val, "type": "string"}
20772097

2098+
if isinstance(val, list):
2099+
if not val:
2100+
return {"value": [], "type": "string[]"}
2101+
2102+
# Only lists of elements of a single type are supported
2103+
list_types: 'dict[type, Literal["string[]", "integer[]", "double[]", "boolean[]"]]' = {
2104+
str: "string[]",
2105+
int: "integer[]",
2106+
float: "double[]",
2107+
bool: "boolean[]",
2108+
}
2109+
2110+
ty = type(val[0])
2111+
if ty in list_types and all(type(v) is ty for v in val):
2112+
return {"value": val, "type": list_types[ty]}
2113+
20782114
# Coerce to string if we don't know what to do with the value. This should
20792115
# never happen as we pre-format early in format_attribute, but let's be safe.
20802116
return {"value": safe_repr(val), "type": "string"}

0 commit comments

Comments
 (0)