Skip to content

Commit 4ce3c03

Browse files
feat(bedrock): Set gen_ai.input.messages attribute
1 parent 7dec8c9 commit 4ce3c03

3 files changed

Lines changed: 763 additions & 8 deletions

File tree

‎sentry_sdk/_types.py‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -471,20 +471,44 @@ class DataCollection(TypedDict):
471471

472472
class TextPart(TypedDict):
473473
type: Literal["text"]
474-
content: str
474+
content: NotRequired[str]
475475

476476
class ReasoningPart(TypedDict):
477477
type: Literal["reasoning"]
478-
content: str
478+
content: NotRequired[str]
479479

480480
class ToolCallPart(TypedDict):
481481
type: Literal["tool_call"]
482482
name: NotRequired[str]
483483
arguments: NotRequired[Any]
484484

485+
class ToolCallResponsePart(TypedDict):
486+
type: Literal["tool_call_response"]
487+
response: NotRequired[Any]
488+
489+
Modality = Literal["image", "video", "audio", "document"]
490+
491+
class UriPart(TypedDict):
492+
type: Literal["uri"]
493+
modality: NotRequired[Modality]
494+
uri: NotRequired[str]
495+
496+
class BlobPart(TypedDict):
497+
type: Literal["blob"]
498+
modality: NotRequired[Modality]
499+
485500
class InputMessage(TypedDict):
486501
role: Literal["user", "assistant", "tool"]
487-
parts: list[Union[TextPart, ReasoningPart, ToolCallPart]]
502+
parts: list[
503+
Union[
504+
TextPart,
505+
ReasoningPart,
506+
ToolCallPart,
507+
ToolCallResponsePart,
508+
UriPart,
509+
BlobPart,
510+
]
511+
]
488512

489513
class OutputMessage(TypedDict):
490514
role: Literal["assistant"]

‎sentry_sdk/integrations/boto3/_services/bedrock.py‎

Lines changed: 202 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,17 @@
3030
ConverseStreamResponseTypeDef,
3131
)
3232

33-
from sentry_sdk._types import Attributes, TextPart, ToolDefinition
33+
from sentry_sdk._types import (
34+
Attributes,
35+
BlobPart,
36+
InputMessage,
37+
ReasoningPart,
38+
TextPart,
39+
ToolCallPart,
40+
ToolCallResponsePart,
41+
ToolDefinition,
42+
UriPart,
43+
)
3444
from sentry_sdk.integrations.boto3._context import AwsCallContext
3545
from sentry_sdk.tracing import Span
3646

@@ -202,6 +212,189 @@ def _transform_system_instructions(
202212
return instruction_text_parts
203213

204214

215+
def _transform_messages(
216+
input_messages: "Union[list[dict[str, Any]], tuple[dict[str, Any], ...]]",
217+
) -> "list[InputMessage]":
218+
"""
219+
Unlike other providers, AWS declares that input and output messages use the same message type.
220+
For this reason, the same transform is used for user and assistant messages.
221+
"""
222+
messages: "list[InputMessage]" = []
223+
for message in input_messages:
224+
if not isinstance(message, dict):
225+
continue
226+
227+
content = message.get("content")
228+
if not isinstance(content, (list, tuple)) or "role" not in message:
229+
continue
230+
231+
parts: "list[Union[TextPart, ReasoningPart, ToolCallPart, ToolCallResponsePart, UriPart, BlobPart]]" = []
232+
for block in content:
233+
if not isinstance(block, dict):
234+
continue
235+
236+
if "text" in block:
237+
parts.append({"type": "text", "content": block["text"]})
238+
continue
239+
240+
tool_use = block.get("toolUse")
241+
if tool_use is not None:
242+
tool_call_part: "ToolCallPart" = {"type": "tool_call"}
243+
244+
if not isinstance(tool_use, dict):
245+
parts.append(tool_call_part)
246+
continue
247+
248+
if "name" in tool_use:
249+
tool_call_part["name"] = tool_use["name"]
250+
if "input" in tool_use:
251+
tool_call_part["arguments"] = tool_use["input"]
252+
parts.append(tool_call_part)
253+
continue
254+
255+
tool_result = block.get("toolResult")
256+
if tool_result is not None:
257+
tool_call_response_part: "ToolCallResponsePart" = {
258+
"type": "tool_call_response",
259+
}
260+
261+
if not isinstance(tool_result, dict) or "content" not in tool_result:
262+
parts.append(tool_call_response_part)
263+
continue
264+
265+
tool_call_response_part["response"] = [
266+
item
267+
for item in tool_result["content"]
268+
if isinstance(item, dict) and ("json" in item or "text" in item)
269+
]
270+
parts.append(tool_call_response_part)
271+
continue
272+
273+
reasoning_content = block.get("reasoningContent")
274+
if reasoning_content is not None:
275+
reasoning_part: "ReasoningPart" = {"type": "reasoning"}
276+
277+
if (
278+
not isinstance(reasoning_content, dict)
279+
or "reasoningText" not in reasoning_content
280+
or not isinstance(reasoning_content["reasoningText"], dict)
281+
or "text" not in reasoning_content["reasoningText"]
282+
):
283+
parts.append(reasoning_part)
284+
continue
285+
286+
reasoning_part["content"] = reasoning_content["reasoningText"]["text"]
287+
parts.append(reasoning_part)
288+
continue
289+
290+
image = block.get("image")
291+
if (
292+
isinstance(image, dict)
293+
and isinstance(image.get("source"), dict)
294+
and "s3Location" in image["source"]
295+
):
296+
uri_part: "UriPart" = {
297+
"type": "uri",
298+
"modality": "image",
299+
}
300+
s3_location = image["source"]["s3Location"]
301+
if not isinstance(s3_location, dict) or "uri" not in s3_location:
302+
parts.append(uri_part)
303+
continue
304+
305+
uri_part["uri"] = s3_location["uri"]
306+
parts.append(uri_part)
307+
continue
308+
elif (
309+
isinstance(image, dict)
310+
and isinstance(image.get("source"), dict)
311+
and "bytes" in image["source"]
312+
):
313+
parts.append({"type": "blob", "modality": "image"})
314+
continue
315+
316+
video = block.get("video")
317+
if (
318+
isinstance(video, dict)
319+
and isinstance(video.get("source"), dict)
320+
and "s3Location" in video["source"]
321+
):
322+
uri_part = {
323+
"type": "uri",
324+
"modality": "video",
325+
}
326+
s3_location = video["source"]["s3Location"]
327+
if not isinstance(s3_location, dict) or "uri" not in s3_location:
328+
parts.append(uri_part)
329+
continue
330+
331+
uri_part["uri"] = s3_location["uri"]
332+
parts.append(uri_part)
333+
continue
334+
elif (
335+
isinstance(video, dict)
336+
and isinstance(video.get("source"), dict)
337+
and "bytes" in video["source"]
338+
):
339+
parts.append({"type": "blob", "modality": "video"})
340+
continue
341+
342+
audio = block.get("audio")
343+
if (
344+
isinstance(audio, dict)
345+
and isinstance(audio.get("source"), dict)
346+
and "s3Location" in audio["source"]
347+
):
348+
uri_part = {
349+
"type": "uri",
350+
"modality": "audio",
351+
}
352+
s3_location = audio["source"]["s3Location"]
353+
if not isinstance(s3_location, dict) or "uri" not in s3_location:
354+
parts.append(uri_part)
355+
continue
356+
357+
uri_part["uri"] = s3_location["uri"]
358+
parts.append(uri_part)
359+
continue
360+
elif (
361+
isinstance(audio, dict)
362+
and isinstance(audio.get("source"), dict)
363+
and "bytes" in audio["source"]
364+
):
365+
parts.append({"type": "blob", "modality": "audio"})
366+
continue
367+
368+
document = block.get("document")
369+
if (
370+
not isinstance(document, dict)
371+
or "source" not in document
372+
or not isinstance(document["source"], dict)
373+
):
374+
continue
375+
376+
if "s3Location" in document["source"]:
377+
uri_part = {
378+
"type": "uri",
379+
"modality": "document",
380+
}
381+
s3_location = document["source"]["s3Location"]
382+
if not isinstance(s3_location, dict) or "uri" not in s3_location:
383+
parts.append(uri_part)
384+
continue
385+
386+
uri_part["uri"] = s3_location["uri"]
387+
parts.append(uri_part)
388+
continue
389+
390+
if "bytes" in document["source"]:
391+
parts.append({"type": "blob", "modality": "document"})
392+
continue
393+
394+
messages.append({"role": message["role"], "parts": parts})
395+
return messages
396+
397+
205398
class _BedrockExtension(_ServiceExtension):
206399
__slots__ = ()
207400
skip_if_no_active_span = False
@@ -250,6 +443,14 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes":
250443
if not client_options["data_collection"]["gen_ai"]["inputs"]:
251444
return attributes
252445

446+
input_messages = ctx.params.get("messages")
447+
if (
448+
has_data_collection_enabled(client_options) or should_send_default_pii()
449+
) and isinstance(input_messages, (list, tuple)):
450+
attributes[SPANDATA.GEN_AI_INPUT_MESSAGES] = json.dumps(
451+
_transform_messages(input_messages)
452+
)
453+
253454
system_instructions = ctx.params.get("system")
254455
if (
255456
has_data_collection_enabled(client_options) or should_send_default_pii()

0 commit comments

Comments
 (0)