From 70dca2abe81b786685d089e3e7e541290268e317 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Mon, 26 Oct 2026 18:31:21 +0100 Subject: [PATCH 01/14] . --- sentry_sdk/consts.py | 20 ++ .../integrations/boto3/_instrumentation.py | 5 + .../integrations/boto3/_services/base.py | 6 + .../integrations/boto3/_services/sqs.py | 97 +++++-- tests/integrations/boto3/test_sqs.py | 272 ++++++++++++++++-- 5 files changed, 361 insertions(+), 39 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index f7a673f1bc..8cda281a25 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -471,6 +471,12 @@ class SPANDATA: Example: "db-password" """ + AWS_SQS_QUEUE_URL = "aws.sqs.queue.url" + """ + The URL of the AWS SQS queue the request refers to. + Example: "https://sqs.us-east-1.amazonaws.com/123456789012/orders" + """ + CACHE_HIT = "cache.hit" """ A boolean indicating whether the requested data was found in the cache. @@ -1090,6 +1096,18 @@ class SPANDATA: Number of retries/attempts to process a message. """ + MESSAGING_OPERATION_NAME = "messaging.operation.name" + """ + The system-specific name of the messaging operation. + Example: "publish" + """ + + MESSAGING_OPERATION_TYPE = "messaging.operation.type" + """ + The type of the messaging operation: `create`, `send`, `receive`, `process` or `settle`. + Example: "send" + """ + MESSAGING_SYSTEM = "messaging.system" """ The messaging system's name, e.g. `kafka`, `aws_sqs` @@ -1489,6 +1507,8 @@ class OP: OBJECT_UPLOAD_PART = "object.upload_part" QUEUE_PROCESS = "queue.process" QUEUE_PUBLISH = "queue.publish" + QUEUE_RECEIVE = "queue.receive" + QUEUE_SETTLE = "queue.settle" QUEUE_SUBMIT_ARQ = "queue.submit.arq" QUEUE_TASK_ARQ = "queue.task.arq" QUEUE_SUBMIT_CELERY = "queue.submit.celery" diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 731dac6fa3..8b7e34da87 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -180,6 +180,11 @@ def _start_client_span( with capture_internal_exceptions(): attributes.update(service_ext.get_request_attributes(ctx)) + with capture_internal_exceptions(): + service_span_name = service_ext.get_span_name(ctx, attributes) + if service_span_name is not None: + span_name = service_span_name + # `start_span()` evaluates `ignore_spans` against the initial attributes. # https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span attributes.update( diff --git a/sentry_sdk/integrations/boto3/_services/base.py b/sentry_sdk/integrations/boto3/_services/base.py index ec7ac1df1c..edff2d31a9 100644 --- a/sentry_sdk/integrations/boto3/_services/base.py +++ b/sentry_sdk/integrations/boto3/_services/base.py @@ -21,6 +21,12 @@ def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]": """Return an optional `sentry.op` override, or `None` to keep the default.""" return None + def get_span_name( + self, ctx: "AwsCallContext", attributes: "Attributes" + ) -> "Optional[str]": + """Return an optional span name override from the span's start attributes, or `None` to keep the default.""" + return None + def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": """Return request attributes to add before the AWS request is made.""" return {} diff --git a/sentry_sdk/integrations/boto3/_services/sqs.py b/sentry_sdk/integrations/boto3/_services/sqs.py index e9ae02142e..833c6f7b4b 100644 --- a/sentry_sdk/integrations/boto3/_services/sqs.py +++ b/sentry_sdk/integrations/boto3/_services/sqs.py @@ -2,36 +2,97 @@ from typing import TYPE_CHECKING from urllib.parse import unquote, urlsplit -from sentry_sdk.consts import SPANDATA +from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3._services.base import _ServiceExtension from sentry_sdk.integrations.boto3._utils import _get_aws_arn_attributes -from sentry_sdk.utils import capture_internal_exceptions if TYPE_CHECKING: + from typing import Any, Optional + from sentry_sdk._types import Attributes from sentry_sdk.integrations.boto3._context import AwsCallContext +# operation -> (`sentry.op`, `messaging.operation.type`) +_MESSAGING_OPERATIONS = { + "SendMessage": (OP.QUEUE_PUBLISH, "send"), + "SendMessageBatch": (OP.QUEUE_PUBLISH, "send"), + "ReceiveMessage": (OP.QUEUE_RECEIVE, "receive"), + "DeleteMessage": (OP.QUEUE_SETTLE, "settle"), + "DeleteMessageBatch": (OP.QUEUE_SETTLE, "settle"), +} + class _SQSExtension(_ServiceExtension): + def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]": + if ctx.operation_name in _MESSAGING_OPERATIONS: + return _MESSAGING_OPERATIONS[ctx.operation_name][0] + return None + + def get_span_name( + self, ctx: "AwsCallContext", attributes: "Attributes" + ) -> "Optional[str]": + if ctx.operation_name not in _MESSAGING_OPERATIONS: + return None + return f"{attributes[SPANDATA.MESSAGING_OPERATION_TYPE]} {attributes[SPANDATA.MESSAGING_DESTINATION_NAME]}" + def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": - attributes: "Attributes" = {} - with capture_internal_exceptions(): + attributes = _get_aws_arn_attributes( + (ctx.params.get("DestinationArn"), ctx.params.get("SourceArn")), ("sqs",) + ) + + queue_url = ctx.params.get("QueueUrl") + if queue_url is not None: + attributes[SPANDATA.AWS_SQS_QUEUE_URL] = queue_url + # https://sqs..amazonaws.com// + path = unquote(urlsplit(queue_url).path).strip("/").split("/") + attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = path[-1] + if re.fullmatch(r"[0-9]{12}", path[0]): + attributes[SPANDATA.CLOUD_ACCOUNT_ID] = path[0] + elif "QueueName" in ctx.params: + # `CreateQueue`, `GetQueueUrl`. + attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = ctx.params["QueueName"] + if "QueueOwnerAWSAccountId" in ctx.params: + attributes[SPANDATA.CLOUD_ACCOUNT_ID] = ctx.params[ + "QueueOwnerAWSAccountId" + ] + elif "SourceArn" in ctx.params: + # message move tasks: arn:aws:sqs::: + attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = ctx.params[ + "SourceArn" + ].split(":")[-1] + + if ctx.operation_name in _MESSAGING_OPERATIONS: + operation_type = _MESSAGING_OPERATIONS[ctx.operation_name][1] attributes.update( - _get_aws_arn_attributes( - ( - ctx.params.get("DestinationArn"), - ctx.params.get("SourceArn"), - ), - ("sqs",), - ) + { + SPANDATA.MESSAGING_SYSTEM: "aws_sqs", + SPANDATA.MESSAGING_OPERATION_TYPE: operation_type, + SPANDATA.MESSAGING_OPERATION_NAME: operation_type, + } ) + if operation_type == "send": + attributes[SPANDATA.SENTRY_KIND] = "producer" + if "Entries" in ctx.params: + attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] = len( + ctx.params["Entries"] + ) + return attributes - queue_url = ctx.params.get("QueueUrl") - if queue_url is None: + def get_response_attributes( + self, ctx: "AwsCallContext", response: "Any" + ) -> "Attributes": + attributes: "Attributes" = {} + # a `ClientError` response holds no results. + if "Error" in response: return attributes - - with capture_internal_exceptions(): - account_id = unquote(urlsplit(queue_url).path).strip("/").split("/", 1)[0] - if re.fullmatch(r"^[0-9]{12}$", account_id): - attributes[SPANDATA.CLOUD_ACCOUNT_ID] = account_id + if ctx.operation_name == "SendMessage": + attributes[SPANDATA.MESSAGING_MESSAGE_ID] = response["MessageId"] + elif ctx.operation_name == "ReceiveMessage": + # `Messages` is left out when no message was received. + attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] = len( + response.get("Messages", ()) + ) + # failed entries come back in a `200 OK` response. + if response.get("Failed"): + attributes[SPANDATA.ERROR_TYPE] = "aws_batch_partial_failure" return attributes diff --git a/tests/integrations/boto3/test_sqs.py b/tests/integrations/boto3/test_sqs.py index ecc81c69d9..2bd6bf56a0 100644 --- a/tests/integrations/boto3/test_sqs.py +++ b/tests/integrations/boto3/test_sqs.py @@ -1,4 +1,5 @@ import pytest +from botocore.exceptions import ClientError from botocore.stub import Stubber from sentry_sdk.consts import OP, SPANDATA @@ -9,23 +10,59 @@ from tests.integrations.boto3.helpers import client_factory as client_factory -@pytest.mark.parametrize("source", ["arn", "queue-url"]) -def test_request_attributes(client_factory, capture_items, source): +@pytest.mark.parametrize( + "method,params,response,queue_url,account_id,resource_id", + [ + pytest.param( + "delete_queue", + {"QueueUrl": "https://sqs.eu-north-1.amazonaws.com/123456789012/orders"}, + {}, + "https://sqs.eu-north-1.amazonaws.com/123456789012/orders", + "123456789012", + None, + id="queue-url", + ), + pytest.param( + "create_queue", + {"QueueName": "orders"}, + {"QueueUrl": "https://sqs.eu-north-1.amazonaws.com/123456789012/orders"}, + None, + None, + None, + id="create-queue", + ), + pytest.param( + "get_queue_url", + {"QueueName": "orders", "QueueOwnerAWSAccountId": "123456789012"}, + {"QueueUrl": "https://sqs.eu-north-1.amazonaws.com/123456789012/orders"}, + None, + "123456789012", + None, + id="get-queue-url", + ), + pytest.param( + "start_message_move_task", + {"SourceArn": "arn:aws:sqs:eu-north-1:123456789012:orders"}, + {"TaskHandle": "task-handle"}, + None, + "123456789012", + "arn:aws:sqs:eu-north-1:123456789012:orders", + id="source-arn", + ), + ], +) +def test_request_attributes( + client_factory, + capture_items, + method, + params, + response, + queue_url, + account_id, + resource_id, +): client = client_factory("sqs") - - if source == "arn": - require_botocore_model_fields(client, "start_message_move_task") - resource_id = "arn:aws:sqs:eu-north-1:123456789012:orders-dlq" - method = "start_message_move_task" - params = {"SourceArn": resource_id} - response = {"TaskHandle": "task-handle"} - else: - resource_id = None - method = "delete_queue" - params = { - "QueueUrl": "https://sqs.eu-north-1.amazonaws.com/123456789012/orders" - } - response = {} + require_botocore_model_fields(client, method) with Stubber(client) as stubber: stubber.add_response(method, response, params) @@ -35,8 +72,201 @@ def test_request_attributes(client_factory, capture_items, source): (span,) = spans[OP.RPC] attributes = span["attributes"] - assert attributes[SPANDATA.CLOUD_ACCOUNT_ID] == "123456789012" - if resource_id is None: - assert SPANDATA.CLOUD_RESOURCE_ID not in attributes - else: - assert attributes[SPANDATA.CLOUD_RESOURCE_ID] == resource_id + assert attributes[SPANDATA.MESSAGING_DESTINATION_NAME] == "orders" + assert attributes.get(SPANDATA.AWS_SQS_QUEUE_URL) == queue_url + assert attributes.get(SPANDATA.CLOUD_ACCOUNT_ID) == account_id + assert attributes.get(SPANDATA.CLOUD_RESOURCE_ID) == resource_id + assert SPANDATA.MESSAGING_SYSTEM not in attributes + + +def test_send_message(client_factory, capture_items): + client = client_factory("sqs") + + with Stubber(client) as stubber: + stubber.add_response( + "send_message", {"MessageId": "5fea7756-0ea4-451a-a703-a558b933e274"} + ) + spans = capture_spans_by_op( + lambda: client.send_message( + QueueUrl="https://sqs.eu-north-1.amazonaws.com/123456789012/orders", + MessageBody="hi", + ), + capture_items, + ) + + (span,) = spans[OP.QUEUE_PUBLISH] + assert span["name"] == "send orders" + attributes = span["attributes"] + assert attributes[SPANDATA.SENTRY_KIND] == "producer" + assert attributes[SPANDATA.MESSAGING_SYSTEM] == "aws_sqs" + assert attributes[SPANDATA.MESSAGING_OPERATION_TYPE] == "send" + assert attributes[SPANDATA.MESSAGING_OPERATION_NAME] == "send" + assert attributes[SPANDATA.MESSAGING_DESTINATION_NAME] == "orders" + assert ( + attributes[SPANDATA.MESSAGING_MESSAGE_ID] + == "5fea7756-0ea4-451a-a703-a558b933e274" + ) + assert SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT not in attributes + + +@pytest.mark.parametrize( + "failed,error_type", + [ + pytest.param([], None, id="ok"), + pytest.param( + [{"Id": "2", "Code": "InternalError", "SenderFault": False}], + "aws_batch_partial_failure", + id="partial-failure", + ), + ], +) +def test_send_message_batch(client_factory, capture_items, failed, error_type): + client = client_factory("sqs") + + with Stubber(client) as stubber: + stubber.add_response( + "send_message_batch", + { + "Successful": [ + { + "Id": "1", + "MessageId": "5fea7756", + "MD5OfMessageBody": "49f68a5c8493ec2c0bf489821c21fc3b", + } + ], + "Failed": failed, + }, + ) + spans = capture_spans_by_op( + lambda: client.send_message_batch( + QueueUrl="https://sqs.eu-north-1.amazonaws.com/123456789012/orders", + Entries=[ + {"Id": "1", "MessageBody": "hi"}, + {"Id": "2", "MessageBody": "hi"}, + ], + ), + capture_items, + ) + + (span,) = spans[OP.QUEUE_PUBLISH] + assert span["name"] == "send orders" + assert span["status"] == ("ok" if error_type is None else "error") + attributes = span["attributes"] + assert attributes[SPANDATA.SENTRY_KIND] == "producer" + assert attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] == 2 + assert attributes.get(SPANDATA.ERROR_TYPE) == error_type + assert SPANDATA.MESSAGING_MESSAGE_ID not in attributes + + +@pytest.mark.parametrize( + "response,message_count", + [ + # `Messages` is absent when the queue is empty. + pytest.param({}, 0, id="empty"), + pytest.param( + { + "Messages": [ + {"MessageId": "1", "ReceiptHandle": "receipt-handle-1"}, + {"MessageId": "2", "ReceiptHandle": "receipt-handle-2"}, + ] + }, + 2, + id="messages", + ), + ], +) +def test_receive_message(client_factory, capture_items, response, message_count): + client = client_factory("sqs") + + with Stubber(client) as stubber: + stubber.add_response("receive_message", response) + spans = capture_spans_by_op( + lambda: client.receive_message( + QueueUrl="https://sqs.eu-north-1.amazonaws.com/123456789012/orders", + MaxNumberOfMessages=10, + ), + capture_items, + ) + + (span,) = spans[OP.QUEUE_RECEIVE] + assert span["name"] == "receive orders" + attributes = span["attributes"] + assert attributes[SPANDATA.SENTRY_KIND] == "client" + assert attributes[SPANDATA.MESSAGING_SYSTEM] == "aws_sqs" + assert attributes[SPANDATA.MESSAGING_OPERATION_TYPE] == "receive" + assert attributes[SPANDATA.MESSAGING_OPERATION_NAME] == "receive" + assert attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] == message_count + assert SPANDATA.MESSAGING_MESSAGE_ID not in attributes + assert "receipt-handle" not in str(span) + + +def test_receive_message_error(client_factory, capture_items): + client = client_factory("sqs") + + with Stubber(client) as stubber: + stubber.add_client_error( + "receive_message", + service_error_code="AWS.SimpleQueueService.NonExistentQueue", + http_status_code=400, + ) + + def call(): + with pytest.raises(ClientError): + client.receive_message( + QueueUrl="https://sqs.eu-north-1.amazonaws.com/123456789012/orders" + ) + + spans = capture_spans_by_op(call, capture_items) + + (span,) = spans[OP.QUEUE_RECEIVE] + assert span["status"] == "error" + assert SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT not in span["attributes"] + + +@pytest.mark.parametrize( + "method,params,response,message_count", + [ + pytest.param( + "delete_message", + {"ReceiptHandle": "receipt-handle-1"}, + {}, + None, + id="single", + ), + pytest.param( + "delete_message_batch", + { + "Entries": [ + {"Id": "1", "ReceiptHandle": "receipt-handle-1"}, + {"Id": "2", "ReceiptHandle": "receipt-handle-2"}, + ] + }, + {"Successful": [{"Id": "1"}, {"Id": "2"}], "Failed": []}, + 2, + id="batch", + ), + ], +) +def test_delete_message( + client_factory, capture_items, method, params, response, message_count +): + client = client_factory("sqs") + + with Stubber(client) as stubber: + stubber.add_response(method, response) + spans = capture_spans_by_op( + lambda: getattr(client, method)( + QueueUrl="https://sqs.eu-north-1.amazonaws.com/123456789012/orders", + **params, + ), + capture_items, + ) + + (span,) = spans[OP.QUEUE_SETTLE] + assert span["name"] == "settle orders" + attributes = span["attributes"] + assert attributes[SPANDATA.SENTRY_KIND] == "client" + assert attributes[SPANDATA.MESSAGING_OPERATION_TYPE] == "settle" + assert attributes[SPANDATA.MESSAGING_OPERATION_NAME] == "settle" + assert attributes.get(SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT) == message_count + assert "receipt-handle" not in str(span) From 0f904231b43598cf6c6c16dd20933b0b2f8c3bf2 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Tue, 27 Oct 2026 17:41:36 +0100 Subject: [PATCH 02/14] wip --- .../integrations/boto3/_instrumentation.py | 7 +- .../integrations/boto3/_services/base.py | 2 +- .../integrations/boto3/_services/sqs.py | 33 +++- tests/integrations/boto3/test_sqs.py | 154 +++++++++++------- 4 files changed, 123 insertions(+), 73 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 8b7e34da87..9be195bd95 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -180,10 +180,9 @@ def _start_client_span( with capture_internal_exceptions(): attributes.update(service_ext.get_request_attributes(ctx)) - with capture_internal_exceptions(): - service_span_name = service_ext.get_span_name(ctx, attributes) - if service_span_name is not None: - span_name = service_span_name + service_span_name = service_ext.get_span_name(ctx, attributes) + if service_span_name is not None: + span_name = service_span_name # `start_span()` evaluates `ignore_spans` against the initial attributes. # https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span diff --git a/sentry_sdk/integrations/boto3/_services/base.py b/sentry_sdk/integrations/boto3/_services/base.py index edff2d31a9..397499bffb 100644 --- a/sentry_sdk/integrations/boto3/_services/base.py +++ b/sentry_sdk/integrations/boto3/_services/base.py @@ -24,7 +24,7 @@ def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]": def get_span_name( self, ctx: "AwsCallContext", attributes: "Attributes" ) -> "Optional[str]": - """Return an optional span name override from the span's start attributes, or `None` to keep the default.""" + """Return an optional span name override, or `None` to keep the default.""" return None def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": diff --git a/sentry_sdk/integrations/boto3/_services/sqs.py b/sentry_sdk/integrations/boto3/_services/sqs.py index 833c6f7b4b..1be4fc1483 100644 --- a/sentry_sdk/integrations/boto3/_services/sqs.py +++ b/sentry_sdk/integrations/boto3/_services/sqs.py @@ -33,6 +33,8 @@ def get_span_name( ) -> "Optional[str]": if ctx.operation_name not in _MESSAGING_OPERATIONS: return None + # for example: `send ` when `messaging.operation.type=send`. + # https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/#span-name return f"{attributes[SPANDATA.MESSAGING_OPERATION_TYPE]} {attributes[SPANDATA.MESSAGING_DESTINATION_NAME]}" def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": @@ -43,20 +45,23 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": queue_url = ctx.params.get("QueueUrl") if queue_url is not None: attributes[SPANDATA.AWS_SQS_QUEUE_URL] = queue_url - # https://sqs..amazonaws.com// + + # `https://sqs.eu-north-1.amazonaws.com/123456789012/orders` => ['123456789012', 'orders'] path = unquote(urlsplit(queue_url).path).strip("/").split("/") + attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = path[-1] if re.fullmatch(r"[0-9]{12}", path[0]): attributes[SPANDATA.CLOUD_ACCOUNT_ID] = path[0] elif "QueueName" in ctx.params: - # `CreateQueue`, `GetQueueUrl`. + # `CreateQueue` & `GetQueueUrl` operations. attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = ctx.params["QueueName"] + if "QueueOwnerAWSAccountId" in ctx.params: attributes[SPANDATA.CLOUD_ACCOUNT_ID] = ctx.params[ "QueueOwnerAWSAccountId" ] elif "SourceArn" in ctx.params: - # message move tasks: arn:aws:sqs::: + # `arn:aws:sqs:eu-north-1:123456789012:orders` => `orders` attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = ctx.params[ "SourceArn" ].split(":")[-1] @@ -82,17 +87,27 @@ def get_response_attributes( self, ctx: "AwsCallContext", response: "Any" ) -> "Attributes": attributes: "Attributes" = {} - # a `ClientError` response holds no results. + + # `ClientError` response holds no results. if "Error" in response: return attributes + if ctx.operation_name == "SendMessage": attributes[SPANDATA.MESSAGING_MESSAGE_ID] = response["MessageId"] elif ctx.operation_name == "ReceiveMessage": # `Messages` is left out when no message was received. - attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] = len( - response.get("Messages", ()) + messages = response.get("Messages", ()) + attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] = len(messages) + if len(messages) == 1: + attributes[SPANDATA.MESSAGING_MESSAGE_ID] = messages[0]["MessageId"] + + # failed entries have a `200 OK` response; use their code when they share one. + # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_BatchResultErrorEntry.html + error_codes = {entry["Code"] for entry in response.get("Failed", ())} + if error_codes: + attributes[SPANDATA.ERROR_TYPE] = ( + error_codes.pop() + if len(error_codes) == 1 + else "aws_batch_partial_failure" ) - # failed entries come back in a `200 OK` response. - if response.get("Failed"): - attributes[SPANDATA.ERROR_TYPE] = "aws_batch_partial_failure" return attributes diff --git a/tests/integrations/boto3/test_sqs.py b/tests/integrations/boto3/test_sqs.py index 2bd6bf56a0..f28f8cb74b 100644 --- a/tests/integrations/boto3/test_sqs.py +++ b/tests/integrations/boto3/test_sqs.py @@ -9,6 +9,11 @@ ) from tests.integrations.boto3.helpers import client_factory as client_factory +_BATCH_ENTRIES = [ + {"Id": "1", "MessageBody": "hi"}, + {"Id": "2", "MessageBody": "hi"}, +] + @pytest.mark.parametrize( "method,params,response,queue_url,account_id,resource_id", @@ -79,71 +84,94 @@ def test_request_attributes( assert SPANDATA.MESSAGING_SYSTEM not in attributes -def test_send_message(client_factory, capture_items): - client = client_factory("sqs") - - with Stubber(client) as stubber: - stubber.add_response( - "send_message", {"MessageId": "5fea7756-0ea4-451a-a703-a558b933e274"} - ) - spans = capture_spans_by_op( - lambda: client.send_message( - QueueUrl="https://sqs.eu-north-1.amazonaws.com/123456789012/orders", - MessageBody="hi", - ), - capture_items, - ) - - (span,) = spans[OP.QUEUE_PUBLISH] - assert span["name"] == "send orders" - attributes = span["attributes"] - assert attributes[SPANDATA.SENTRY_KIND] == "producer" - assert attributes[SPANDATA.MESSAGING_SYSTEM] == "aws_sqs" - assert attributes[SPANDATA.MESSAGING_OPERATION_TYPE] == "send" - assert attributes[SPANDATA.MESSAGING_OPERATION_NAME] == "send" - assert attributes[SPANDATA.MESSAGING_DESTINATION_NAME] == "orders" - assert ( - attributes[SPANDATA.MESSAGING_MESSAGE_ID] - == "5fea7756-0ea4-451a-a703-a558b933e274" - ) - assert SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT not in attributes - - @pytest.mark.parametrize( - "failed,error_type", + "method,params,response,message_count,message_id,error_type", [ - pytest.param([], None, id="ok"), pytest.param( - [{"Id": "2", "Code": "InternalError", "SenderFault": False}], - "aws_batch_partial_failure", - id="partial-failure", + "send_message", + {"MessageBody": "hi"}, + {"MessageId": "5fea7756-0ea4-451a-a703-a558b933e274"}, + None, + "5fea7756-0ea4-451a-a703-a558b933e274", + None, + id="single", ), - ], -) -def test_send_message_batch(client_factory, capture_items, failed, error_type): - client = client_factory("sqs") - - with Stubber(client) as stubber: - stubber.add_response( + pytest.param( "send_message_batch", + {"Entries": _BATCH_ENTRIES}, { "Successful": [ { "Id": "1", - "MessageId": "5fea7756", + "MessageId": "message-1", + "MD5OfMessageBody": "49f68a5c8493ec2c0bf489821c21fc3b", + }, + { + "Id": "2", + "MessageId": "message-2", + "MD5OfMessageBody": "49f68a5c8493ec2c0bf489821c21fc3b", + }, + ], + "Failed": [], + }, + 2, + None, + None, + id="batch", + ), + pytest.param( + "send_message_batch", + {"Entries": _BATCH_ENTRIES}, + { + "Successful": [ + { + "Id": "1", + "MessageId": "message-1", "MD5OfMessageBody": "49f68a5c8493ec2c0bf489821c21fc3b", } ], - "Failed": failed, + "Failed": [{"Id": "2", "Code": "InternalError", "SenderFault": False}], }, - ) + 2, + None, + "InternalError", + id="batch-shared-error-code", + ), + pytest.param( + "send_message_batch", + {"Entries": _BATCH_ENTRIES}, + { + "Successful": [], + "Failed": [ + {"Id": "1", "Code": "InternalError", "SenderFault": False}, + {"Id": "2", "Code": "InvalidParameterValue", "SenderFault": True}, + ], + }, + 2, + None, + "aws_batch_partial_failure", + id="batch-mixed-error-codes", + ), + ], +) +def test_send_message( + client_factory, + capture_items, + method, + params, + response, + message_count, + message_id, + error_type, +): + client = client_factory("sqs") + + with Stubber(client) as stubber: + stubber.add_response(method, response) spans = capture_spans_by_op( - lambda: client.send_message_batch( + lambda: getattr(client, method)( QueueUrl="https://sqs.eu-north-1.amazonaws.com/123456789012/orders", - Entries=[ - {"Id": "1", "MessageBody": "hi"}, - {"Id": "2", "MessageBody": "hi"}, - ], + **params, ), capture_items, ) @@ -153,16 +181,23 @@ def test_send_message_batch(client_factory, capture_items, failed, error_type): assert span["status"] == ("ok" if error_type is None else "error") attributes = span["attributes"] assert attributes[SPANDATA.SENTRY_KIND] == "producer" - assert attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] == 2 + assert attributes[SPANDATA.MESSAGING_OPERATION_TYPE] == "send" + assert attributes[SPANDATA.MESSAGING_OPERATION_NAME] == "send" + assert attributes.get(SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT) == message_count + assert attributes.get(SPANDATA.MESSAGING_MESSAGE_ID) == message_id assert attributes.get(SPANDATA.ERROR_TYPE) == error_type - assert SPANDATA.MESSAGING_MESSAGE_ID not in attributes @pytest.mark.parametrize( - "response,message_count", + "response,message_count,message_id", [ - # `Messages` is absent when the queue is empty. - pytest.param({}, 0, id="empty"), + pytest.param({}, 0, None, id="empty"), + pytest.param( + {"Messages": [{"MessageId": "1", "ReceiptHandle": "receipt-handle-1"}]}, + 1, + "1", + id="single-message", + ), pytest.param( { "Messages": [ @@ -171,11 +206,14 @@ def test_send_message_batch(client_factory, capture_items, failed, error_type): ] }, 2, + None, id="messages", ), ], ) -def test_receive_message(client_factory, capture_items, response, message_count): +def test_receive_message( + client_factory, capture_items, response, message_count, message_id +): client = client_factory("sqs") with Stubber(client) as stubber: @@ -192,11 +230,10 @@ def test_receive_message(client_factory, capture_items, response, message_count) assert span["name"] == "receive orders" attributes = span["attributes"] assert attributes[SPANDATA.SENTRY_KIND] == "client" - assert attributes[SPANDATA.MESSAGING_SYSTEM] == "aws_sqs" assert attributes[SPANDATA.MESSAGING_OPERATION_TYPE] == "receive" assert attributes[SPANDATA.MESSAGING_OPERATION_NAME] == "receive" assert attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] == message_count - assert SPANDATA.MESSAGING_MESSAGE_ID not in attributes + assert attributes.get(SPANDATA.MESSAGING_MESSAGE_ID) == message_id assert "receipt-handle" not in str(span) @@ -219,7 +256,6 @@ def call(): spans = capture_spans_by_op(call, capture_items) (span,) = spans[OP.QUEUE_RECEIVE] - assert span["status"] == "error" assert SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT not in span["attributes"] From 053099c9578f0ffe273ad6aa502fb8d7a33dad2a Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Mon, 26 Oct 2026 18:31:44 +0100 Subject: [PATCH 03/14] . --- sentry_sdk/consts.py | 5 + .../integrations/boto3/_services/sns.py | 81 +++++- tests/integrations/boto3/test_sns.py | 242 +++++++++++++++++- 3 files changed, 310 insertions(+), 18 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 8cda281a25..a98ec7b01e 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -476,6 +476,11 @@ class SPANDATA: The URL of the AWS SQS queue the request refers to. Example: "https://sqs.us-east-1.amazonaws.com/123456789012/orders" """ + AWS_SNS_TOPIC_ARN = "aws.sns.topic.arn" + """ + The ARN of the AWS SNS topic the request refers to. + Example: "arn:aws:sns:us-east-1:123456789012:alerts" + """ CACHE_HIT = "cache.hit" """ diff --git a/sentry_sdk/integrations/boto3/_services/sns.py b/sentry_sdk/integrations/boto3/_services/sns.py index 4c72d64a2d..4cc55ba13a 100644 --- a/sentry_sdk/integrations/boto3/_services/sns.py +++ b/sentry_sdk/integrations/boto3/_services/sns.py @@ -1,28 +1,85 @@ from typing import TYPE_CHECKING +import sentry_sdk +from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3._services.base import _ServiceExtension from sentry_sdk.integrations.boto3._utils import _get_aws_arn_attributes -from sentry_sdk.utils import capture_internal_exceptions if TYPE_CHECKING: + from typing import Any, Optional + from sentry_sdk._types import Attributes from sentry_sdk.integrations.boto3._context import AwsCallContext +_PUBLISH_OPERATIONS = ("Publish", "PublishBatch") + class _SNSExtension(_ServiceExtension): + def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]": + if ctx.operation_name in _PUBLISH_OPERATIONS: + return OP.QUEUE_PUBLISH + return None + + def get_span_name( + self, ctx: "AwsCallContext", attributes: "Attributes" + ) -> "Optional[str]": + if ctx.operation_name not in _PUBLISH_OPERATIONS: + return None + destination = attributes.get(SPANDATA.MESSAGING_DESTINATION_NAME) + if destination is None and SPANDATA.SERVER_ADDRESS in attributes: + destination = f"{attributes[SPANDATA.SERVER_ADDRESS]}:{attributes[SPANDATA.SERVER_PORT]}" + return f"send {destination}" if destination else "send" + def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": attributes: "Attributes" = {} - with capture_internal_exceptions(): + # `ResourceArn` (tagging, data protection policies) is always a topic. + topic_arn = ctx.params.get("TopicArn") or ctx.params.get("ResourceArn") + target_arn = ctx.params.get("TargetArn") + # a `TargetArn` is a topic or a platform endpoint, i.e. a single device. + endpoint_arn = target_arn if target_arn and ":endpoint/" in target_arn else None + topic_arn = topic_arn or (target_arn if endpoint_arn is None else None) + + if topic_arn: + attributes[SPANDATA.AWS_SNS_TOPIC_ARN] = topic_arn + attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = topic_arn.split(":")[-1] + elif ctx.operation_name == "CreateTopic": + attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = ctx.params["Name"] + + if ctx.operation_name in _PUBLISH_OPERATIONS: attributes.update( - _get_aws_arn_attributes( - ( - ctx.params.get("EndpointArn"), - ctx.params.get("PlatformApplicationArn"), - ctx.params.get("SubscriptionArn"), - ctx.params.get("TargetArn"), - ctx.params.get("TopicArn"), - ), - ("sns",), - ) + { + SPANDATA.SENTRY_KIND: "producer", + SPANDATA.MESSAGING_SYSTEM: "aws.sns", + SPANDATA.MESSAGING_OPERATION_TYPE: "send", + SPANDATA.MESSAGING_OPERATION_NAME: "publish", + } ) + # phone numbers and device endpoints are personal destinations. + if "PhoneNumber" in ctx.params or endpoint_arn: + attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = "[Filtered]" + if ctx.operation_name == "PublishBatch": + attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] = len( + ctx.params["PublishBatchRequestEntries"] + ) + + # endpoint and subscription ARNs identify a single device or subscriber. + arns = [ctx.params.get("PlatformApplicationArn"), topic_arn] + if sentry_sdk.get_client().options["data_collection"]["user_info"]: + arns += [ + ctx.params.get("EndpointArn"), + ctx.params.get("SubscriptionArn"), + endpoint_arn, + ] + attributes.update(_get_aws_arn_attributes(arns, ("sns",))) + return attributes + + def get_response_attributes( + self, ctx: "AwsCallContext", response: "Any" + ) -> "Attributes": + attributes: "Attributes" = {} + if ctx.operation_name == "Publish" and "MessageId" in response: + attributes[SPANDATA.MESSAGING_MESSAGE_ID] = response["MessageId"] + # failed entries come back in a `200 OK` response. + if response.get("Failed"): + attributes[SPANDATA.ERROR_TYPE] = "aws_batch_partial_failure" return attributes diff --git a/tests/integrations/boto3/test_sns.py b/tests/integrations/boto3/test_sns.py index 7f593d357e..f512122167 100644 --- a/tests/integrations/boto3/test_sns.py +++ b/tests/integrations/boto3/test_sns.py @@ -1,21 +1,251 @@ +import pytest +from botocore.exceptions import ClientError from botocore.stub import Stubber from sentry_sdk.consts import OP, SPANDATA +from sentry_sdk.integrations.boto3 import Boto3Integration from tests.integrations.boto3.helpers import capture_spans_by_op from tests.integrations.boto3.helpers import client_factory as client_factory +from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES -def test_request_attributes(client_factory, capture_items): +@pytest.mark.parametrize( + "method,params,response,topic_arn", + [ + pytest.param( + "delete_topic", + {"TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts"}, + {}, + "arn:aws:sns:eu-north-1:123456789012:alerts", + id="topic-arn", + ), + pytest.param( + "tag_resource", + { + "ResourceArn": "arn:aws:sns:eu-north-1:123456789012:alerts", + "Tags": [{"Key": "team", "Value": "ops"}], + }, + {}, + "arn:aws:sns:eu-north-1:123456789012:alerts", + id="resource-arn", + ), + pytest.param( + "create_topic", + {"Name": "alerts"}, + {"TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts"}, + None, + id="create-topic", + ), + ], +) +def test_request_attributes( + client_factory, capture_items, method, params, response, topic_arn +): client = client_factory("sns") - topic_arn = "arn:aws:sns:eu-north-1:123456789012:orders" with Stubber(client) as stubber: - stubber.add_response("delete_topic", {}, {"TopicArn": topic_arn}) + stubber.add_response(method, response, params) spans = capture_spans_by_op( - lambda: client.delete_topic(**{"TopicArn": topic_arn}), capture_items + lambda: getattr(client, method)(**params), capture_items ) (span,) = spans[OP.RPC] attributes = span["attributes"] - assert attributes[SPANDATA.CLOUD_ACCOUNT_ID] == "123456789012" - assert attributes[SPANDATA.CLOUD_RESOURCE_ID] == topic_arn + assert attributes[SPANDATA.MESSAGING_DESTINATION_NAME] == "alerts" + assert attributes.get(SPANDATA.AWS_SNS_TOPIC_ARN) == topic_arn + assert attributes.get(SPANDATA.CLOUD_RESOURCE_ID) == topic_arn + if topic_arn is not None: + assert attributes[SPANDATA.CLOUD_ACCOUNT_ID] == "123456789012" + + +@pytest.mark.parametrize( + "params,destination,topic_arn", + [ + pytest.param( + {"TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts"}, + "alerts", + "arn:aws:sns:eu-north-1:123456789012:alerts", + id="topic", + ), + pytest.param( + {"TargetArn": "arn:aws:sns:eu-north-1:123456789012:alerts"}, + "alerts", + "arn:aws:sns:eu-north-1:123456789012:alerts", + id="target-topic", + ), + pytest.param( + {"PhoneNumber": "+15555550123"}, "[Filtered]", None, id="phone-number" + ), + pytest.param( + { + "TargetArn": "arn:aws:sns:eu-north-1:123456789012:endpoint/GCM/shop/5f1c8a3e-0b7d-3c55-9b5e-1a2b3c4d5e6f" + }, + "[Filtered]", + None, + id="device-endpoint", + ), + ], +) +def test_publish(client_factory, capture_items, params, destination, topic_arn): + client = client_factory("sns") + + with Stubber(client) as stubber: + stubber.add_response( + "publish", {"MessageId": "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b"} + ) + spans = capture_spans_by_op( + lambda: client.publish(Message="hi", **params), capture_items + ) + + (span,) = spans[OP.QUEUE_PUBLISH] + assert span["name"] == f"send {destination}" + attributes = span["attributes"] + assert attributes[SPANDATA.SENTRY_KIND] == "producer" + assert attributes[SPANDATA.MESSAGING_SYSTEM] == "aws.sns" + assert attributes[SPANDATA.MESSAGING_OPERATION_TYPE] == "send" + assert attributes[SPANDATA.MESSAGING_OPERATION_NAME] == "publish" + assert attributes[SPANDATA.MESSAGING_DESTINATION_NAME] == destination + assert attributes.get(SPANDATA.AWS_SNS_TOPIC_ARN) == topic_arn + assert ( + attributes[SPANDATA.MESSAGING_MESSAGE_ID] + == "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b" + ) + + +def test_publish_without_destination(client_factory, capture_items): + client = client_factory("sns") + + with Stubber(client) as stubber: + stubber.add_client_error( + "publish", service_error_code="InvalidParameter", http_status_code=400 + ) + + def call(): + with pytest.raises(ClientError): + client.publish(Message="hi") + + spans = capture_spans_by_op(call, capture_items) + + (span,) = spans[OP.QUEUE_PUBLISH] + assert span["name"] == "send sns.eu-north-1.amazonaws.com:443" + assert span["status"] == "error" + assert SPANDATA.MESSAGING_DESTINATION_NAME not in span["attributes"] + + +@pytest.mark.parametrize( + "failed,error_type", + [ + pytest.param([], None, id="ok"), + pytest.param( + [{"Id": "2", "Code": "InternalError", "SenderFault": False}], + "aws_batch_partial_failure", + id="partial-failure", + ), + ], +) +def test_publish_batch(client_factory, capture_items, failed, error_type): + client = client_factory("sns") + + with Stubber(client) as stubber: + stubber.add_response( + "publish_batch", + { + "Successful": [{"Id": "1", "MessageId": "4c1f2a7e"}], + "Failed": failed, + }, + ) + spans = capture_spans_by_op( + lambda: client.publish_batch( + TopicArn="arn:aws:sns:eu-north-1:123456789012:alerts", + PublishBatchRequestEntries=[ + {"Id": "1", "Message": "hi"}, + {"Id": "2", "Message": "hi"}, + ], + ), + capture_items, + ) + + (span,) = spans[OP.QUEUE_PUBLISH] + assert span["name"] == "send alerts" + assert span["status"] == ("ok" if error_type is None else "error") + attributes = span["attributes"] + assert attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] == 2 + assert attributes.get(SPANDATA.ERROR_TYPE) == error_type + assert SPANDATA.MESSAGING_MESSAGE_ID not in attributes + + +@pytest.mark.parametrize( + "method,params,response,gated", + [ + # a platform endpoint is a single device. + pytest.param( + "get_endpoint_attributes", + { + "EndpointArn": "arn:aws:sns:eu-north-1:123456789012:endpoint/GCM/shop/5f1c8a3e-0b7d-3c55-9b5e-1a2b3c4d5e6f" + }, + {"Attributes": {}}, + True, + id="endpoint", + ), + # a subscription is a single subscriber. + pytest.param( + "unsubscribe", + { + "SubscriptionArn": "arn:aws:sns:eu-north-1:123456789012:alerts:8e2a6b1c-4d3f-4f6a-9c1e-2b3d4e5f6a7b" + }, + {}, + True, + id="subscription", + ), + pytest.param( + "publish", + { + "TargetArn": "arn:aws:sns:eu-north-1:123456789012:endpoint/GCM/shop/5f1c8a3e-0b7d-3c55-9b5e-1a2b3c4d5e6f", + "Message": "hi", + }, + {"MessageId": "4c1f2a7e"}, + True, + id="device-endpoint", + ), + pytest.param( + "publish", + {"TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts", "Message": "hi"}, + {"MessageId": "4c1f2a7e"}, + False, + id="topic", + ), + ], +) +@pytest.mark.parametrize( + "data_collection,expect_user_info", DATA_COLLECTION_USER_INFO_CASES +) +def test_resource_id_user_info( + sentry_init, + client_factory, + capture_items, + method, + params, + response, + gated, + data_collection, + expect_user_info, +): + client = client_factory("sns") + sentry_init( + traces_sample_rate=1.0, + integrations=[Boto3Integration()], + data_collection=data_collection, + ) + (arn,) = (value for key, value in params.items() if key.endswith("Arn")) + + with Stubber(client) as stubber: + stubber.add_response(method, response) + spans = capture_spans_by_op( + lambda: getattr(client, method)(**params), capture_items + ) + + ((span,),) = spans.values() + if expect_user_info or not gated: + assert span["attributes"][SPANDATA.CLOUD_RESOURCE_ID] == arn + else: + assert SPANDATA.CLOUD_RESOURCE_ID not in span["attributes"] From c73d9ba872907ab99c77eb7206f968b61f9220e6 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 28 Oct 2026 12:56:06 +0100 Subject: [PATCH 04/14] fix(boto3): Add `capture_internal_exceptions()` guard to `get_span_name()` --- sentry_sdk/integrations/boto3/_instrumentation.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 9be195bd95..8b7e34da87 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -180,9 +180,10 @@ def _start_client_span( with capture_internal_exceptions(): attributes.update(service_ext.get_request_attributes(ctx)) - service_span_name = service_ext.get_span_name(ctx, attributes) - if service_span_name is not None: - span_name = service_span_name + with capture_internal_exceptions(): + service_span_name = service_ext.get_span_name(ctx, attributes) + if service_span_name is not None: + span_name = service_span_name # `start_span()` evaluates `ignore_spans` against the initial attributes. # https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span From 61994bf639603921585f14f8a313facf4b0493aa Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 28 Oct 2026 13:00:09 +0100 Subject: [PATCH 05/14] fix(boto3): Simplify error type handling in batch operations --- sentry_sdk/integrations/boto3/_services/sqs.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/sqs.py b/sentry_sdk/integrations/boto3/_services/sqs.py index 1be4fc1483..274f6d21dc 100644 --- a/sentry_sdk/integrations/boto3/_services/sqs.py +++ b/sentry_sdk/integrations/boto3/_services/sqs.py @@ -102,12 +102,10 @@ def get_response_attributes( attributes[SPANDATA.MESSAGING_MESSAGE_ID] = messages[0]["MessageId"] # failed entries have a `200 OK` response; use their code when they share one. - # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_BatchResultErrorEntry.html + # https://docs.aws.amazon.com/sns/latest/api/API_BatchResultErrorEntry.html error_codes = {entry["Code"] for entry in response.get("Failed", ())} - if error_codes: - attributes[SPANDATA.ERROR_TYPE] = ( - error_codes.pop() - if len(error_codes) == 1 - else "aws_batch_partial_failure" - ) + if len(error_codes) == 1: + attributes[SPANDATA.ERROR_TYPE] = error_codes.pop() + elif error_codes: + attributes[SPANDATA.ERROR_TYPE] = "aws_batch_partial_failure" return attributes From 6ccbcd846f6572bab0c4d37127b62836fbeb4bee Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 28 Oct 2026 13:20:15 +0100 Subject: [PATCH 06/14] wip --- .../integrations/boto3/_services/sns.py | 65 ++++-- tests/integrations/boto3/test_sns.py | 197 +++++++++++------- 2 files changed, 173 insertions(+), 89 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/sns.py b/sentry_sdk/integrations/boto3/_services/sns.py index 4cc55ba13a..b302d2693a 100644 --- a/sentry_sdk/integrations/boto3/_services/sns.py +++ b/sentry_sdk/integrations/boto3/_services/sns.py @@ -18,6 +18,7 @@ class _SNSExtension(_ServiceExtension): def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]": if ctx.operation_name in _PUBLISH_OPERATIONS: return OP.QUEUE_PUBLISH + # defaults to `rpc` for other operations. return None def get_span_name( @@ -25,22 +26,33 @@ def get_span_name( ) -> "Optional[str]": if ctx.operation_name not in _PUBLISH_OPERATIONS: return None + destination = attributes.get(SPANDATA.MESSAGING_DESTINATION_NAME) if destination is None and SPANDATA.SERVER_ADDRESS in attributes: destination = f"{attributes[SPANDATA.SERVER_ADDRESS]}:{attributes[SPANDATA.SERVER_PORT]}" + + # for example: `send ` when `messaging.operation.type=send` (always send). + # https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/#span-name return f"send {destination}" if destination else "send" def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": attributes: "Attributes" = {} - # `ResourceArn` (tagging, data protection policies) is always a topic. - topic_arn = ctx.params.get("TopicArn") or ctx.params.get("ResourceArn") + + # `TargetArn` can be either a topic or a platform endpoint; a topic ARN should + # never contain ":endpoint/". target_arn = ctx.params.get("TargetArn") - # a `TargetArn` is a topic or a platform endpoint, i.e. a single device. - endpoint_arn = target_arn if target_arn and ":endpoint/" in target_arn else None - topic_arn = topic_arn or (target_arn if endpoint_arn is None else None) + endpoint_arn = ctx.params.get("EndpointArn") + if target_arn and ":endpoint/" in target_arn: + endpoint_arn, target_arn = target_arn, None + + # `TopicArn` and `ResourceArn` are always topic ARNs. + topic_arn = ( + ctx.params.get("TopicArn") or ctx.params.get("ResourceArn") or target_arn + ) if topic_arn: attributes[SPANDATA.AWS_SNS_TOPIC_ARN] = topic_arn + # `arn:aws:sns:eu-north-1:123456789012:alerts` -> `alerts` attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = topic_arn.split(":")[-1] elif ctx.operation_name == "CreateTopic": attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = ctx.params["Name"] @@ -54,7 +66,8 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": SPANDATA.MESSAGING_OPERATION_NAME: "publish", } ) - # phone numbers and device endpoints are personal destinations. + + # phone numbers and device endpoints are PII. if "PhoneNumber" in ctx.params or endpoint_arn: attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = "[Filtered]" if ctx.operation_name == "PublishBatch": @@ -62,14 +75,22 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": ctx.params["PublishBatchRequestEntries"] ) - # endpoint and subscription ARNs identify a single device or subscriber. - arns = [ctx.params.get("PlatformApplicationArn"), topic_arn] - if sentry_sdk.get_client().options["data_collection"]["user_info"]: - arns += [ - ctx.params.get("EndpointArn"), - ctx.params.get("SubscriptionArn"), - endpoint_arn, - ] + # endpoints and subscriptions identify a single device or subscriber. + subscription_arn = ctx.params.get("SubscriptionArn") + if not sentry_sdk.get_client().options["data_collection"]["user_info"]: + if endpoint_arn: + endpoint_arn = endpoint_arn.rsplit("/", 1)[0].replace( + ":endpoint/", ":app/", 1 + ) + if subscription_arn: + subscription_arn = subscription_arn.rsplit(":", 1)[0] + + arns = ( + ctx.params.get("PlatformApplicationArn"), + topic_arn, + endpoint_arn, + subscription_arn, + ) attributes.update(_get_aws_arn_attributes(arns, ("sns",))) return attributes @@ -77,9 +98,19 @@ def get_response_attributes( self, ctx: "AwsCallContext", response: "Any" ) -> "Attributes": attributes: "Attributes" = {} - if ctx.operation_name == "Publish" and "MessageId" in response: + + # `ClientError` response holds no results. + if "Error" in response: + return attributes + + if ctx.operation_name == "Publish": attributes[SPANDATA.MESSAGING_MESSAGE_ID] = response["MessageId"] - # failed entries come back in a `200 OK` response. - if response.get("Failed"): + + # failed entries have a `200 OK` response; use their code when they share one. + # https://docs.aws.amazon.com/sns/latest/api/API_BatchResultErrorEntry.html + error_codes = {entry["Code"] for entry in response.get("Failed", ())} + if len(error_codes) == 1: + attributes[SPANDATA.ERROR_TYPE] = error_codes.pop() + elif error_codes: attributes[SPANDATA.ERROR_TYPE] = "aws_batch_partial_failure" return attributes diff --git a/tests/integrations/boto3/test_sns.py b/tests/integrations/boto3/test_sns.py index f512122167..22837c28cb 100644 --- a/tests/integrations/boto3/test_sns.py +++ b/tests/integrations/boto3/test_sns.py @@ -8,6 +8,11 @@ from tests.integrations.boto3.helpers import client_factory as client_factory from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES +_BATCH_ENTRIES = [ + {"Id": "1", "Message": "hi"}, + {"Id": "2", "Message": "hi"}, +] + @pytest.mark.parametrize( "method,params,response,topic_arn", @@ -56,60 +61,152 @@ def test_request_attributes( assert attributes.get(SPANDATA.CLOUD_RESOURCE_ID) == topic_arn if topic_arn is not None: assert attributes[SPANDATA.CLOUD_ACCOUNT_ID] == "123456789012" + assert SPANDATA.MESSAGING_SYSTEM not in attributes @pytest.mark.parametrize( - "params,destination,topic_arn", + "method,params,response,destination,topic_arn,message_count,message_id,error_type", [ pytest.param( - {"TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts"}, + "publish", + {"TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts", "Message": "hi"}, + {"MessageId": "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b"}, "alerts", "arn:aws:sns:eu-north-1:123456789012:alerts", + None, + "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b", + None, id="topic", ), pytest.param( - {"TargetArn": "arn:aws:sns:eu-north-1:123456789012:alerts"}, + "publish", + { + "TargetArn": "arn:aws:sns:eu-north-1:123456789012:alerts", + "Message": "hi", + }, + {"MessageId": "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b"}, "alerts", "arn:aws:sns:eu-north-1:123456789012:alerts", + None, + "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b", + None, id="target-topic", ), pytest.param( - {"PhoneNumber": "+15555550123"}, "[Filtered]", None, id="phone-number" + "publish", + {"PhoneNumber": "+15555550123", "Message": "hi"}, + {"MessageId": "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b"}, + "[Filtered]", + None, + None, + "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b", + None, + id="phone-number", ), pytest.param( + "publish", { - "TargetArn": "arn:aws:sns:eu-north-1:123456789012:endpoint/GCM/shop/5f1c8a3e-0b7d-3c55-9b5e-1a2b3c4d5e6f" + "TargetArn": "arn:aws:sns:eu-north-1:123456789012:endpoint/GCM/shop/5f1c8a3e-0b7d-3c55-9b5e-1a2b3c4d5e6f", + "Message": "hi", }, + {"MessageId": "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b"}, "[Filtered]", None, + None, + "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b", + None, id="device-endpoint", ), + pytest.param( + "publish_batch", + { + "TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts", + "PublishBatchRequestEntries": _BATCH_ENTRIES, + }, + { + "Successful": [ + {"Id": "1", "MessageId": "message-1"}, + {"Id": "2", "MessageId": "message-2"}, + ], + "Failed": [], + }, + "alerts", + "arn:aws:sns:eu-north-1:123456789012:alerts", + 2, + None, + None, + id="batch", + ), + pytest.param( + "publish_batch", + { + "TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts", + "PublishBatchRequestEntries": _BATCH_ENTRIES, + }, + { + "Successful": [{"Id": "1", "MessageId": "message-1"}], + "Failed": [{"Id": "2", "Code": "InternalError", "SenderFault": False}], + }, + "alerts", + "arn:aws:sns:eu-north-1:123456789012:alerts", + 2, + None, + "InternalError", + id="batch-shared-error-code", + ), + pytest.param( + "publish_batch", + { + "TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts", + "PublishBatchRequestEntries": _BATCH_ENTRIES, + }, + { + "Successful": [], + "Failed": [ + {"Id": "1", "Code": "InternalError", "SenderFault": False}, + {"Id": "2", "Code": "EndpointDisabled", "SenderFault": True}, + ], + }, + "alerts", + "arn:aws:sns:eu-north-1:123456789012:alerts", + 2, + None, + "aws_batch_partial_failure", + id="batch-mixed-error-codes", + ), ], ) -def test_publish(client_factory, capture_items, params, destination, topic_arn): +def test_publish( + client_factory, + capture_items, + method, + params, + response, + destination, + topic_arn, + message_count, + message_id, + error_type, +): client = client_factory("sns") with Stubber(client) as stubber: - stubber.add_response( - "publish", {"MessageId": "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b"} - ) + stubber.add_response(method, response) spans = capture_spans_by_op( - lambda: client.publish(Message="hi", **params), capture_items + lambda: getattr(client, method)(**params), capture_items ) (span,) = spans[OP.QUEUE_PUBLISH] assert span["name"] == f"send {destination}" + assert span["status"] == ("ok" if error_type is None else "error") attributes = span["attributes"] assert attributes[SPANDATA.SENTRY_KIND] == "producer" - assert attributes[SPANDATA.MESSAGING_SYSTEM] == "aws.sns" assert attributes[SPANDATA.MESSAGING_OPERATION_TYPE] == "send" assert attributes[SPANDATA.MESSAGING_OPERATION_NAME] == "publish" - assert attributes[SPANDATA.MESSAGING_DESTINATION_NAME] == destination assert attributes.get(SPANDATA.AWS_SNS_TOPIC_ARN) == topic_arn - assert ( - attributes[SPANDATA.MESSAGING_MESSAGE_ID] - == "4c1f2a7e-9b3d-5e6f-8a1b-2c3d4e5f6a7b" - ) + assert attributes.get(SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT) == message_count + assert attributes.get(SPANDATA.MESSAGING_MESSAGE_ID) == message_id + assert attributes.get(SPANDATA.ERROR_TYPE) == error_type def test_publish_without_destination(client_factory, capture_items): @@ -128,73 +225,28 @@ def call(): (span,) = spans[OP.QUEUE_PUBLISH] assert span["name"] == "send sns.eu-north-1.amazonaws.com:443" - assert span["status"] == "error" assert SPANDATA.MESSAGING_DESTINATION_NAME not in span["attributes"] @pytest.mark.parametrize( - "failed,error_type", - [ - pytest.param([], None, id="ok"), - pytest.param( - [{"Id": "2", "Code": "InternalError", "SenderFault": False}], - "aws_batch_partial_failure", - id="partial-failure", - ), - ], -) -def test_publish_batch(client_factory, capture_items, failed, error_type): - client = client_factory("sns") - - with Stubber(client) as stubber: - stubber.add_response( - "publish_batch", - { - "Successful": [{"Id": "1", "MessageId": "4c1f2a7e"}], - "Failed": failed, - }, - ) - spans = capture_spans_by_op( - lambda: client.publish_batch( - TopicArn="arn:aws:sns:eu-north-1:123456789012:alerts", - PublishBatchRequestEntries=[ - {"Id": "1", "Message": "hi"}, - {"Id": "2", "Message": "hi"}, - ], - ), - capture_items, - ) - - (span,) = spans[OP.QUEUE_PUBLISH] - assert span["name"] == "send alerts" - assert span["status"] == ("ok" if error_type is None else "error") - attributes = span["attributes"] - assert attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] == 2 - assert attributes.get(SPANDATA.ERROR_TYPE) == error_type - assert SPANDATA.MESSAGING_MESSAGE_ID not in attributes - - -@pytest.mark.parametrize( - "method,params,response,gated", + "method,params,response,filtered_arn", [ - # a platform endpoint is a single device. pytest.param( "get_endpoint_attributes", { "EndpointArn": "arn:aws:sns:eu-north-1:123456789012:endpoint/GCM/shop/5f1c8a3e-0b7d-3c55-9b5e-1a2b3c4d5e6f" }, {"Attributes": {}}, - True, + "arn:aws:sns:eu-north-1:123456789012:app/GCM/shop", id="endpoint", ), - # a subscription is a single subscriber. pytest.param( "unsubscribe", { "SubscriptionArn": "arn:aws:sns:eu-north-1:123456789012:alerts:8e2a6b1c-4d3f-4f6a-9c1e-2b3d4e5f6a7b" }, {}, - True, + "arn:aws:sns:eu-north-1:123456789012:alerts", id="subscription", ), pytest.param( @@ -204,14 +256,14 @@ def test_publish_batch(client_factory, capture_items, failed, error_type): "Message": "hi", }, {"MessageId": "4c1f2a7e"}, - True, + "arn:aws:sns:eu-north-1:123456789012:app/GCM/shop", id="device-endpoint", ), pytest.param( "publish", {"TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts", "Message": "hi"}, {"MessageId": "4c1f2a7e"}, - False, + "arn:aws:sns:eu-north-1:123456789012:alerts", id="topic", ), ], @@ -219,14 +271,14 @@ def test_publish_batch(client_factory, capture_items, failed, error_type): @pytest.mark.parametrize( "data_collection,expect_user_info", DATA_COLLECTION_USER_INFO_CASES ) -def test_resource_id_user_info( +def test_resource_id_data_collection( sentry_init, client_factory, capture_items, method, params, response, - gated, + filtered_arn, data_collection, expect_user_info, ): @@ -245,7 +297,8 @@ def test_resource_id_user_info( ) ((span,),) = spans.values() - if expect_user_info or not gated: - assert span["attributes"][SPANDATA.CLOUD_RESOURCE_ID] == arn - else: - assert SPANDATA.CLOUD_RESOURCE_ID not in span["attributes"] + attributes = span["attributes"] + assert attributes[SPANDATA.CLOUD_RESOURCE_ID] == ( + arn if expect_user_info else filtered_arn + ) + assert attributes[SPANDATA.CLOUD_ACCOUNT_ID] == "123456789012" From 50a49d1655072f8a76fc7f53245220648fa4c6ac Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 28 Oct 2026 15:27:01 +0100 Subject: [PATCH 07/14] add "publish_batch" method requirement --- tests/integrations/boto3/test_sns.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/integrations/boto3/test_sns.py b/tests/integrations/boto3/test_sns.py index 22837c28cb..a04e30cd1d 100644 --- a/tests/integrations/boto3/test_sns.py +++ b/tests/integrations/boto3/test_sns.py @@ -4,7 +4,10 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3 import Boto3Integration -from tests.integrations.boto3.helpers import capture_spans_by_op +from tests.integrations.boto3.helpers import ( + capture_spans_by_op, + require_botocore_model_fields, +) from tests.integrations.boto3.helpers import client_factory as client_factory from tests.integrations.utils import DATA_COLLECTION_USER_INFO_CASES @@ -190,6 +193,11 @@ def test_publish( ): client = client_factory("sns") + require_botocore_model_fields( + client, + "publish_batch", + ) + with Stubber(client) as stubber: stubber.add_response(method, response) spans = capture_spans_by_op( From 4738824fba6517c91a69e4c7532c218303f645f3 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Wed, 28 Oct 2026 16:12:05 +0100 Subject: [PATCH 08/14] fix --- tests/integrations/boto3/test_sns.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/integrations/boto3/test_sns.py b/tests/integrations/boto3/test_sns.py index a04e30cd1d..1d123d2e6b 100644 --- a/tests/integrations/boto3/test_sns.py +++ b/tests/integrations/boto3/test_sns.py @@ -193,10 +193,7 @@ def test_publish( ): client = client_factory("sns") - require_botocore_model_fields( - client, - "publish_batch", - ) + require_botocore_model_fields(client, method) with Stubber(client) as stubber: stubber.add_response(method, response) From d81085973646a6700778338b003b0a8f2b3f2126 Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Thu, 29 Oct 2026 10:52:41 +0100 Subject: [PATCH 09/14] wip: check CI for different botocore versions --- sentry_sdk/consts.py | 6 + sentry_sdk/integrations/boto3/_client.py | 6 + .../integrations/boto3/_services/base.py | 20 ++- .../integrations/boto3/_services/sns.py | 18 ++- .../integrations/boto3/_services/sqs.py | 95 ++++++++++- sentry_sdk/integrations/boto3/_utils.py | 73 +++++++++ tests/integrations/boto3/test_sqs.py | 116 ++++++++++++++ .../boto3/test_trace_propagation.py | 149 ++++++++++++++++++ 8 files changed, 476 insertions(+), 7 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index a98ec7b01e..c176b0e5ad 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1393,6 +1393,12 @@ class SPANDATA: Example: "client", "server", "producer", "consumer", "internal" """ + SENTRY_LINK_TYPE = "sentry.link.type" + """ + Set on a span link. Describes the relationship between the span and the linked span. + Example: "message_origin" + """ + SENTRY_OP = "sentry.op" """ The operation of a span. diff --git a/sentry_sdk/integrations/boto3/_client.py b/sentry_sdk/integrations/boto3/_client.py index 35ea846e22..54bac75382 100644 --- a/sentry_sdk/integrations/boto3/_client.py +++ b/sentry_sdk/integrations/boto3/_client.py @@ -96,6 +96,10 @@ def sentry_patched_make_api_call( ctx.add_metadata(self) service_ext = _resolve_service(ctx.service_name) span = _start_client_span(ctx, service_ext) + if service_ext is not None: + with capture_internal_exceptions(): + # override the request params for e.g. trace propagation. + api_params = service_ext.get_request_params(ctx, span) or api_params if span is None: return orig_make_api_call(self, operation_name, api_params) @@ -122,6 +126,8 @@ def sentry_patched_make_api_call( attributes.update( service_ext.get_response_attributes(ctx, parsed) ) + with capture_internal_exceptions(): + span.add_links(service_ext.get_response_links(ctx, parsed)) with capture_internal_exceptions(): attributes.update(_get_response_attributes(parsed)) with capture_internal_exceptions(): diff --git a/sentry_sdk/integrations/boto3/_services/base.py b/sentry_sdk/integrations/boto3/_services/base.py index 397499bffb..6a81f8fdb3 100644 --- a/sentry_sdk/integrations/boto3/_services/base.py +++ b/sentry_sdk/integrations/boto3/_services/base.py @@ -1,10 +1,11 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from typing import Any, Optional + from typing import Any, Dict, List, Optional - from sentry_sdk._types import Attributes + from sentry_sdk._types import Attributes, SpanLink from sentry_sdk.integrations.boto3._context import AwsCallContext + from sentry_sdk.traces import Span class _ServiceExtension: @@ -36,3 +37,18 @@ def get_response_attributes( ) -> "Attributes": """Return response attributes to add after the AWS request is made.""" return {} + + def get_response_links( + self, ctx: "AwsCallContext", response: "Any" + ) -> "List[SpanLink]": + """Return links to add to the span after the AWS request is made.""" + return [] + + def get_request_params( + self, ctx: "AwsCallContext", span: "Optional[Span]" + ) -> "Optional[Dict[str, Any]]": + """ + Return params to send instead of `ctx.params`, e.g. with trace propagation added, + or `None` to send them unchanged; must not mutate `ctx.params`. + """ + return None diff --git a/sentry_sdk/integrations/boto3/_services/sns.py b/sentry_sdk/integrations/boto3/_services/sns.py index b302d2693a..146a425762 100644 --- a/sentry_sdk/integrations/boto3/_services/sns.py +++ b/sentry_sdk/integrations/boto3/_services/sns.py @@ -3,13 +3,17 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3._services.base import _ServiceExtension -from sentry_sdk.integrations.boto3._utils import _get_aws_arn_attributes +from sentry_sdk.integrations.boto3._utils import ( + _get_aws_arn_attributes, + _inject_message_attributes, +) if TYPE_CHECKING: - from typing import Any, Optional + from typing import Any, Dict, Optional from sentry_sdk._types import Attributes from sentry_sdk.integrations.boto3._context import AwsCallContext + from sentry_sdk.traces import Span _PUBLISH_OPERATIONS = ("Publish", "PublishBatch") @@ -114,3 +118,13 @@ def get_response_attributes( elif error_codes: attributes[SPANDATA.ERROR_TYPE] = "aws_batch_partial_failure" return attributes + + def get_request_params( + self, ctx: "AwsCallContext", span: "Optional[Span]" + ) -> "Optional[Dict[str, Any]]": + if ctx.operation_name not in _PUBLISH_OPERATIONS: + return None + + return _inject_message_attributes( + ctx, span, "Message", "PublishBatchRequestEntries" + ) diff --git a/sentry_sdk/integrations/boto3/_services/sqs.py b/sentry_sdk/integrations/boto3/_services/sqs.py index 1be4fc1483..0925350c21 100644 --- a/sentry_sdk/integrations/boto3/_services/sqs.py +++ b/sentry_sdk/integrations/boto3/_services/sqs.py @@ -1,16 +1,23 @@ +import json import re from typing import TYPE_CHECKING from urllib.parse import unquote, urlsplit from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3._services.base import _ServiceExtension -from sentry_sdk.integrations.boto3._utils import _get_aws_arn_attributes +from sentry_sdk.integrations.boto3._utils import ( + _get_aws_arn_attributes, + _inject_message_attributes, +) +from sentry_sdk.traces import BAGGAGE_HEADER_NAME, SENTRY_TRACE_HEADER_NAME, NoOpSpan +from sentry_sdk.tracing_utils import extract_sentrytrace_data if TYPE_CHECKING: - from typing import Any, Optional + from typing import Any, Dict, List, Optional, Tuple - from sentry_sdk._types import Attributes + from sentry_sdk._types import Attributes, SpanLink from sentry_sdk.integrations.boto3._context import AwsCallContext + from sentry_sdk.traces import Span # operation -> (`sentry.op`, `messaging.operation.type`) _MESSAGING_OPERATIONS = { @@ -22,6 +29,44 @@ } +def _message_link(message: "Dict[str, Any]") -> "Optional[SpanLink]": + # message was sent straight to SQS, or through SNS with raw message delivery on. + sentry_trace = ( + message.get("MessageAttributes", {}) + .get(SENTRY_TRACE_HEADER_NAME, {}) + .get("StringValue") + ) + + # SNS wraps the message and its attributes in a JSON envelope when raw message delivery is off. + # https://docs.aws.amazon.com/sns/latest/dg/sns-large-payload-raw-message-delivery.html + body = message.get("Body", "") + if sentry_trace is None and f'"{SENTRY_TRACE_HEADER_NAME}"' in body: + try: + envelope = json.loads(body) + except ValueError: + envelope = None + if isinstance(envelope, dict) and envelope.get("Type") == "Notification": + sentry_trace = ( + envelope.get("MessageAttributes", {}) + .get(SENTRY_TRACE_HEADER_NAME, {}) + .get("Value") + ) + + data = extract_sentrytrace_data(sentry_trace) + if data is None or not data["trace_id"] or not data["parent_span_id"]: + return None + + link: "SpanLink" = { + "trace_id": str(data["trace_id"]), + "span_id": str(data["parent_span_id"]), + # Sentry drops other link attributes for now; add `messaging.message.id` in the future. + "attributes": {SPANDATA.SENTRY_LINK_TYPE: "message_origin"}, + } + if data["parent_sampled"] is not None: + link["sampled"] = bool(data["parent_sampled"]) + return link + + class _SQSExtension(_ServiceExtension): def get_span_op(self, ctx: "AwsCallContext") -> "Optional[str]": if ctx.operation_name in _MESSAGING_OPERATIONS: @@ -111,3 +156,47 @@ def get_response_attributes( else "aws_batch_partial_failure" ) return attributes + + def get_response_links( + self, ctx: "AwsCallContext", response: "Any" + ) -> "List[SpanLink]": + if ctx.operation_name != "ReceiveMessage": + return [] + + # one link per producer context; batch entries of one send share it. + links: "Dict[Tuple[str, str], SpanLink]" = {} + for message in response.get("Messages", ()): + # returns a link to the producer context a received message carries, if valid. + link = _message_link(message) + if link is not None: + links.setdefault((link["trace_id"], link["span_id"]), link) + return list(links.values()) + + def get_request_params( + self, ctx: "AwsCallContext", span: "Optional[Span]" + ) -> "Optional[Dict[str, Any]]": + if ctx.operation_name in ("SendMessage", "SendMessageBatch"): + return _inject_message_attributes(ctx, span, "MessageBody", "Entries") + + # only request the carrier when the receive span can record links. + if ( + ctx.operation_name != "ReceiveMessage" + or span is None + or isinstance(span, NoOpSpan) + ): + return None + + # SQS only returns the message attributes named in `MessageAttributeNames`. + # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_ReceiveMessage.html + names = ctx.params.get("MessageAttributeNames", []) + missing = [ + name + for name in (SENTRY_TRACE_HEADER_NAME, BAGGAGE_HEADER_NAME) + if name not in names + ] + # `All` and `.*` already return every attribute, including ours. + if not missing or "All" in names or ".*" in names: + return None + + # append ours to the caller's names, in a copy of their params. + return {**ctx.params, "MessageAttributeNames": [*names, *missing]} diff --git a/sentry_sdk/integrations/boto3/_utils.py b/sentry_sdk/integrations/boto3/_utils.py index 9f124639a6..8271391a92 100644 --- a/sentry_sdk/integrations/boto3/_utils.py +++ b/sentry_sdk/integrations/boto3/_utils.py @@ -1,15 +1,28 @@ from typing import TYPE_CHECKING +import sentry_sdk from sentry_sdk.consts import SPANDATA +from sentry_sdk.tracing_utils import should_propagate_trace if TYPE_CHECKING: from typing import Any, Dict, Iterable, Optional, Sequence, Tuple from sentry_sdk._types import Attributes + from sentry_sdk.integrations.boto3._context import AwsCallContext + from sentry_sdk.traces import Span # tuple of (request param name, span attribute name). _AttributeSpec = Tuple[str, str] +# SQS and SNS reject a message with more than 10 message attributes. +# https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-message-metadata.html +_MAX_MESSAGE_ATTRIBUTES = 10 + +# body and attributes must fit the queue's or topic's configurable `MaximumMessageSize`; +# assume the 256 KiB default of SNS topics and older SQS queues; batch uses the same limit. +# https://docs.aws.amazon.com/sns/latest/api/API_PublishBatch.html +_MAX_MESSAGE_SIZE = 256 * 1024 + def _extract_attributes( source: "Dict[str, Any]", specs: "Sequence[_AttributeSpec]" @@ -38,3 +51,63 @@ def _get_aws_arn_attributes( return attributes return {} + + +def _inject_message_attributes( + ctx: "AwsCallContext", span: "Optional[Span]", body_key: str, entries_key: str +) -> "Optional[Dict[str, Any]]": + """ + Return a copy of `ctx.params` with `sentry-trace` and `baggage` added in + string message attributes, or to each batch entry with room for them. + """ + if ctx.endpoint_url is None or not should_propagate_trace( + sentry_sdk.get_client(), ctx.endpoint_url + ): + return None + + carrier = { + name: {"DataType": "String", "StringValue": value} + for name, value in sentry_sdk.get_current_scope().iter_trace_propagation_headers( + span=span + ) + } + if not carrier: + return None + + def _get_message_size(body: str, attributes: "Dict[str, Any]") -> int: + size = len(body.encode()) + + # names, types and values of message attributes count towards the message size. + # https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-message-metadata.html + for name, value in attributes.items(): + size += len(name.encode()) + len(value["DataType"].encode()) + if "StringValue" in value: + size += len(value["StringValue"].encode()) + if "BinaryValue" in value: + binary = value["BinaryValue"] + size += len(binary.encode() if isinstance(binary, str) else binary) + return size + + is_batch = entries_key in ctx.params + messages = [] + for message in ctx.params[entries_key] if is_batch else (ctx.params,): + attributes = message.get("MessageAttributes", {}) + injected = {**attributes, **carrier} + if ( + # never replace the caller's carrier. + not carrier.keys() & attributes.keys() + # don't make AWS reject the message. + and len(injected) <= _MAX_MESSAGE_ATTRIBUTES + and _get_message_size(message[body_key], injected) <= _MAX_MESSAGE_SIZE + ): + message = {**message, "MessageAttributes": injected} + messages.append(message) + + total_size = sum( + _get_message_size(message[body_key], message.get("MessageAttributes", {})) + for message in messages + ) + if total_size > _MAX_MESSAGE_SIZE: + return None + + return {**ctx.params, entries_key: messages} if is_batch else messages[0] diff --git a/tests/integrations/boto3/test_sqs.py b/tests/integrations/boto3/test_sqs.py index f28f8cb74b..f3455a2258 100644 --- a/tests/integrations/boto3/test_sqs.py +++ b/tests/integrations/boto3/test_sqs.py @@ -1,3 +1,6 @@ +import copy +import json + import pytest from botocore.exceptions import ClientError from botocore.stub import Stubber @@ -306,3 +309,116 @@ def test_delete_message( assert attributes[SPANDATA.MESSAGING_OPERATION_NAME] == "settle" assert attributes.get(SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT) == message_count assert "receipt-handle" not in str(span) + + +@pytest.mark.parametrize( + "names,sent_names", + [ + pytest.param(None, ["sentry-trace", "baggage"], id="default"), + pytest.param( + ["orderId", "baggage"], + ["orderId", "baggage", "sentry-trace"], + id="caller-names", + ), + pytest.param( + ["baggage", "sentry-trace"], + ["baggage", "sentry-trace"], + id="already-requested", + ), + pytest.param(["All"], ["All"], id="all"), + pytest.param([".*"], [".*"], id="all-pattern"), + ], +) +def test_receive_message_requests_carrier( + client_factory, capture_items, names, sent_names +): + client = client_factory("sqs") + queue_url = "https://sqs.eu-north-1.amazonaws.com/123456789012/orders" + params = {} if names is None else {"MessageAttributeNames": names} + caller_params = copy.deepcopy(params) + + with Stubber(client) as stubber: + stubber.add_response( + "receive_message", + {}, + {"QueueUrl": queue_url, "MessageAttributeNames": sent_names}, + ) + capture_spans_by_op( + lambda: client.receive_message(QueueUrl=queue_url, **params), + capture_items, + ) + + assert params == caller_params + + +def test_receive_message_links(client_factory, capture_items): + client = client_factory("sqs") + sentry_trace = { + "sentry-trace": { + "DataType": "String", + "StringValue": "771a43a4192642f0b136d5159a501700-a3e9e5ba6f99f4d6-1", + } + } + sns_envelope = { + "Type": "Notification", + "MessageId": "sns-message-3", + "TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts", + "Message": "hi", + "MessageAttributes": { + "sentry-trace": { + "Type": "String", + "Value": "b2c1b1d6cb5f4a0d8a6a2f1c3e9d7b40-9c4f3a2b1e0d8f7a", + } + }, + } + response = { + "Messages": [ + # batch entries of one send share a context. + {"MessageId": "1", "Body": "hi", "MessageAttributes": sentry_trace}, + {"MessageId": "2", "Body": "hi", "MessageAttributes": sentry_trace}, + # SNS-to-SQS delivery without raw message delivery. + {"MessageId": "3", "Body": json.dumps(sns_envelope)}, + # trace id with malformed sentry-trace. + { + "MessageId": "4", + "Body": "hi", + "MessageAttributes": { + "sentry-trace": { + "DataType": "String", + "StringValue": "771a43a4192642f0b136d5159a501700", + } + }, + }, + # JSON body that is not an SNS envelope. + {"MessageId": "5", "Body": '{"sentry-trace": "not an SNS envelope"}'}, + {"MessageId": "6", "Body": "hi"}, + ] + } + + with Stubber(client) as stubber: + stubber.add_response("receive_message", response) + spans = capture_spans_by_op( + lambda: client.receive_message( + QueueUrl="https://sqs.eu-north-1.amazonaws.com/123456789012/orders" + ), + capture_items, + ) + + (span,) = spans[OP.QUEUE_RECEIVE] + link_type = { + SPANDATA.SENTRY_LINK_TYPE: {"value": "message_origin", "type": "string"} + } + assert span["links"] == [ + # messages 1 and 2 share a context, so one link. + { + "trace_id": "771a43a4192642f0b136d5159a501700", + "span_id": "a3e9e5ba6f99f4d6", + "sampled": True, + "attributes": link_type, + }, + { + "trace_id": "b2c1b1d6cb5f4a0d8a6a2f1c3e9d7b40", + "span_id": "9c4f3a2b1e0d8f7a", + "attributes": link_type, + }, + ] diff --git a/tests/integrations/boto3/test_trace_propagation.py b/tests/integrations/boto3/test_trace_propagation.py index 41e3226215..6df105007b 100644 --- a/tests/integrations/boto3/test_trace_propagation.py +++ b/tests/integrations/boto3/test_trace_propagation.py @@ -1,18 +1,26 @@ +import copy from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Thread from urllib.parse import parse_qs, urlparse import boto3 +import pytest from botocore.auth import SIGNED_HEADERS_BLACKLIST from botocore.config import Config +from botocore.stub import Stubber import sentry_sdk +from sentry_sdk.consts import OP from sentry_sdk.integrations.boto3 import Boto3Integration from sentry_sdk.integrations.stdlib import StdlibIntegration from sentry_sdk.utils import ( _get_aws_sigv4_signed_headers_from_authorization_header, _get_aws_sigv4_signed_headers_from_url_query_string, ) +from tests.integrations.boto3.helpers import ( + capture_spans_by_op, + require_botocore_model_fields, +) # botocore 1.43.115+ never signs `baggage`. BOTOCORE_SIGNS_BAGGAGE = "baggage" not in SIGNED_HEADERS_BLACKLIST @@ -207,3 +215,144 @@ def test_presigned_urls_do_not_require_sentry_headers(sentry_init): # presigned URL query must not contain `sentry-trace` or `baggage`. assert "sentry-trace" not in url assert "baggage" not in url + + +def _send(sentry_init, capture_items, service, messages, **options): + """ + Send `messages`, i.e. `(body, message attributes)`, with the service's + (batch) API; return the publish span and the sent messages. + """ + sentry_init(traces_sample_rate=1.0, integrations=[Boto3Integration()], **options) + + client = boto3.client( # type: ignore[attr-defined] + service, + region_name="eu-north-1", + aws_access_key_id="-", + aws_secret_access_key="-", + ) + + # service -> (single method, batch method, destination, body key, entries key) + single, batch, destination, body_key, entries_key = { + "sqs": ( + "send_message", + "send_message_batch", + {"QueueUrl": "https://sqs.eu-north-1.amazonaws.com/123456789012/orders"}, + "MessageBody", + "Entries", + ), + "sns": ( + "publish", + "publish_batch", + {"TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts"}, + "Message", + "PublishBatchRequestEntries", + ), + }[service] + + entries = [ + {"Id": str(i), body_key: body, "MessageAttributes": attributes} + for i, (body, attributes) in enumerate(messages) + ] + if len(entries) == 1: + method, response = single, {"MessageId": "1"} + (params,) = entries + # single-method API uses `MessageId` instead of `Id`. + del params["Id"] + params.update(destination) + else: + method, response = batch, {"Successful": [], "Failed": []} + params = {**destination, entries_key: entries} + + require_botocore_model_fields(client, method) + + sent_params = [] + client.meta.events.register( + "before-parameter-build", lambda params, **kwargs: sent_params.append(params) + ) + caller_params = copy.deepcopy(params) + with Stubber(client) as stubber: + stubber.add_response(method, response) + spans = capture_spans_by_op( + lambda: getattr(client, method)(**params), capture_items + ) + + # the caller's params are left as-is. + assert params == caller_params + (sent,) = sent_params + (span,) = spans[OP.QUEUE_PUBLISH] + return span, sent.get(entries_key, [sent]) + + +@pytest.mark.parametrize("message_count", [1, 2], ids=["single", "batch"]) +@pytest.mark.parametrize("service", ["sqs", "sns"]) +def test_message_attributes_propagation( + sentry_init, capture_items, service, message_count +): + span, sent = _send( + sentry_init, + capture_items, + service, + [("hi", {"orderId": {"DataType": "String", "StringValue": "42"}})] + * message_count, + ) + + # every batch entry carries the context of the publish span. + assert len(sent) == message_count + for message in sent: + attributes = message["MessageAttributes"] + assert attributes.keys() == {"orderId", "sentry-trace", "baggage"} + assert attributes["sentry-trace"] == { + "DataType": "String", + "StringValue": f"{span['trace_id']}-{span['span_id']}-1", + } + assert attributes["baggage"]["DataType"] == "String" + assert ( + f"sentry-trace_id={span['trace_id']}" + in attributes["baggage"]["StringValue"] + ) + + +@pytest.mark.parametrize( + "messages,options", + [ + pytest.param( + [("hi", {"baggage": {"DataType": "String", "StringValue": "vendor=1"}})], + {}, + id="caller-carrier", + ), + pytest.param( + [ + ( + "hi", + { + f"attribute{i}": {"DataType": "String", "StringValue": "1"} + for i in range(9) + }, + ) + ], + {}, + id="attribute-limit", + ), + pytest.param([("x" * 1024 * 256, {})], {}, id="message-size-limit"), + # 2 * (131_000 + ~385) > 262_144 which exceeds the message size limit. + pytest.param( + [("x" * 131_000, {}), ("x" * 131_000, {})], + {}, + id="batch-size-limit", + ), + pytest.param( + [("hi", {})], + {"trace_propagation_targets": ["example.com"]}, + id="trace-propagation-targets", + ), + ], +) +@pytest.mark.parametrize("service", ["sqs", "sns"]) +def test_message_attributes_propagation_skipped( + sentry_init, capture_items, service, messages, options +): + _, sent = _send(sentry_init, capture_items, service, messages, **options) + + assert [message["MessageAttributes"] for message in sent] == [ + attributes for _, attributes in messages + ] From c640799da086e733083e4f808952eb51e95d0eef Mon Sep 17 00:00:00 2001 From: Pablo Deputter Date: Fri, 30 Oct 2026 13:54:10 +0100 Subject: [PATCH 10/14] fix(boto3): make sure span is sent - stop reading operation type from attributes in `get_span_name()` so that failure in `get_request_attributes()` can't make this fail as well - wrap URL parsing in `get_request_attributes()` so failure just loses the attributes that come from url --- .../integrations/boto3/_services/sqs.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/sqs.py b/sentry_sdk/integrations/boto3/_services/sqs.py index 274f6d21dc..7a1bce2955 100644 --- a/sentry_sdk/integrations/boto3/_services/sqs.py +++ b/sentry_sdk/integrations/boto3/_services/sqs.py @@ -5,6 +5,7 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3._services.base import _ServiceExtension from sentry_sdk.integrations.boto3._utils import _get_aws_arn_attributes +from sentry_sdk.utils import capture_internal_exceptions if TYPE_CHECKING: from typing import Any, Optional @@ -35,7 +36,13 @@ def get_span_name( return None # for example: `send ` when `messaging.operation.type=send`. # https://opentelemetry.io/docs/specs/semconv/messaging/messaging-spans/#span-name - return f"{attributes[SPANDATA.MESSAGING_OPERATION_TYPE]} {attributes[SPANDATA.MESSAGING_DESTINATION_NAME]}" + operation_type = _MESSAGING_OPERATIONS[ctx.operation_name][1] + destination_name = attributes.get(SPANDATA.MESSAGING_DESTINATION_NAME) + return ( + f"{operation_type} {destination_name}" + if destination_name + else operation_type + ) def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": attributes = _get_aws_arn_attributes( @@ -46,12 +53,13 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": if queue_url is not None: attributes[SPANDATA.AWS_SQS_QUEUE_URL] = queue_url - # `https://sqs.eu-north-1.amazonaws.com/123456789012/orders` => ['123456789012', 'orders'] - path = unquote(urlsplit(queue_url).path).strip("/").split("/") + with capture_internal_exceptions(): + # `https://sqs.eu-north-1.amazonaws.com/123456789012/orders` => ['123456789012', 'orders'] + path = unquote(urlsplit(queue_url).path).strip("/").split("/") - attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = path[-1] - if re.fullmatch(r"[0-9]{12}", path[0]): - attributes[SPANDATA.CLOUD_ACCOUNT_ID] = path[0] + attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = path[-1] + if re.fullmatch(r"[0-9]{12}", path[0]): + attributes[SPANDATA.CLOUD_ACCOUNT_ID] = path[0] elif "QueueName" in ctx.params: # `CreateQueue` & `GetQueueUrl` operations. attributes[SPANDATA.MESSAGING_DESTINATION_NAME] = ctx.params["QueueName"] From bb65d326f7a38b72fa12b41e97cb1e0cd55be0f1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 08:01:48 +0000 Subject: [PATCH 11/14] test(boto3): Round-trip SQS/SNS trace propagation Send a message, receive what was injected, and check that the receive span links to the send span. Covers SQS and an SNS notification delivered to SQS without raw message delivery. Co-Authored-By: johdax <114897+dingsdax@users.noreply.github.com> --- .../boto3/test_trace_propagation.py | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/integrations/boto3/test_trace_propagation.py b/tests/integrations/boto3/test_trace_propagation.py index 6df105007b..2c73ff7f2c 100644 --- a/tests/integrations/boto3/test_trace_propagation.py +++ b/tests/integrations/boto3/test_trace_propagation.py @@ -1,4 +1,5 @@ import copy +import json from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Thread from urllib.parse import parse_qs, urlparse @@ -356,3 +357,49 @@ def test_message_attributes_propagation_skipped( assert [message["MessageAttributes"] for message in sent] == [ attributes for _, attributes in messages ] + + +@pytest.mark.parametrize("service", ["sqs", "sns"]) +def test_message_attributes_round_trip(sentry_init, capture_items, service): + """A receive span links to the send span whose context the message carries.""" + publish_span, (sent,) = _send(sentry_init, capture_items, service, [("hi", {})]) + + if service == "sqs": + message = {"MessageAttributes": sent["MessageAttributes"], "Body": "hi"} + else: + # an SNS subscription without raw message delivery wraps the message and its + # attributes in a JSON notification. + notification = { + "Type": "Notification", + "MessageId": "sns-1", + "TopicArn": "arn:aws:sns:eu-north-1:123456789012:alerts", + "Message": "hi", + "MessageAttributes": { + name: {"Type": value["DataType"], "Value": value["StringValue"]} + for name, value in sent["MessageAttributes"].items() + }, + } + message = {"Body": json.dumps(notification)} + + client = boto3.client( # type: ignore[attr-defined] + "sqs", + region_name="eu-north-1", + aws_access_key_id="-", + aws_secret_access_key="-", + ) + with Stubber(client) as stubber: + stubber.add_response( + "receive_message", {"Messages": [{"MessageId": "1", **message}]} + ) + spans = capture_spans_by_op( + lambda: client.receive_message( + QueueUrl="https://sqs.eu-north-1.amazonaws.com/123456789012/orders" + ), + capture_items, + ) + + (receive_span,) = spans[OP.QUEUE_RECEIVE] + (link,) = receive_span["links"] + assert link["trace_id"] == publish_span["trace_id"] + assert link["span_id"] == publish_span["span_id"] + assert link["sampled"] is True From 48eae1cfb41695e21fd7ebec5664e8e646bf42f1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:31:53 +0000 Subject: [PATCH 12/14] ref(boto3): Guard SQS ARN and batch-count reads separately MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Botocore validates params after our hook, so a bad Entries or ARN value now only loses that attribute, not every SQS request attribute. Move the batch error.type code to _utils so SNS can share it, and link the SQS docs for BatchResultErrorEntry. Co-Authored-By: Johannes Daxböck <114897+dingsdax@users.noreply.github.com> --- .../integrations/boto3/_services/sqs.py | 33 +++++++++++-------- sentry_sdk/integrations/boto3/_utils.py | 15 +++++++++ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_services/sqs.py b/sentry_sdk/integrations/boto3/_services/sqs.py index 7a1bce2955..d488ee4456 100644 --- a/sentry_sdk/integrations/boto3/_services/sqs.py +++ b/sentry_sdk/integrations/boto3/_services/sqs.py @@ -4,7 +4,10 @@ from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3._services.base import _ServiceExtension -from sentry_sdk.integrations.boto3._utils import _get_aws_arn_attributes +from sentry_sdk.integrations.boto3._utils import ( + _get_aws_arn_attributes, + _get_batch_error_type, +) from sentry_sdk.utils import capture_internal_exceptions if TYPE_CHECKING: @@ -45,9 +48,14 @@ def get_span_name( ) def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": - attributes = _get_aws_arn_attributes( - (ctx.params.get("DestinationArn"), ctx.params.get("SourceArn")), ("sqs",) - ) + attributes: "Attributes" = {} + with capture_internal_exceptions(): + attributes.update( + _get_aws_arn_attributes( + (ctx.params.get("DestinationArn"), ctx.params.get("SourceArn")), + ("sqs",), + ) + ) queue_url = ctx.params.get("QueueUrl") if queue_url is not None: @@ -86,9 +94,10 @@ def get_request_attributes(self, ctx: "AwsCallContext") -> "Attributes": if operation_type == "send": attributes[SPANDATA.SENTRY_KIND] = "producer" if "Entries" in ctx.params: - attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] = len( - ctx.params["Entries"] - ) + with capture_internal_exceptions(): + attributes[SPANDATA.MESSAGING_BATCH_MESSAGE_COUNT] = len( + ctx.params["Entries"] + ) return attributes def get_response_attributes( @@ -109,11 +118,7 @@ def get_response_attributes( if len(messages) == 1: attributes[SPANDATA.MESSAGING_MESSAGE_ID] = messages[0]["MessageId"] - # failed entries have a `200 OK` response; use their code when they share one. - # https://docs.aws.amazon.com/sns/latest/api/API_BatchResultErrorEntry.html - error_codes = {entry["Code"] for entry in response.get("Failed", ())} - if len(error_codes) == 1: - attributes[SPANDATA.ERROR_TYPE] = error_codes.pop() - elif error_codes: - attributes[SPANDATA.ERROR_TYPE] = "aws_batch_partial_failure" + error_type = _get_batch_error_type(response) + if error_type is not None: + attributes[SPANDATA.ERROR_TYPE] = error_type return attributes diff --git a/sentry_sdk/integrations/boto3/_utils.py b/sentry_sdk/integrations/boto3/_utils.py index 9f124639a6..1d20103313 100644 --- a/sentry_sdk/integrations/boto3/_utils.py +++ b/sentry_sdk/integrations/boto3/_utils.py @@ -38,3 +38,18 @@ def _get_aws_arn_attributes( return attributes return {} + + +def _get_batch_error_type(response: "Any") -> "Optional[str]": + """ + `error.type` for a batch call whose response has `Failed` entries. + Failed entries come back in a `200 OK` response; use their code when they share one. + https://docs.aws.amazon.com/AWSSimpleQueueService/latest/APIReference/API_BatchResultErrorEntry.html + https://docs.aws.amazon.com/sns/latest/api/API_BatchResultErrorEntry.html + """ + error_codes = {entry["Code"] for entry in response.get("Failed", ())} + if len(error_codes) == 1: + return error_codes.pop() + if error_codes: + return "aws_batch_partial_failure" + return None From 54710b71f6488903dc074a1c5777c88d77eea178 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:33:35 +0000 Subject: [PATCH 13/14] ref(boto3): Share the batch error.type code with SQS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Johannes Daxböck <114897+dingsdax@users.noreply.github.com> --- sentry_sdk/consts.py | 1 + sentry_sdk/integrations/boto3/_services/sns.py | 15 +++++++-------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index 16caa3f96c..3b15e6aae4 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -476,6 +476,7 @@ class SPANDATA: The URL of the AWS SQS queue the request refers to. Example: "https://sqs.us-east-1.amazonaws.com/123456789012/orders" """ + AWS_SNS_TOPIC_ARN = "aws.sns.topic.arn" """ The ARN of the AWS SNS topic the request refers to. diff --git a/sentry_sdk/integrations/boto3/_services/sns.py b/sentry_sdk/integrations/boto3/_services/sns.py index b302d2693a..0e0da476b9 100644 --- a/sentry_sdk/integrations/boto3/_services/sns.py +++ b/sentry_sdk/integrations/boto3/_services/sns.py @@ -3,7 +3,10 @@ import sentry_sdk from sentry_sdk.consts import OP, SPANDATA from sentry_sdk.integrations.boto3._services.base import _ServiceExtension -from sentry_sdk.integrations.boto3._utils import _get_aws_arn_attributes +from sentry_sdk.integrations.boto3._utils import ( + _get_aws_arn_attributes, + _get_batch_error_type, +) if TYPE_CHECKING: from typing import Any, Optional @@ -106,11 +109,7 @@ def get_response_attributes( if ctx.operation_name == "Publish": attributes[SPANDATA.MESSAGING_MESSAGE_ID] = response["MessageId"] - # failed entries have a `200 OK` response; use their code when they share one. - # https://docs.aws.amazon.com/sns/latest/api/API_BatchResultErrorEntry.html - error_codes = {entry["Code"] for entry in response.get("Failed", ())} - if len(error_codes) == 1: - attributes[SPANDATA.ERROR_TYPE] = error_codes.pop() - elif error_codes: - attributes[SPANDATA.ERROR_TYPE] = "aws_batch_partial_failure" + error_type = _get_batch_error_type(response) + if error_type is not None: + attributes[SPANDATA.ERROR_TYPE] = error_type return attributes From 0966c93839f81defdd35393fd3fef7fee5527dc6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:06:34 +0000 Subject: [PATCH 14/14] ref(boto3): Remove duplicated span name override Co-Authored-By: Ivana Kellyer <131587164+sentrivana@users.noreply.github.com> --- sentry_sdk/integrations/boto3/_instrumentation.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/sentry_sdk/integrations/boto3/_instrumentation.py b/sentry_sdk/integrations/boto3/_instrumentation.py index 47dd9cf216..7ac437f51b 100644 --- a/sentry_sdk/integrations/boto3/_instrumentation.py +++ b/sentry_sdk/integrations/boto3/_instrumentation.py @@ -184,11 +184,6 @@ def _start_client_span( if service_span_name is not None: span_name = service_span_name - with capture_internal_exceptions(): - service_span_name = service_ext.get_span_name(ctx, attributes) - if service_span_name is not None: - span_name = service_span_name - # `start_span()` evaluates `ignore_spans` against the initial attributes. # https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/#rpc-client-span attributes.update(