Skip to content
Draft
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
13 changes: 5 additions & 8 deletions sentry_sdk/ai/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@
return content


def _normalize_data(data: "Any", unpack: bool = True) -> "Any":
def _normalize_data(data: "Any") -> "Any":
# convert pydantic data (e.g. OpenAI v1+) to json compatible format
if hasattr(data, "model_dump"):
# Check if it's a class (type) rather than an instance
Expand All @@ -472,29 +472,26 @@
return f"<ClassType: {data.__name__}>"

try:
return _normalize_data(data.model_dump(), unpack=unpack)
return _normalize_data(data.model_dump())
except Exception as e:
logger.warning("Could not convert pydantic data to JSON: %s", e)
return data if isinstance(data, (int, float, bool, str)) else str(data)

if isinstance(data, list):
if unpack and len(data) == 1:
return _normalize_data(data[0], unpack=unpack) # remove empty dimensions
return list(_normalize_data(x, unpack=unpack) for x in data)
return list(_normalize_data(x) for x in data)

if isinstance(data, dict):
return {k: _normalize_data(v, unpack=unpack) for (k, v) in data.items()}
return {k: _normalize_data(v) for (k, v) in data.items()}

return data if isinstance(data, (int, float, bool, str)) else str(data)


def set_data_normalized(
span: "Union[Span, StreamedSpan]",
key: str,
value: "Any",
unpack: bool = True,
) -> None:
normalized = _normalize_data(value, unpack=unpack)
normalized = _normalize_data(value)

Check warning on line 494 in sentry_sdk/ai/utils.py

View check run for this annotation

@sentry/warden / warden: code-review

Singleton AI response lists are now emitted as JSON arrays

`set_data_normalized()` now preserves list values, then JSON-serializes them as arrays. Several integrations pass text outputs as lists even when there is only one text block, so a span that previously exposed `"Hi, I'm Claude."` now exposes `["Hi, I'm Claude."]`. This changes the shape of the emitted span attribute and conflicts with existing tests and consumers expecting a scalar. Flatten scalar-semantic outputs at those call sites, or update the tests and document the new attribute shape if this is intentional.
Comment on lines 480 to +494

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Singleton AI response lists are now emitted as JSON arrays

set_data_normalized() now preserves list values, then JSON-serializes them as arrays. Several integrations pass text outputs as lists even when there is only one text block, so a span that previously exposed "Hi, I'm Claude." now exposes ["Hi, I'm Claude."]. This changes the shape of the emitted span attribute and conflicts with existing tests and consumers expecting a scalar. Flatten scalar-semantic outputs at those call sites, or update the tests and document the new attribute shape if this is intentional.

Evidence
  • _normalize_data() preserves lists, and set_data_normalized() JSON-serializes non-scalars; a one-item list therefore remains a JSON array string.
  • anthropic._set_output_data() collects text blocks in output_messages["response"] and passes that list to set_data_normalized().
  • The Anthropic tests still assert a bare "Hi, I'm Claude." value for these responses, confirming the emitted shape no longer matches the existing expectation.
  • LangChain also passes nested generation text lists to set_data_normalized(), so singleton generation results are affected as well.

Identified by Warden · code-review · 8XB-P9Q

if isinstance(normalized, (int, float, bool, str)):
_set_span_data_attribute(span, key, normalized)
else:
Expand Down
2 changes: 0 additions & 2 deletions sentry_sdk/integrations/anthropic.py
Original file line number Diff line number Diff line change
Expand Up @@ -564,7 +564,6 @@ def _set_common_input_data(
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)


Expand Down Expand Up @@ -705,7 +704,6 @@ def _set_output_data(
span,
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
output_messages["tool"],
unpack=False,
)

if len(output_messages["response"]) > 0:
Expand Down
4 changes: 0 additions & 4 deletions sentry_sdk/integrations/google_genai/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -950,15 +950,13 @@ def set_span_data_for_request(
span,
SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS,
formatted_tools,
unpack=False,
)
else:
# To remove once data collection has been fully rolled out
set_data_normalized(
span,
SPANDATA.GEN_AI_REQUEST_AVAILABLE_TOOLS,
formatted_tools,
unpack=False,
)

record_inputs = False
Expand Down Expand Up @@ -1002,7 +1000,6 @@ def set_span_data_for_request(
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)

# Extract parameters directly from config (not nested under generation_config)
Expand Down Expand Up @@ -1167,7 +1164,6 @@ def set_span_data_for_embed_request(
span,
SPANDATA.GEN_AI_EMBEDDINGS_INPUT,
input_texts,
unpack=False,
)


Expand Down
14 changes: 3 additions & 11 deletions sentry_sdk/integrations/huggingface_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,21 +139,17 @@ def new_huggingface_task(*args: "Any", **kwargs: "Any") -> "Any":
# Input attributes
if has_data_collection_enabled(client.options):
if client.options["data_collection"]["gen_ai"]["inputs"]:
set_data_normalized(
span, SPANDATA.GEN_AI_REQUEST_MESSAGES, prompt, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_REQUEST_MESSAGES, prompt)
elif should_send_default_pii() and integration.include_prompts:
set_data_normalized(
span, SPANDATA.GEN_AI_REQUEST_MESSAGES, prompt, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_REQUEST_MESSAGES, prompt)

for attribute, span_attribute in attribute_mapping.items():
value = kwargs.get(attribute, None)
if value is not None:
if isinstance(value, (int, float, bool, str)):
_set_span_data_attribute(span, span_attribute, value)
else:
set_data_normalized(span, span_attribute, value, unpack=False)
set_data_normalized(span, span_attribute, value)

# LLM Execution
try:
Expand Down Expand Up @@ -230,14 +226,12 @@ def new_huggingface_task(*args: "Any", **kwargs: "Any") -> "Any":
span,
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
tool_calls,
unpack=False,
)
elif should_send_default_pii() and integration.include_prompts:
set_data_normalized(
span,
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
tool_calls,
unpack=False,
)

if len(response_text_buffer) > 0:
Expand Down Expand Up @@ -436,7 +430,6 @@ def new_iterator() -> "Iterable[ChatCompletionStreamOutput]":
span,
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
tool_calls,
unpack=False,
)
elif (
should_send_default_pii()
Expand All @@ -446,7 +439,6 @@ def new_iterator() -> "Iterable[ChatCompletionStreamOutput]":
span,
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
tool_calls,
unpack=False,
)

if len(response_text_buffer) > 0:
Expand Down
27 changes: 6 additions & 21 deletions sentry_sdk/integrations/langchain.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ def on_llm_start(
):
continue

set_data_normalized(span, attribute, all_params[key], unpack=False)
set_data_normalized(span, attribute, all_params[key])

_set_tools_on_span(span, all_params.get("tools"))

Expand Down Expand Up @@ -472,7 +472,6 @@ def on_llm_start(
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)

def on_chat_model_start(
Expand Down Expand Up @@ -544,7 +543,7 @@ def on_chat_model_start(
):
continue

set_data_normalized(span, attribute, all_params[key], unpack=False)
set_data_normalized(span, attribute, all_params[key])

_set_tools_on_span(span, all_params.get("tools"))

Expand Down Expand Up @@ -585,7 +584,6 @@ def on_chat_model_start(
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)

def on_chat_model_end(
Expand Down Expand Up @@ -673,7 +671,6 @@ def on_llm_end(
span,
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
tool_calls,
unpack=False,
)

if record_outputs:
Expand Down Expand Up @@ -1081,7 +1078,6 @@ def _set_tools_on_span(span: "Union[Span, StreamedSpan]", tools: "Any") -> None:
span,
attribute_name,
simplified_tools,
unpack=False,
)


Expand Down Expand Up @@ -1218,7 +1214,6 @@ def new_invoke(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)

output = result.get("output")
Expand Down Expand Up @@ -1260,7 +1255,6 @@ def new_invoke(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)

output = result.get("output")
Expand Down Expand Up @@ -1338,7 +1332,6 @@ def new_stream(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)

# Run the agent
Expand Down Expand Up @@ -1454,9 +1447,7 @@ def new_embedding_method(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
input_data = args[0]
# Normalize to list format
texts = input_data if isinstance(input_data, list) else [input_data]
set_data_normalized(
span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, texts, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, texts)

result = f(self, *args, **kwargs)
return result
Expand All @@ -1474,9 +1465,7 @@ def new_embedding_method(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
input_data = args[0]
# Normalize to list format
texts = input_data if isinstance(input_data, list) else [input_data]
set_data_normalized(
span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, texts, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, texts)

result = f(self, *args, **kwargs)
return result
Expand Down Expand Up @@ -1521,9 +1510,7 @@ async def new_async_embedding_method(
input_data = args[0]
# Normalize to list format
texts = input_data if isinstance(input_data, list) else [input_data]
set_data_normalized(
span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, texts, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, texts)

result = await f(self, *args, **kwargs)
return result
Expand All @@ -1541,9 +1528,7 @@ async def new_async_embedding_method(
input_data = args[0]
# Normalize to list format
texts = input_data if isinstance(input_data, list) else [input_data]
set_data_normalized(
span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, texts, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, texts)

result = await f(self, *args, **kwargs)
return result
Expand Down
5 changes: 0 additions & 5 deletions sentry_sdk/integrations/langgraph.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,6 @@ def new_invoke(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)

result = f(self, *args, **kwargs)
Expand Down Expand Up @@ -274,7 +273,6 @@ def new_invoke(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)

result = f(self, *args, **kwargs)
Expand Down Expand Up @@ -333,7 +331,6 @@ async def new_ainvoke(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)

result = await f(self, *args, **kwargs)
Expand Down Expand Up @@ -372,7 +369,6 @@ async def new_ainvoke(self: "Any", *args: "Any", **kwargs: "Any") -> "Any":
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)

result = await f(self, *args, **kwargs)
Expand Down Expand Up @@ -513,5 +509,4 @@ def _set_response_attributes(
span,
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
safe_serialize(tool_calls),
unpack=False,
)
2 changes: 0 additions & 2 deletions sentry_sdk/integrations/litellm.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,6 @@ def _input_callback(kwargs: "Dict[str, Any]") -> None:
span,
SPANDATA.GEN_AI_EMBEDDINGS_INPUT,
messages_data,
unpack=False,
)
else:
# For chat, look for the 'messages' parameter
Expand All @@ -175,7 +174,6 @@ def _input_callback(kwargs: "Dict[str, Any]") -> None:
span,
SPANDATA.GEN_AI_REQUEST_MESSAGES,
messages_data,
unpack=False,
)

# Record other parameters
Expand Down
26 changes: 6 additions & 20 deletions sentry_sdk/integrations/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -486,9 +486,7 @@
else normalized_messages
)
if messages_data is not None:
set_data_normalized(
span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data)
return

# Input was provided as a list (potentially a multi-turn conversation)
Expand All @@ -505,9 +503,7 @@
else normalized_messages
)
if messages_data is not None:
set_data_normalized(
span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data)


def _set_completions_api_input_data(
Expand Down Expand Up @@ -592,9 +588,7 @@
else normalized_messages
)
if messages_data is not None:
set_data_normalized(
span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data)
return

# dict special case following https://github.com/openai/openai-python/blob/3e0c05b84a2056870abf3bd6a5e7849020209cc3/src/openai/_utils/_transform.py#L194-L197
Expand Down Expand Up @@ -626,9 +620,7 @@
else normalized_messages
)
if messages_data is not None:
set_data_normalized(
span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_REQUEST_MESSAGES, messages_data)


def _set_embeddings_input_data(
Expand Down Expand Up @@ -670,9 +662,7 @@
else normalized_messages
)
if messages_data is not None:
set_data_normalized(
span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, messages_data, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, messages_data)

return

Expand All @@ -692,9 +682,7 @@
else normalized_messages
)
if messages_data is not None:
set_data_normalized(
span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, messages_data, unpack=False
)
set_data_normalized(span, SPANDATA.GEN_AI_EMBEDDINGS_INPUT, messages_data)


def _set_common_output_data(
Expand Down Expand Up @@ -775,13 +763,12 @@
span,
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
output_messages["tool"],
unpack=False,
)

if record_outputs and len(output_messages["response"]) > 0:
set_data_normalized(
span, SPANDATA.GEN_AI_RESPONSE_TEXT, output_messages["response"]
)

Check warning on line 771 in sentry_sdk/integrations/openai.py

View check run for this annotation

@sentry/warden / warden: find-bugs

Single-text AI responses are now recorded as JSON arrays

OpenAI Responses collects text outputs in a list and passes that list to `set_data_normalized()`. The helper serializes non-scalar values as JSON, so a single text output is recorded as `'["..."]'` instead of the bare string expected by existing OpenAI and Anthropic tests. Unwrap a single text value before setting this attribute, while preserving the intended shape for multiple outputs.

elif (
should_send_default_pii()
Expand All @@ -804,7 +791,6 @@
span,
SPANDATA.GEN_AI_RESPONSE_TOOL_CALLS,
output_messages["tool"],
unpack=False,
)

if len(output_messages["response"]) > 0:
Expand Down
Loading
Loading